diff --git a/scripts/ci/lib/notarization-ticket.sh b/scripts/ci/lib/notarization-ticket.sh new file mode 100755 index 000000000000..1fce6d5c711c --- /dev/null +++ b/scripts/ci/lib/notarization-ticket.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Architecture-aware checks supplement stapler's ticket validation. A ticket +# found by the host's CDHash can belong to a thin copy of a universal helper. + +CODESIGN_TOOL="${CMUX_CODESIGN_TOOL:-/usr/bin/codesign}" +LIPO_TOOL="${CMUX_LIPO_TOOL:-lipo}" + +# Prints arch=CDHash for every slice. Capture command results explicitly: a +# failing process substitution would otherwise let an empty slice set pass. +slice_cdhashes() { + local bundle="$1" executable architectures arch output hash + executable="$(python3 - "$bundle" <<'PY' +import pathlib, plistlib, sys +bundle = pathlib.Path(sys.argv[1]) +with (bundle / 'Contents/Info.plist').open('rb') as handle: + name = plistlib.load(handle)['CFBundleExecutable'] +if not name or pathlib.Path(name).name != name: + sys.exit('Invalid CFBundleExecutable') +print(bundle / 'Contents/MacOS' / name) +PY + )" || return 1 + architectures="$("$LIPO_TOOL" -archs "$executable")" || return 1 + if [ -z "$architectures" ]; then + echo "error: no architectures found in $executable" >&2 + return 1 + fi + architectures="$(printf '%s\n' "$architectures" | tr ' ' '\n' | sed '/^$/d' | sort -u)" + if [ -z "$architectures" ]; then + echo "error: empty architecture list for $executable" >&2 + return 1 + fi + while IFS= read -r arch; do + output="$("$CODESIGN_TOOL" -d -a "$arch" --verbose=4 "$bundle" 2>&1)" || { + printf '%s\n' "$output" >&2 + return 1 + } + hash="$(printf '%s\n' "$output" | sed -n 's/^CDHash=//p')" + if ! [[ "$hash" =~ ^[0-9a-f]{40}$ ]]; then + echo "error: invalid $arch CDHash for $bundle: $hash" >&2 + return 1 + fi + printf '%s=%s\n' "$arch" "$hash" + done <<< "$architectures" +} + +# Info.plist is hashed into each slice's CodeDirectory. A new nonce before +# signing separates submissions across variants, channels, and reruns without +# changing the helper's bundle identifier or designated requirement. +isolate_helper_submission() { + python3 - "$1/Contents/Info.plist" <<'PY' +import pathlib, plistlib, sys, uuid +path = pathlib.Path(sys.argv[1]) +data = path.read_bytes() +info = plistlib.loads(data) +info['CMUXNotarizationSubmission'] = str(uuid.uuid4()) +fmt = plistlib.FMT_BINARY if data.startswith(b'bplist00') else plistlib.FMT_XML +path.write_bytes(plistlib.dumps(info, fmt=fmt)) +PY +} + +verify_ticket_contents_cover_slices() { + local log_file="$1" bundle="$2" slices + slices="$(slice_cdhashes "$bundle")" || return 1 + python3 - "$log_file" "$bundle" "$slices" <<'PY' +import json, sys +log_file, bundle, slices = sys.argv[1:] +with open(log_file) as handle: + log = json.load(handle) +if log.get('status') != 'Accepted': + sys.exit(f'error: notarization log is not Accepted: {bundle}') +covered = {(entry.get('arch'), entry.get('cdhash')) for entry in log.get('ticketContents') or []} +for line in slices.splitlines(): + arch, cdhash = line.split('=', 1) + if (arch, cdhash) not in covered: + sys.exit(f'error: accepted ticket is missing {arch} CDHash {cdhash}: {bundle}') + print(f'accepted ticket covers {arch} CDHash {cdhash}: {bundle}') +PY +} + +# stapler validate remains mandatory to authenticate the ticket. This extra +# membership check catches a valid thin ticket attached to a universal bundle; +# it does not attempt to replace Apple's signature/ticket verification. +verify_stapled_ticket_covers_slices() { + local bundle="$1" slices + slices="$(slice_cdhashes "$bundle")" || return 1 + python3 - "$bundle" "$slices" <<'PY' +import pathlib, sys +bundle, slices = sys.argv[1:] +ticket = pathlib.Path(bundle) / 'Contents/CodeResources' +if not ticket.is_file(): + sys.exit(f'error: no stapled ticket: {bundle}') +data = ticket.read_bytes() +for line in slices.splitlines(): + arch, cdhash = line.split('=', 1) + if bytes.fromhex(cdhash) not in data: + sys.exit(f'error: stapled ticket is missing {arch} CDHash {cdhash}: {bundle}') + print(f'stapled ticket covers {arch} CDHash {cdhash}: {bundle}') +PY +} diff --git a/scripts/ci/notarize-computer-use-helper.sh b/scripts/ci/notarize-computer-use-helper.sh index 201e256a0cb6..155eb7402d54 100755 --- a/scripts/ci/notarize-computer-use-helper.sh +++ b/scripts/ci/notarize-computer-use-helper.sh @@ -15,7 +15,7 @@ usage: $0 [--start | --finish ] &1 \ - | awk -F= '/^CDHash=/ { print $2; exit }' +helper_cdhashes() { + slice_cdhashes "$HELPER_PATH" | paste -sd ',' - } submission_value() { @@ -137,6 +138,11 @@ start_submission() { exit 1 fi + # A signing timestamp does not change a slice CDHash. Isolate this + # submission before signing so thin and universal builds cannot retrieve + # each other's notarization tickets through a shared CDHash. + isolate_helper_submission "$HELPER_PATH" + # Give the helper its final Developer ID signature before upload. Later host # signing must use all-except-computer-use so this exact CDHash survives until # finish staples the ticket and re-seals only the outer app. @@ -148,7 +154,7 @@ start_submission() { --entitlements "$HELPER_ENTITLEMENTS" \ "$HELPER_PATH" "$CODESIGN_TOOL" --verify --strict --verbose=2 "$HELPER_PATH" - submitted_cdhash="$(helper_cdhash)" + submitted_cdhash="$(helper_cdhashes)" if [ -z "$submitted_cdhash" ]; then echo "Could not resolve Computer Use helper CDHash before notarization" >&2 exit 1 @@ -171,7 +177,7 @@ start_submission() { umask 077 { printf 'submission_id=%s\n' "$submit_id" - printf 'cdhash=%s\n' "$submitted_cdhash" + printf 'cdhashes=%s\n' "$submitted_cdhash" } > "$state_tmp" /bin/mv "$state_tmp" "$SUBMISSION_FILE" echo "Computer Use helper notarization submitted: $submit_id ($submit_status)" @@ -184,13 +190,14 @@ finish_submission() { exit 1 fi submit_id="$(submission_value submission_id)" - submitted_cdhash="$(submission_value cdhash)" + submitted_cdhash="$(submission_value cdhashes)" if [ -z "$submit_id" ] || [ -z "$submitted_cdhash" ]; then echo "Computer Use notarization state is incomplete: $SUBMISSION_FILE" >&2 exit 1 fi - current_cdhash="$(helper_cdhash)" + "$CODESIGN_TOOL" --verify --strict --verbose=2 "$HELPER_PATH" + current_cdhash="$(helper_cdhashes)" if [ "$current_cdhash" != "$submitted_cdhash" ]; then echo "Computer Use helper changed after notarization submission" >&2 echo " submitted CDHash: $submitted_cdhash" >&2 @@ -223,9 +230,12 @@ finish_submission() { "$XCRUN_TOOL" notarytool log "$submit_id" \ --apple-id "$APPLE_ID" \ --team-id "$APPLE_TEAM_ID" \ - --password "$APPLE_APP_SPECIFIC_PASSWORD" + --password "$APPLE_APP_SPECIFIC_PASSWORD" > "$TMP_DIR/notary-log.json" + cat "$TMP_DIR/notary-log.json" + verify_ticket_contents_cover_slices "$TMP_DIR/notary-log.json" "$HELPER_PATH" "$XCRUN_TOOL" stapler staple "$HELPER_PATH" "$XCRUN_TOOL" stapler validate "$HELPER_PATH" + verify_stapled_ticket_covers_slices "$HELPER_PATH" "$CODESIGN_TOOL" --verify --strict --verbose=2 "$HELPER_PATH" # Validate the same shape the runtime launches: a standalone copy outside the @@ -233,6 +243,7 @@ finish_submission() { mkdir -p "$STANDALONE_DIR" "$DITTO_TOOL" "$HELPER_PATH" "$STANDALONE_HELPER" "$XCRUN_TOOL" stapler validate "$STANDALONE_HELPER" + verify_stapled_ticket_covers_slices "$STANDALONE_HELPER" "$CODESIGN_TOOL" --verify --strict --verbose=2 "$STANDALONE_HELPER" assess_with_gatekeeper "$STANDALONE_HELPER" @@ -242,6 +253,7 @@ finish_submission() { "$SIGN_BUNDLE_TOOL" "$APP_PATH" "$APP_ENTITLEMENTS" "$SIGNING_IDENTITY" "$CODESIGN_TOOL" --verify --deep --strict --verbose=2 "$APP_PATH" "$XCRUN_TOOL" stapler validate "$HELPER_PATH" + verify_stapled_ticket_covers_slices "$HELPER_PATH" rm -f "$SUBMISSION_FILE" echo "Computer Use helper notarized and stapled: $HELPER_PATH" diff --git a/tests/test_notarize_computer_use_helper.py b/tests/test_notarize_computer_use_helper.py new file mode 100644 index 000000000000..43ee9f47388d --- /dev/null +++ b/tests/test_notarize_computer_use_helper.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Exercise helper submission, artifact identity, and release gates with fake Apple tools.""" +import json +import os +from pathlib import Path +import plistlib +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / 'scripts/ci/notarize-computer-use-helper.sh' +TOOL = r'''#!/usr/bin/env python3 +import hashlib, json, os, pathlib, shutil, sys +name = pathlib.Path(sys.argv[0]).name +args = sys.argv[1:] +root = pathlib.Path(os.environ['FIXTURE_ROOT']) +with (root / 'calls').open('a') as f: + f.write(json.dumps([name, *args]) + '\n') +helper = root / 'cmux.app/Contents/Library/cmux Computer Use.app' +def arches(): + return os.environ.get('FIXTURE_ARCHS', 'arm64 x86_64').split() +def cdhash(bundle, arch): + override = os.environ.get('FIXTURE_HASH_' + arch) + if override is not None: + return override + data = (pathlib.Path(bundle) / 'Contents/Info.plist').read_bytes() + return hashlib.sha256(data + arch.encode()).hexdigest()[:40] +if name == 'codesign': + if '-d' in args: + arch = args[args.index('-a') + 1] if '-a' in args else 'arm64' + if os.environ.get('FIXTURE_CODESIGN_FAIL'): + sys.exit(1) + print('CDHash=' + cdhash(args[-1], arch), file=sys.stderr) + elif '--verify' in args and os.environ.get('FIXTURE_VERIFY_FAIL'): + sys.exit(1) +elif name == 'lipo': + if os.environ.get('FIXTURE_LIPO_FAIL'): + sys.exit(1) + print(' '.join(arches())) +elif name == 'ditto': + if args[0] == '-c': + pathlib.Path(args[-1]).write_bytes(b'zip') + else: + shutil.copytree(args[0], args[1]) +elif name == 'xcrun': + if args[:2] == ['notarytool', 'submit']: + (root / 'submitted.json').write_text(json.dumps([ + {'arch': a, 'cdhash': cdhash(helper, a)} for a in arches() + ])) + print(json.dumps({'id': 'fixture-submission', 'status': 'In Progress'})) + elif args[:2] == ['notarytool', 'wait']: + print(json.dumps({'id': 'fixture-submission', 'status': os.environ.get('FIXTURE_NOTARY_STATUS', 'Accepted')})) + elif args[:2] == ['notarytool', 'log']: + entries = json.loads((root / 'submitted.json').read_text()) + entries = [e for e in entries if e['arch'] != os.environ.get('FIXTURE_LOG_OMIT')] + print(json.dumps({'status': 'Accepted', 'ticketContents': entries})) + elif args[:2] == ['stapler', 'staple']: + entries = json.loads((root / 'submitted.json').read_text()) + data = b'fixture-ticket' + b''.join(bytes.fromhex(e['cdhash']) for e in entries if e['arch'] != os.environ.get('FIXTURE_TICKET_OMIT')) + (pathlib.Path(args[-1]) / 'Contents/CodeResources').write_bytes(data) + elif args[:2] == ['stapler', 'validate'] and os.environ.get('FIXTURE_VALIDATE_FAIL'): + sys.exit(1) +elif name == 'spctl': + if '--ignore-cache' not in args or '--no-cache' not in args: + print('assessment cache was reused', file=sys.stderr) + sys.exit(2) + count_file = root / 'assessments' + count = int(count_file.read_text()) + 1 if count_file.exists() else 1 + count_file.write_text(str(count)) + if count <= int(os.environ.get('FIXTURE_REJECTS', '0')): + print('source=Unnotarized Developer ID', file=sys.stderr) + sys.exit(3) +elif name == 'sign-bundle': + if os.environ.get('CMUX_SIGN_MODE') != 'main-only': + sys.exit('helper must not be re-signed after stapling') +''' + + +class HelperNotarizationTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='cmux-notary-test-') + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.app = self.root / 'cmux.app' + self.helper = self.app / 'Contents/Library/cmux Computer Use.app' + (self.helper / 'Contents/MacOS').mkdir(parents=True) + (self.helper / 'Contents/MacOS/cmux-cua').write_bytes(b'fixture-executable') + (self.helper / 'Contents/Info.plist').write_bytes(plistlib.dumps({ + 'CFBundleExecutable': 'cmux-cua', 'CFBundleIdentifier': 'com.cmuxterm.cua', + })) + self.entitlements = self.root / 'entitlements.plist' + self.entitlements.write_bytes(plistlib.dumps({})) + self.state = self.root / 'submission.state' + # Authentication is stubbed; this credential has no account or network access. + self.env = dict(os.environ, FIXTURE_ROOT=str(self.root), + APPLE_ID='fixture@example.com', APPLE_TEAM_ID='FIXTURETEAM', + APPLE_APP_SPECIFIC_PASSWORD='fixture-password', # noqa: S106 # gitleaks:allow + CMUX_HELPER_ENTITLEMENTS=str(self.entitlements), + CMUX_GATEKEEPER_ASSESS_DELAY_SECONDS='0', + CMUX_GATEKEEPER_ASSESS_ATTEMPTS='3') + for tool in ('codesign', 'lipo', 'ditto', 'xcrun', 'spctl', 'sign-bundle'): + path = self.root / tool + path.write_text(TOOL) + path.chmod(0o755) + self.env['CMUX_' + tool.upper().replace('-', '_') + '_TOOL'] = str(path) + + def run_helper(self, *args, success=True, **env): + # Only the repository script and fixture arguments are executed, without a shell. + result = subprocess.run([str(SCRIPT), *map(str, args), str(self.app), # noqa: S603 + str(self.entitlements), 'Developer ID Application: Fixture'], + env=dict(self.env, **env), text=True, + capture_output=True, check=False) + self.assertEqual(result.returncode == 0, success, result.stdout + result.stderr) + return result + + def calls(self, tool, *prefix): + calls = [json.loads(line) for line in (self.root / 'calls').read_text().splitlines()] + return [c for c in calls if c[0] == tool and c[1:1 + len(prefix)] == list(prefix)] + + def test_submit_and_finish_preserve_ticket_and_reseal_outer_app(self): + self.run_helper('--start', self.state) + self.assertTrue(self.state.exists()) + self.assertFalse(self.calls('xcrun', 'notarytool', 'wait')) + self.assertFalse(self.calls('xcrun', 'stapler')) + self.assertNotIn('--wait', self.calls('xcrun', 'notarytool', 'submit')[0]) + submitted = (self.helper / 'Contents/Info.plist').read_bytes() + self.run_helper('--finish', self.state) + self.assertFalse(self.state.exists()) + self.assertEqual((self.helper / 'Contents/Info.plist').read_bytes(), submitted) + self.assertEqual(len(self.calls('xcrun', 'notarytool', 'submit')), 1) + self.assertEqual(len(self.calls('sign-bundle')), 1) + self.assertIn('/standalone/cmux Computer Use.app', self.calls('spctl')[0][-1]) + self.assertTrue((self.helper / 'Contents/CodeResources').exists()) + + def test_each_submission_has_distinct_signed_hashes(self): + self.run_helper('--start', self.state) + first = json.loads((self.root / 'submitted.json').read_text()) + self.run_helper('--start', self.root / 'another.state') + second = json.loads((self.root / 'submitted.json').read_text()) + self.assertTrue({e['cdhash'] for e in first}.isdisjoint(e['cdhash'] for e in second), + 'separate notarization submissions must not share any slice CDHash') + + def test_changed_non_native_slice_stops_before_wait(self): + self.run_helper('--start', self.state) + self.run_helper('--finish', self.state, success=False, FIXTURE_HASH_x86_64='a' * 40) + self.assertFalse(self.calls('xcrun', 'notarytool', 'wait')) + + def test_missing_or_broken_slice_discovery_stops_before_upload(self): + for env in ({'FIXTURE_ARCHS': ''}, {'FIXTURE_LIPO_FAIL': '1'}, + {'FIXTURE_CODESIGN_FAIL': '1'}, {'FIXTURE_HASH_arm64': 'invalid'}): + with self.subTest(env=env): + self.run_helper(success=False, **env) + self.assertFalse(self.calls('xcrun', 'notarytool', 'submit')) + + def test_incomplete_accepted_ticket_stops_before_staple(self): + self.run_helper(success=False, FIXTURE_LOG_OMIT='x86_64') + self.assertFalse(self.calls('xcrun', 'stapler', 'staple')) + self.assertFalse(self.calls('sign-bundle')) + + def test_incomplete_stapled_ticket_stops_before_gatekeeper(self): + self.run_helper(success=False, FIXTURE_TICKET_OMIT='x86_64') + self.assertFalse(self.calls('spctl')) + self.assertFalse(self.calls('sign-bundle')) + + def test_all_supported_architecture_sets(self): + for archs in ('arm64', 'x86_64', 'arm64 x86_64'): + with self.subTest(archs=archs): + self.run_helper(FIXTURE_ARCHS=archs) + + def test_rejected_notarization_stops_before_staple(self): + self.run_helper(success=False, FIXTURE_NOTARY_STATUS='Invalid') + self.assertFalse(self.calls('xcrun', 'stapler')) + self.assertTrue(self.calls('xcrun', 'notarytool', 'log')) + + def test_invalid_signature_or_ticket_cannot_reseal_host(self): + for env in ({'FIXTURE_VERIFY_FAIL': '1'}, {'FIXTURE_VALIDATE_FAIL': '1'}): + with self.subTest(env=env): + self.run_helper(success=False, **env) + self.assertFalse(self.calls('sign-bundle')) + + def test_gatekeeper_eventually_accepts(self): + self.run_helper(FIXTURE_REJECTS='2') + self.assertEqual(len(self.calls('spctl')), 3) + self.assertTrue(self.calls('sign-bundle')) + + def test_gatekeeper_rejection_still_blocks_release(self): + self.run_helper(success=False, FIXTURE_REJECTS='5') + self.assertEqual(len(self.calls('spctl')), 3) + self.assertFalse(self.calls('sign-bundle')) + + def test_existing_submission_cannot_be_overwritten(self): + self.run_helper('--start', self.state) + state = self.state.read_bytes() + self.run_helper('--start', self.state, success=False) + self.assertEqual(self.state.read_bytes(), state) + self.assertEqual(len(self.calls('xcrun', 'notarytool', 'submit')), 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_notarize_computer_use_helper.sh b/tests/test_notarize_computer_use_helper.sh index ebd7c68b6665..2a329335f53a 100755 --- a/tests/test_notarize_computer_use_helper.sh +++ b/tests/test_notarize_computer_use_helper.sh @@ -1,278 +1,4 @@ #!/usr/bin/env bash set -euo pipefail - ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -SCRIPT="$ROOT_DIR/scripts/ci/notarize-computer-use-helper.sh" -TMP_DIR="$(mktemp -d)" -trap 'rm -rf "$TMP_DIR"' EXIT - -if [ ! -x "$SCRIPT" ]; then - echo "FAIL: executable Computer Use helper notarization script is required" >&2 - exit 1 -fi - -APP="$TMP_DIR/cmux.app" -HELPER="$APP/Contents/Library/cmux Computer Use.app" -FAKE_BIN="$TMP_DIR/bin" -LOG="$TMP_DIR/calls.log" -mkdir -p "$HELPER/Contents/MacOS" "$FAKE_BIN" -printf 'signed-helper-fixture\n' > "$HELPER/Contents/MacOS/cmux-cua" - -cat > "$FAKE_BIN/ditto" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -printf 'ditto %s\n' "$*" >> "$CMUX_TEST_CALL_LOG" -if [ "${1:-}" = "-c" ]; then - output="${@: -1}" - printf 'zip-fixture\n' > "$output" -else - source_path="$1" - destination_path="$2" - cp -R "$source_path" "$destination_path" -fi -EOF - -cat > "$FAKE_BIN/xcrun" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -printf 'xcrun %s\n' "$*" >> "$CMUX_TEST_CALL_LOG" -if [ "${1:-}" = "notarytool" ] && [ "${2:-}" = "submit" ]; then - printf '{"id":"helper-fixture-id","status":"In Progress"}\n' -elif [ "${1:-}" = "notarytool" ] && [ "${2:-}" = "wait" ]; then - printf '{"id":"helper-fixture-id","status":"%s"}\n' \ - "${CMUX_TEST_NOTARY_STATUS:-Accepted}" -fi -EOF - -cat > "$FAKE_BIN/codesign" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -printf 'codesign %s\n' "$*" >> "$CMUX_TEST_CALL_LOG" -if [ "${1:-}" = "-d" ]; then - printf 'CDHash=%s\n' "${CMUX_TEST_CDHASH:-fixture-cdhash}" >&2 -fi -EOF - -cat > "$FAKE_BIN/spctl" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -printf 'spctl %s\n' "$*" >> "$CMUX_TEST_CALL_LOG" -# Gatekeeper keeps negative assessments in a code-directory cache. A fresh -# stapled ticket must be assessed without consulting or populating that cache. -if [ "${CMUX_TEST_SPCTL_REQUIRE_FRESH:-0}" = "1" ]; then - if [[ " $* " != *" --ignore-cache "* || " $* " != *" --no-cache "* ]]; then - echo "assessment cache was reused" >&2 - exit 2 - fi -fi -# Simulate Gatekeeper not yet seeing the notarization ticket: reject the first -# CMUX_TEST_SPCTL_REJECTS assessments, then accept. -count_file="${CMUX_TEST_SPCTL_COUNT_FILE:-}" -if [ -n "$count_file" ]; then - n=0; [ -f "$count_file" ] && n="$(cat "$count_file")" - n=$((n + 1)); printf '%s' "$n" > "$count_file" - if [ "$n" -le "${CMUX_TEST_SPCTL_REJECTS:-0}" ]; then - echo "$*: rejected" >&2 - echo "source=Unnotarized Developer ID" >&2 - exit 3 - fi -fi -EOF - -cat > "$FAKE_BIN/sign-bundle" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -printf 'sign-bundle mode=%s %s\n' "${CMUX_SIGN_MODE:-}" "$*" \ - >> "$CMUX_TEST_CALL_LOG" -EOF -chmod +x "$FAKE_BIN"/* - -run_helper() { - CMUX_TEST_CALL_LOG="$LOG" \ - CMUX_TEST_CDHASH="${CMUX_TEST_CDHASH:-fixture-cdhash}" \ - CMUX_DITTO_TOOL="$FAKE_BIN/ditto" \ - CMUX_XCRUN_TOOL="$FAKE_BIN/xcrun" \ - CMUX_CODESIGN_TOOL="$FAKE_BIN/codesign" \ - CMUX_SPCTL_TOOL="$FAKE_BIN/spctl" \ - CMUX_GATEKEEPER_ASSESS_DELAY_SECONDS=0 \ - CMUX_SIGN_BUNDLE_TOOL="$FAKE_BIN/sign-bundle" \ - APPLE_ID=fixture@example.com \ - APPLE_APP_SPECIFIC_PASSWORD=fixture-password \ - APPLE_TEAM_ID=FIXTURETEAM \ - "$SCRIPT" \ - "$@" \ - "$APP" \ - "$TMP_DIR/cmux.release.entitlements" \ - 'Developer ID Application: Fixture' -} - -: > "$TMP_DIR/cmux.release.entitlements" -run_helper - -if [ "$(grep -c '^xcrun notarytool submit ' "$LOG")" -ne 1 ]; then - echo "FAIL: helper must have exactly one independent notarization submission" >&2 - exit 1 -fi -if ! grep -Fq "ditto -c -k --sequesterRsrc --keepParent $HELPER" "$LOG"; then - echo "FAIL: nested helper was not packaged as the notarization payload" >&2 - exit 1 -fi - -line_of() { - grep -nF "$1" "$LOG" | head -n 1 | cut -d: -f1 -} -submit_line="$(line_of "xcrun notarytool submit")" -helper_sign_line="$(line_of "codesign --force --options runtime --timestamp --sign Developer ID Application: Fixture")" -wait_line="$(line_of "xcrun notarytool wait helper-fixture-id")" -staple_line="$(line_of "xcrun stapler staple $HELPER")" -validate_line="$(line_of "xcrun stapler validate $HELPER")" -reseal_line="$(line_of "sign-bundle mode=main-only")" -host_verify_line="$(line_of "codesign --verify --deep --strict --verbose=2 $APP")" -if ! [ "$helper_sign_line" -lt "$submit_line" ] \ - || ! [ "$submit_line" -lt "$wait_line" ] \ - || ! [ "$wait_line" -lt "$staple_line" ] \ - || ! [ "$staple_line" -lt "$validate_line" ] \ - || ! [ "$validate_line" -lt "$reseal_line" ] \ - || ! [ "$reseal_line" -lt "$host_verify_line" ]; then - echo "FAIL: helper notarization, stapling, and outer resealing ran out of order" >&2 - exit 1 -fi -if ! grep -Eq '^spctl -a -vv --ignore-cache --no-cache --type execute .*/standalone/cmux Computer Use\.app$' "$LOG"; then - echo "FAIL: independently copied helper did not pass the Gatekeeper check" >&2 - exit 1 -fi - -# CI starts the upload before the rest of release preparation, then waits only -# at the outer-signing boundary. The start phase must return without polling or -# mutating the helper, and finish must operate on that exact submitted CDHash. -STATE_FILE="$TMP_DIR/helper-notarization.state" -: > "$LOG" -run_helper --start "$STATE_FILE" -if [ ! -s "$STATE_FILE" ]; then - echo "FAIL: start phase did not persist the notarization submission" >&2 - exit 1 -fi -if ! grep -Fq 'xcrun notarytool submit ' "$LOG" \ - || grep -Fq 'xcrun notarytool wait ' "$LOG" \ - || grep -Fq 'xcrun stapler staple ' "$LOG" \ - || grep -Fq 'sign-bundle mode=main-only' "$LOG"; then - echo "FAIL: start phase waited for or finalized helper notarization" >&2 - exit 1 -fi -if grep -F 'xcrun notarytool submit ' "$LOG" | grep -Fq -- ' --wait'; then - echo "FAIL: start phase used a blocking notary submission" >&2 - exit 1 -fi -printf 'parallel-release-work\n' >> "$LOG" -run_helper --finish "$STATE_FILE" -parallel_line="$(line_of 'parallel-release-work')" -wait_line="$(line_of 'xcrun notarytool wait helper-fixture-id')" -if ! [ "$parallel_line" -lt "$wait_line" ] \ - || ! grep -Fq "xcrun stapler staple $HELPER" "$LOG" \ - || ! grep -Fq 'sign-bundle mode=main-only' "$LOG"; then - echo "FAIL: finish phase did not resume after parallel release work" >&2 - exit 1 -fi -if [ -e "$STATE_FILE" ]; then - echo "FAIL: completed helper notarization left a reusable state file" >&2 - exit 1 -fi - -: > "$LOG" -run_helper --start "$STATE_FILE" -if CMUX_TEST_CDHASH=changed-cdhash run_helper --finish "$STATE_FILE"; then - echo "FAIL: finish accepted a helper that changed after submission" >&2 - exit 1 -fi -if grep -Fq 'xcrun notarytool wait ' "$LOG" \ - || grep -Fq 'xcrun stapler staple ' "$LOG"; then - echo "FAIL: changed helper reached notarization wait or stapling" >&2 - exit 1 -fi - -: > "$LOG" -if CMUX_TEST_NOTARY_STATUS=Invalid run_helper; then - echo "FAIL: rejected helper notarization unexpectedly succeeded" >&2 - exit 1 -fi -if grep -Fq 'xcrun stapler staple' "$LOG"; then - echo "FAIL: rejected helper must not be stapled" >&2 - exit 1 -fi -if grep -Fq 'sign-bundle mode=main-only' "$LOG"; then - echo "FAIL: rejected helper must not reseal the outer app" >&2 - exit 1 -fi -if ! grep -Fq 'xcrun notarytool log helper-fixture-id' "$LOG"; then - echo "FAIL: rejected helper notarization did not retrieve its diagnostic log" >&2 - exit 1 -fi - -# Gatekeeper may not see a fresh ticket immediately after stapling. The -# assessment polls: two rejections then acceptance must still succeed, and the -# accepted assessment must be the one that ends the poll. -: > "$LOG" -rm -f "$TMP_DIR/spctl-count" -if ! CMUX_TEST_SPCTL_COUNT_FILE="$TMP_DIR/spctl-count" CMUX_TEST_SPCTL_REJECTS=2 run_helper >"$TMP_DIR/poll.out" 2>&1; then - echo "FAIL: helper notarization gave up while the Gatekeeper ticket was still propagating" >&2 - exit 1 -fi -if [ "$(grep -c '^spctl -a -vv --ignore-cache --no-cache --type execute .*/standalone/cmux Computer Use\.app$' "$LOG")" -ne 3 ]; then - echo "FAIL: expected three Gatekeeper assessments (two rejected, one accepted)" >&2 - exit 1 -fi - -# The first rejection announces the whole budget so a log reader can tell a -# propagation wait from a hang. -if ! grep -Eq '^Gatekeeper propagation budget: [0-9]+ attempts x [0-9]+s \(about [0-9]+ minutes\)$' "$TMP_DIR/poll.out"; then - echo "FAIL: Gatekeeper polling must announce its attempt budget on the first rejection" >&2 - exit 1 -fi - -# The default budget must cover Apple's CDN propagation tail for a stable -# release: nightly run 34208928547 (2026-09-08) was still rejected 4m50s -# after notarytool reported Accepted and failed on a 20 x 15s budget. Keep the -# default at twenty minutes or more, polled often enough that a landed ticket -# is noticed within half a minute, and keep both knobs env-configurable. -default_attempts="$(sed -n 's/^GATEKEEPER_ASSESS_ATTEMPTS="\${CMUX_GATEKEEPER_ASSESS_ATTEMPTS:-\([0-9][0-9]*\)}"$/\1/p' "$SCRIPT")" -default_delay="$(sed -n 's/^GATEKEEPER_ASSESS_DELAY_SECONDS="\${CMUX_GATEKEEPER_ASSESS_DELAY_SECONDS:-\([0-9][0-9]*\)}"$/\1/p' "$SCRIPT")" -if ! [[ "$default_attempts" =~ ^[0-9]+$ && "$default_delay" =~ ^[0-9]+$ ]]; then - echo "FAIL: Gatekeeper attempt and delay defaults must be env-configurable numeric literals (got '$default_attempts' x '$default_delay')" >&2 - exit 1 -fi -if (( default_attempts * default_delay < 1200 )); then - echo "FAIL: default Gatekeeper propagation budget is $((default_attempts * default_delay))s; a stable release needs at least 1200s" >&2 - exit 1 -fi -if (( default_delay > 30 )); then - echo "FAIL: Gatekeeper poll interval ${default_delay}s is too coarse; poll at least every 30s" >&2 - exit 1 -fi - -# A ticket that never propagates within the budget still fails the release. -: > "$LOG" -rm -f "$TMP_DIR/spctl-count" -if CMUX_TEST_SPCTL_COUNT_FILE="$TMP_DIR/spctl-count" CMUX_TEST_SPCTL_REJECTS=5 CMUX_GATEKEEPER_ASSESS_ATTEMPTS=3 run_helper >/dev/null 2>&1; then - echo "FAIL: helper notarization passed although Gatekeeper never accepted the helper" >&2 - exit 1 -fi -if [ "$(grep -c '^spctl -a -vv --ignore-cache --no-cache --type execute .*/standalone/cmux Computer Use\.app$' "$LOG")" -ne 3 ]; then - echo "FAIL: Gatekeeper assessment must stop after the attempt budget" >&2 - exit 1 -fi - -# Regression: a stale negative assessment must not make a valid stapled helper -# fail just because the same CDHash was assessed before stapling. The fake -# Gatekeeper rejects any assessment that does not opt out of its cache. -: > "$LOG" -if ! CMUX_TEST_SPCTL_REQUIRE_FRESH=1 CMUX_GATEKEEPER_ASSESS_ATTEMPTS=1 run_helper >"$TMP_DIR/fresh-assessment.out" 2>&1; then - echo "FAIL: Gatekeeper assessment reused a stale negative cache entry" >&2 - cat "$TMP_DIR/fresh-assessment.out" >&2 - exit 1 -fi -if ! grep -Eq '^spctl -a -vv --ignore-cache --no-cache --type execute .*/standalone/cmux Computer Use\.app$' "$LOG"; then - echo "FAIL: Gatekeeper assessment did not bypass its cache" >&2 - exit 1 -fi - -echo "PASS: Computer Use helper is independently notarized before outer resealing" +exec python3 "$ROOT_DIR/tests/test_notarize_computer_use_helper.py" "$@"