diff --git a/scripts/classify_archive_failure.py b/scripts/classify_archive_failure.py new file mode 100644 index 0000000..64e491e --- /dev/null +++ b/scripts/classify_archive_failure.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Emit only fixed hints from a bounded private Xcode archive log.""" +import os +import stat +import sys + +MAX_BYTES = 8 * 1024 * 1024 +PATTERNS = { + 'missing-profile': (('no profiles for ', ' were found'), ('requires a provisioning profile',)), + 'missing-certificate': (('no signing certificate ', ' found'), ('no certificate for team ', ' matching')), + 'profile-certificate-mismatch': (('provisioning profile ', "doesn't include signing certificate"),), + 'signing-conflict': (('conflicting provisioning settings',), ('does not support automatic signing',)), + 'missing-team': (('requires a development team',),), + 'swift-compile': (('swift compiler error',), ('swift compile error',), ('.swift:', ': error:')), + 'linker': (('linker command failed',), ('undefined symbols for architecture',)), + 'missing-input': (('build input file cannot be found',),), +} + +def classify(path): + flags = os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0) + try: + fd = os.open(path, flags) + with os.fdopen(fd, 'rb') as source: + info = os.fstat(source.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_BYTES: + return 'unavailable' + data = source.read(MAX_BYTES + 1) + if len(data) > MAX_BYTES: + return 'unavailable' + lines = data.decode('utf-8', 'replace').lower().splitlines() + found = [label for label, groups in PATTERNS.items() + if any(all(phrase in line for phrase in group) + for line in lines for group in groups)] + return found[0] if len(found) == 1 else 'multiple' if found else 'unclassified' + except (OSError, ValueError): + return 'unavailable' + +def main(): + category = classify(sys.argv[1]) if len(sys.argv) == 2 else 'unavailable' + print('Kin Dev archive diagnostic hint: ' + category) + +if __name__ == '__main__': + main() diff --git a/scripts/kin-sign-dev-review.sh b/scripts/kin-sign-dev-review.sh index 8254ebb..730475a 100644 --- a/scripts/kin-sign-dev-review.sh +++ b/scripts/kin-sign-dev-review.sh @@ -17,6 +17,9 @@ report_failure() { trap - EXIT if (( status != 0 )); then printf "Kin Dev signing failed at stage: %s\n" "$stage" >&3 + if [[ "$stage" == archive ]]; then + python3 scripts/classify_archive_failure.py "$work/archive.log" >&3 2>/dev/null || true + fi fi exit "$status" } diff --git a/scripts/tests/test_archive_hint_integration.py b/scripts/tests/test_archive_hint_integration.py new file mode 100644 index 0000000..0006fb6 --- /dev/null +++ b/scripts/tests/test_archive_hint_integration.py @@ -0,0 +1,41 @@ +"""Run the actual Dev trap and cleanup with disposable fake tools only.""" +from pathlib import Path +import subprocess +import unittest +import test_hosted_sign_failures as baseline + +HERE = Path(__file__).resolve().parents[1] + +class ArchiveHintIntegrationTests(unittest.TestCase): + def setUp(self): + self.fx = baseline.SignFailureTests('runTest') + self.fx.setUp(); self.addCleanup(self.fx.doCleanups) + self.root = self.fx.root + (self.root / 'export-test.sh').write_bytes((HERE / 'kin-sign-dev-review.sh').read_bytes()) + (self.root / 'cleanup-test.sh').write_bytes((HERE / 'kin-sign-dev-cleanup.sh').read_bytes()) + self.classifier = self.root / 'scripts/classify_archive_failure.py' + self.classifier.write_bytes((HERE / 'classify_archive_failure.py').read_bytes()) + tool = self.fx.bin / 'xcodebuild' + tool.write_text(tool.read_text().replace('then exit 17; fi', 'then printf "No profiles for private-app were found secret-password /private/path\\n" >&2; exit 17; fi')) + self.env = dict(self.fx.env, KIN_DEV_SIGNING_ONLY='1', FAKE_STAGE='archive') + + def exercise(self, classifier_fails): + if classifier_fails: + self.classifier.write_text('raise RuntimeError("secret-password /private/path")\n') + r = subprocess.run(['bash', str(self.root / 'export-test.sh')], env=self.env, cwd=self.root, capture_output=True, timeout=10) + self.assertEqual(r.returncode, 17) + self.assertIn(b'Kin Dev signing failed at stage: archive', r.stderr) + self.assertNotIn(b'secret-password', r.stdout + r.stderr) + self.assertNotIn(b'/private/path', r.stdout + r.stderr) + if classifier_fails: + self.assertNotIn(b'archive diagnostic hint:', r.stderr) + else: + self.assertIn(b'Kin Dev archive diagnostic hint: missing-profile', r.stderr) + cleanup = subprocess.run(['bash', str(self.root / 'cleanup-test.sh')], env=self.env, cwd=self.root, capture_output=True, timeout=10) + self.assertEqual(cleanup.returncode, 0, cleanup.stderr) + self.assertEqual(list(self.fx.runner.iterdir()), []) + + def test_hint_and_owned_cleanup(self): self.exercise(False) + def test_classifier_crash_preserves_failure_and_cleanup(self): self.exercise(True) + +if __name__ == '__main__': unittest.main() diff --git a/scripts/tests/test_classify_archive_failure.py b/scripts/tests/test_classify_archive_failure.py new file mode 100644 index 0000000..2e7b2c1 --- /dev/null +++ b/scripts/tests/test_classify_archive_failure.py @@ -0,0 +1,59 @@ +import importlib.util +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location('category', ROOT / 'scripts/classify_archive_failure.py') +p = importlib.util.module_from_spec(spec) +spec.loader.exec_module(p) + +class ArchiveCategoryTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.path = Path(self.tmp.name) / 'private.log' + + def test_fixed_categories_and_no_raw_output(self): + examples = { + 'No profiles for private-app were found: secret-path': 'missing-profile', + 'No signing certificate private-label found: secret-path': 'missing-certificate', + "Provisioning profile private-label doesn't include signing certificate secret-path": 'profile-certificate-mismatch', + 'conflicting provisioning settings secret-path': 'signing-conflict', + 'requires a development team secret-path': 'missing-team', + '/private/source.swift:7:4: error: secret-token': 'swift-compile', + 'linker command failed secret-path': 'linker', + 'build input file cannot be found secret-path': 'missing-input', + 'arbitrary secret-path': 'unclassified', + 'requires a development team\nconflicting provisioning settings': 'multiple', + } + for raw, expected in examples.items(): + with self.subTest(expected=expected): + self.path.write_text(raw) + self.assertEqual(p.classify(self.path), expected) + result = subprocess.run(['python3', str(ROOT / 'scripts/classify_archive_failure.py'), str(self.path)], capture_output=True, text=True) + self.assertEqual(result.stdout, 'Kin Dev archive diagnostic hint: ' + expected + '\n') + self.assertNotIn('secret', result.stdout + result.stderr) + self.assertNotIn('/private/', result.stdout + result.stderr) + self.assertNotIn(str(self.path), result.stdout + result.stderr) + + def test_missing_symlink_oversized_fail_closed(self): + self.assertEqual(p.classify(self.path), 'unavailable') + self.path.write_bytes(b'x' * (p.MAX_BYTES + 1)) + self.assertEqual(p.classify(self.path), 'unavailable') + self.path.unlink() + self.path.symlink_to('/dev/null') + self.assertEqual(p.classify(self.path), 'unavailable') + self.path.unlink() + os.mkfifo(self.path) + self.assertEqual(p.classify(self.path), 'unavailable') + + def test_signer_retains_original_exit_and_archive_only(self): + script = (ROOT / 'scripts/kin-sign-dev-review.sh').read_text() + self.assertIn('if [[ "$stage" == archive ]]; then', script) + self.assertIn('classify_archive_failure.py "$work/archive.log" >&3 2>/dev/null || true', script) + self.assertIn('exit "$status"', script) + +if __name__ == '__main__': unittest.main()