Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions scripts/classify_archive_failure.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 3 additions & 0 deletions scripts/kin-sign-dev-review.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
41 changes: 41 additions & 0 deletions scripts/tests/test_archive_hint_integration.py
Original file line number Diff line number Diff line change
@@ -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()
59 changes: 59 additions & 0 deletions scripts/tests/test_classify_archive_failure.py
Original file line number Diff line number Diff line change
@@ -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()
Loading