From 6b74cf6ef75741309f1c37911e50356e73cab005 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 1 Sep 2026 00:56:56 +0800 Subject: [PATCH] Prevent metadata leaks in cask ZIPs Why: - Default ditto packaging can serialize signed-app metadata as AppleDouble entries while filtered inspection misses the unsafe payload. Changed: - Package with explicit metadata-suppression flags, validate a hidden app-only candidate through Python zipfile, and publish normalized ZIP/checksum outputs only after validation. - Add deterministic portable and macOS regressions plus manifest and specification contracts for the defense. Verification: - npm test - npm run agent:verify -- --profile implementation - npm run agent:verify -- --profile spec-only - npm run verify:public-surface - Independent cask regression, public-source, and exact-diff reviews Affected: - Cask ZIP packaging and final-candidate validation - Documentation regressions, verification routing, and packaging specifications Refs: - RR23 cask ZIP metadata regression Session: - task-sessions/rr23-cask-zip-metadata-20260901 --- .../manifests/implementation-foundation.yaml | 4 + .agents/manifests/specs.yaml | 6 +- docs/test/front-door.test.mjs | 256 +++++++++++++++++- scripts/package_cask_zip.sh | 155 ++++++++--- scripts/validate_cask_zip.py | 110 ++++++++ spec/build-and-test.md | 47 +++- spec/project-structure.md | 3 +- 7 files changed, 527 insertions(+), 54 deletions(-) create mode 100644 scripts/validate_cask_zip.py diff --git a/.agents/manifests/implementation-foundation.yaml b/.agents/manifests/implementation-foundation.yaml index ee67acf..82dc7e8 100644 --- a/.agents/manifests/implementation-foundation.yaml +++ b/.agents/manifests/implementation-foundation.yaml @@ -7,10 +7,12 @@ globs: - scripts/install_local.sh - scripts/install_smoke.sh - scripts/installed_app_smoke_evidence.mjs + - scripts/package_cask_zip.sh - scripts/package_distribution.sh - scripts/package_local.sh - scripts/package_smoke.sh - scripts/test_app.sh + - scripts/validate_cask_zip.py - scripts/validate_cli_tar.mjs owner: ios-dev required_skills: @@ -33,10 +35,12 @@ allowed_paths: - scripts/install_local.sh - scripts/install_smoke.sh - scripts/installed_app_smoke_evidence.mjs + - scripts/package_cask_zip.sh - scripts/package_distribution.sh - scripts/package_local.sh - scripts/package_smoke.sh - scripts/test_app.sh + - scripts/validate_cask_zip.py - scripts/validate_cli_tar.mjs - spec/** - .agents/** diff --git a/.agents/manifests/specs.yaml b/.agents/manifests/specs.yaml index 6074926..b9b4df6 100644 --- a/.agents/manifests/specs.yaml +++ b/.agents/manifests/specs.yaml @@ -19,7 +19,6 @@ globs: - Formula/** - packages/simbroker/** - scripts/package_cli.sh - - scripts/package_cask_zip.sh - scripts/package_npm.sh - scripts/sync_homebrew_tap.sh owner: spec-steward @@ -53,10 +52,11 @@ allowed_paths: - Casks/** - Formula/** - packages/simbroker/** - - scripts/package_cli.sh - - scripts/validate_cli_tar.mjs - scripts/package_cask_zip.sh + - scripts/package_cli.sh - scripts/package_npm.sh + - scripts/validate_cask_zip.py + - scripts/validate_cli_tar.mjs - scripts/sync_homebrew_tap.sh forbidden_paths: [] required_evidence: diff --git a/docs/test/front-door.test.mjs b/docs/test/front-door.test.mjs index 26d1ea8..73c6018 100644 --- a/docs/test/front-door.test.mjs +++ b/docs/test/front-door.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -28,6 +29,119 @@ const customReleaseAssetTemplates = [ "Simulator-Broker-.zip", ]; +const caskZipRoot = "Simulator Broker.app"; + +function writeCaskZipFixture(entries, { archiveComment = "" } = {}) { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "simbroker-cask-zip-fixture-")); + const fixture = path.join(fixtureRoot, "fixture.zip"); + const result = spawnSync( + "python3", + [ + "-c", + `import json, sys, zipfile +entries = json.loads(sys.argv[2]) +with zipfile.ZipFile(sys.argv[1], "w") as archive: + archive.comment = sys.argv[3].encode("utf-8") + for item in entries: + info = zipfile.ZipInfo(item["name"]) + info.create_system = 3 + info.external_attr = (item["mode"] & 0xffff) << 16 + info.compress_type = zipfile.ZIP_STORED + info.comment = item.get("comment", "").encode("utf-8") + archive.writestr(info, item.get("data", "").encode("utf-8")) +`, + fixture, + JSON.stringify(entries), + archiveComment, + ], + { encoding: "utf8" }, + ); + assert.equal(result.status, 0, result.stdout + result.stderr); + return fixture; +} + +function runCaskZipValidator(fixture) { + return spawnSync( + "python3", + [path.join(repoRoot, "scripts/validate_cask_zip.py"), fixture, caskZipRoot], + { encoding: "utf8" }, + ); +} + +function basicCaskZipEntries(extraEntries = []) { + return [ + { name: `${caskZipRoot}/`, mode: 0o040755 }, + { name: `${caskZipRoot}/Contents/`, mode: 0o040755 }, + { data: "plist-marker", name: `${caskZipRoot}/Contents/Info.plist`, mode: 0o100644 }, + { name: `${caskZipRoot}/Contents/MacOS/`, mode: 0o040755 }, + { data: "executable", name: `${caskZipRoot}/Contents/MacOS/SimulatorBrokerApp`, mode: 0o100755 }, + { data: "resources", name: `${caskZipRoot}/Contents/CodeResources`, mode: 0o100644 }, + { name: `${caskZipRoot}/Contents/_CodeSignature/`, mode: 0o040755 }, + { data: "seal", name: `${caskZipRoot}/Contents/_CodeSignature/CodeResources`, mode: 0o100644 }, + ...extraEntries, + ]; +} + +function makeCaskAppFixture(prefix) { + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const app = path.join(testRoot, "Simulator Broker.app"); + const outputDir = path.join(testRoot, "output"); + const fakeBin = path.join(testRoot, "fake-bin"); + fs.mkdirSync(path.join(app, "Contents", "MacOS"), { recursive: true }); + fs.mkdirSync(path.join(app, "Contents", "Resources"), { recursive: true }); + fs.mkdirSync(path.join(app, "Contents", "_CodeSignature"), { recursive: true }); + fs.mkdirSync(outputDir); + fs.mkdirSync(fakeBin); + fs.writeFileSync( + path.join(app, "Contents", "Info.plist"), + ` + + + CFBundleExecutableSimulatorBrokerApp + CFBundleIdentifierdev.codex.simulator-broker-app + CFBundleNameSimulator Broker + +`, + ); + const executable = path.join(app, "Contents", "MacOS", "SimulatorBrokerApp"); + fs.writeFileSync(executable, "fixture executable\n"); + fs.chmodSync(executable, 0o755); + fs.writeFileSync(path.join(app, "Contents", "_CodeSignature", "CodeResources"), "fixture seal\n"); + fs.writeFileSync(path.join(app, "Contents", "CodeResources"), "fixture resources\n"); + const metadataFile = path.join(app, "Contents", "Resources", "metadata.txt"); + fs.writeFileSync(metadataFile, "fixture metadata\n"); + return { app, fakeBin, metadataFile, outputDir, testRoot }; +} + +function writeCaskVerificationShims(fakeBin) { + fs.writeFileSync( + path.join(fakeBin, "codesign"), + `#!/usr/bin/env bash +set -euo pipefail +if [[ "\${1:-}" == "--verify" ]]; then + exit 0 +fi +if [[ "\${1:-}" == "-dv" ]]; then + printf '%s\n' 'Authority=Developer ID Application: Test Signer (TEAMID)' >&2 + printf '%s\n' 'Identifier=dev.codex.simulator-broker-app' >&2 + exit 0 +fi +exit 1 +`, + { mode: 0o755 }, + ); + fs.writeFileSync( + path.join(fakeBin, "xcrun"), + `#!/usr/bin/env bash +set -euo pipefail +[[ "\${1:-}" == "stapler" ]] +[[ "\${2:-}" == "validate" ]] +exit 0 +`, + { mode: 0o755 }, + ); +} + function assertPortableCliTarEntries(entries) { return assertPortableCliTarEntriesForRoot(entries, cliArchiveDirectory); } @@ -1258,6 +1372,49 @@ exec "$SIMBROKER_TEST_REAL_TAR" "$@" assert.deepEqual(fs.readdirSync(outputDir), []); }); +test("SB-PKG-CASK-001 cask ZIP validation rejects metadata and unsafe structure", () => { + const valid = writeCaskZipFixture(basicCaskZipEntries()); + assert.equal(runCaskZipValidator(valid).status, 0); + const forbiddenEntryCases = [ + [`${caskZipRoot}/Contents/._CodeResources`, 0o100644], + [`${caskZipRoot}/__MACOSX/metadata`, 0o100644], + ["Other.app/payload", 0o100644], + [`${caskZipRoot}/../Other.app/payload`, 0o100644], + [`${caskZipRoot}\\Contents\\payload`, 0o100644], + [`${caskZipRoot}/Contents/Fifo`, 0o010644], + [`${caskZipRoot}/Contents/OtherLink`, 0o120755], + ]; + for (const [name, mode] of forbiddenEntryCases) { + const result = runCaskZipValidator(writeCaskZipFixture(basicCaskZipEntries([{ name, mode }]))); + assert.notEqual(result.status, 0, name); + assert.equal(result.stderr.includes(repoRoot), false, result.stderr); + } + + const duplicate = writeCaskZipFixture([ + ...basicCaskZipEntries(), + { name: `${caskZipRoot}/Contents/Info.plist`, mode: 0o100644 }, + ]); + assert.notEqual(runCaskZipValidator(duplicate).status, 0); + assert.notEqual( + runCaskZipValidator(writeCaskZipFixture(basicCaskZipEntries(), { archiveComment: "comment" })).status, + 0, + ); + assert.notEqual( + runCaskZipValidator(writeCaskZipFixture(basicCaskZipEntries([ + { comment: "comment", name: `${caskZipRoot}/Contents/commented`, mode: 0o100644 }, + ]))).status, + 0, + ); + + const corrupt = writeCaskZipFixture(basicCaskZipEntries()); + const corruptBytes = fs.readFileSync(corrupt); + const markerOffset = corruptBytes.indexOf(Buffer.from("plist-marker")); + assert.ok(markerOffset >= 0); + corruptBytes[markerOffset] ^= 0xff; + fs.writeFileSync(corrupt, corruptBytes); + assert.notEqual(runCaskZipValidator(corrupt).status, 0); +}); + test("package_cask_zip.sh is the Homebrew cask zip path", () => { const script = readRepoFile("scripts/package_cask_zip.sh"); const pkg = JSON.parse(readRepoFile("package.json")); @@ -1265,7 +1422,8 @@ test("package_cask_zip.sh is the Homebrew cask zip path", () => { const gettingStarted = readRepoFile("docs/getting-started.md"); assert.ok(pkg.scripts["package:cask-zip"].includes("package_cask_zip.sh")); - assert.ok(script.includes("ditto -c -k --keepParent")); + assert.ok(script.includes("ditto -c -k --norsrc --noextattr --noacl --noqtn --keepParent")); + assert.ok(script.includes("scripts/validate_cask_zip.py")); assert.ok(script.includes("Simulator Broker.app")); assert.ok(script.includes("Developer ID Application")); assert.ok(script.includes("dev.codex.simulator-broker-app")); @@ -1295,6 +1453,102 @@ test("package_cask_zip.sh is the Homebrew cask zip path", () => { assert.ok(gettingStarted.includes("npm run package:cask-zip")); }); +test("package_cask_zip.sh suppresses planted macOS metadata without mutating the signed app", { + skip: process.platform !== "darwin", +}, () => { + const { app, fakeBin, metadataFile, outputDir } = makeCaskAppFixture( + "simbroker-package-cask-zip-real-ditto-", + ); + writeCaskVerificationShims(fakeBin); + const plantedXattr = spawnSync("xattr", ["-w", "dev.codex.simulator-broker.test", "value", metadataFile], { + encoding: "utf8", + }); + assert.equal(plantedXattr.status, 0, plantedXattr.stderr); + const plantedResourceFork = spawnSync( + "xattr", + ["-wx", "com.apple.ResourceFork", "00010203", metadataFile], + { encoding: "utf8" }, + ); + assert.equal(plantedResourceFork.status, 0, plantedResourceFork.stderr); + const env = { + ...process.env, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`, + }; + delete env.COPYFILE_DISABLE; + delete env.DITTONORSRC; + + const result = spawnSync( + "bash", + [path.join(repoRoot, "scripts/package_cask_zip.sh"), "--app", app, "--output-dir", outputDir], + { cwd: repoRoot, encoding: "utf8", env }, + ); + assert.equal(result.status, 0, result.stdout + result.stderr); + + const zipName = `Simulator-Broker-${version}.zip`; + const zipPath = path.join(outputDir, zipName); + const checksumPath = `${zipPath}.sha256`; + const validation = runCaskZipValidator(zipPath); + assert.equal(validation.status, 0, validation.stderr); + assert.equal(fs.statSync(zipPath).mode & 0o777, 0o644); + assert.equal(fs.statSync(checksumPath).mode & 0o777, 0o644); + const expectedHash = createHash("sha256").update(fs.readFileSync(zipPath)).digest("hex"); + assert.equal(fs.readFileSync(checksumPath, "utf8"), `${expectedHash} ${zipName}\n`); + assert.equal(spawnSync("xattr", ["-p", "dev.codex.simulator-broker.test", metadataFile]).status, 0); + assert.equal(spawnSync("xattr", ["-px", "com.apple.ResourceFork", metadataFile]).status, 0); +}); + +test("package_cask_zip.sh rejects a leaking ditto candidate and clears stale outputs", { + skip: process.platform !== "darwin", +}, () => { + const { app, fakeBin, outputDir, testRoot } = makeCaskAppFixture( + "simbroker-package-cask-zip-leaking-ditto-", + ); + writeCaskVerificationShims(fakeBin); + const invalidZip = writeCaskZipFixture(basicCaskZipEntries([ + { name: `${caskZipRoot}/Contents/._CodeResources`, mode: 0o100644 }, + ])); + const argsLog = path.join(testRoot, "ditto-args.log"); + fs.writeFileSync( + path.join(fakeBin, "ditto"), + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$@" > "$SIMBROKER_TEST_DITTO_ARGS_LOG" +destination='' +for argument in "$@"; do + destination="$argument" +done +cp "$SIMBROKER_TEST_INVALID_CASK_ZIP" "$destination" +`, + { mode: 0o755 }, + ); + const zipName = `Simulator-Broker-${version}.zip`; + fs.writeFileSync(path.join(outputDir, zipName), "stale zip\n"); + fs.writeFileSync(path.join(outputDir, `${zipName}.sha256`), "stale checksum\n"); + const env = { + ...process.env, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`, + SIMBROKER_TEST_DITTO_ARGS_LOG: argsLog, + SIMBROKER_TEST_INVALID_CASK_ZIP: invalidZip, + }; + delete env.COPYFILE_DISABLE; + delete env.DITTONORSRC; + const result = spawnSync( + "bash", + [path.join(repoRoot, "scripts/package_cask_zip.sh"), "--app", app, "--output-dir", outputDir], + { cwd: repoRoot, encoding: "utf8", env }, + ); + + assert.notEqual(result.status, 0, result.stdout); + assert.match(result.stderr, /^Cask ZIP validation failed: entry \d+ contains AppleDouble metadata\.\n$/); + assert.equal(result.stderr.includes(repoRoot), false, result.stderr); + assert.equal(result.stderr.includes(testRoot), false, result.stderr); + assert.deepEqual( + fs.readFileSync(argsLog, "utf8").trimEnd().split("\n").slice(0, 7), + ["-c", "-k", "--norsrc", "--noextattr", "--noacl", "--noqtn", "--keepParent"], + ); + assert.deepEqual(fs.readdirSync(outputDir), []); +}); + test("package_cask_zip.sh refuses a missing app", () => { const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "simbroker-package-cask-zip-missing-")); const result = spawnSync( diff --git a/scripts/package_cask_zip.sh b/scripts/package_cask_zip.sh index e45ec8e..c5cf033 100755 --- a/scripts/package_cask_zip.sh +++ b/scripts/package_cask_zip.sh @@ -19,7 +19,7 @@ The signed app comes from npm run package:distribution: artifacts/distribution/SimulatorBroker-macOS-distribution/payload/app/Simulator Broker.app Notarize and staple that app first. This script only writes the app-only -zip with ditto -c -k --keepParent. It refuses unsigned, ad-hoc, or +zip with explicit ditto metadata suppression. It refuses unsigned, ad-hoc, or non-Developer ID signatures, a signature that codesign --verify --deep --strict rejects, an app whose CFBundleIdentifier is not dev.codex.simulator-broker-app, an app that xcrun stapler validate @@ -78,13 +78,75 @@ if [[ -z "$version" || "$version" == "." || "$version" == ".." || "$version" == exit 1 fi +app_basename="$(basename "$app_path")" +resolved_app_path="" +if [[ -d "$app_path" ]]; then + if ! resolved_app_path="$(cd "$app_path" 2>/dev/null && pwd -P)"; then + echo "Cask zip could not resolve the signed app bundle." >&2 + exit 1 + fi +fi + +if ! mkdir -p "$output_dir" 2>/dev/null; then + echo "Unable to prepare the cask ZIP output directory." >&2 + exit 1 +fi +if ! output_dir="$(cd "$output_dir" 2>/dev/null && pwd -P)"; then + echo "Unable to resolve the cask ZIP output directory." >&2 + exit 1 +fi +if [[ -n "$resolved_app_path" ]]; then + case "$output_dir/" in + "$resolved_app_path/"*) + echo "Refusing to write cask ZIP outputs inside the signed app bundle." >&2 + exit 1 + ;; + esac +fi + +zip_name="Simulator-Broker-${version}.zip" +zip_path="$output_dir/$zip_name" +checksum_path="$output_dir/${zip_name}.sha256" + +require_strict_child_path "$output_dir" "$zip_path" +require_strict_child_path "$output_dir" "$checksum_path" + +codesign_verify_output="" +codesign_output="" +stapler_output="" +candidate_zip="" +candidate_checksum="" +published=false +cleanup() { + for temporary_path in \ + "$codesign_verify_output" \ + "$codesign_output" \ + "$stapler_output" \ + "$candidate_zip" \ + "$candidate_checksum"; do + if [[ -n "$temporary_path" ]]; then + rm -f "$temporary_path" 2>/dev/null || true + fi + done + if [[ "$published" != true ]]; then + rm -f "$zip_path" "$checksum_path" 2>/dev/null || true + fi +} +trap cleanup EXIT + +# A failed rebuild must not leave older final bytes looking current. +if ! rm -f "$zip_path" "$checksum_path" 2>/dev/null; then + echo "Unable to clear older cask ZIP outputs." >&2 + exit 1 +fi + if [[ ! -d "$app_path" ]]; then echo "Signed app bundle not found: $app_path" >&2 echo "Run npm run package:distribution with a Developer ID Application identity, notarize and staple the app, then rerun." >&2 exit 1 fi +app_path="$resolved_app_path" -app_basename="$(basename "$app_path")" if [[ "$app_basename" != "Simulator Broker.app" ]]; then echo "Cask zip requires the bundle to be named Simulator Broker.app, got: $app_basename" >&2 exit 1 @@ -150,10 +212,6 @@ fi codesign_verify_output="$(mktemp "${TMPDIR:-/tmp}/simbroker-package-cask-zip-codesign-verify.XXXXXX")" codesign_output="$(mktemp "${TMPDIR:-/tmp}/simbroker-package-cask-zip-codesign.XXXXXX")" stapler_output="$(mktemp "${TMPDIR:-/tmp}/simbroker-package-cask-zip-stapler.XXXXXX")" -cleanup() { - rm -f "$codesign_verify_output" "$codesign_output" "$stapler_output" -} -trap cleanup EXIT if ! codesign --verify --deep --strict "$app_path" >"$codesign_verify_output" 2>&1; then echo "Refusing to zip an app whose code signature does not verify. The bundle contents no longer match the embedded signature." >&2 @@ -195,46 +253,65 @@ if ! xcrun stapler validate "$app_path" >"$stapler_output" 2>&1; then exit 1 fi -mkdir -p "$output_dir" -output_dir="$(cd "$output_dir" && pwd -P)" -zip_name="Simulator-Broker-${version}.zip" -zip_path="$output_dir/$zip_name" -checksum_path="$output_dir/${zip_name}.sha256" - -require_strict_child_path "$output_dir" "$zip_path" -require_strict_child_path "$output_dir" "$checksum_path" - -rm -f "$zip_path" "$checksum_path" -ditto -c -k --keepParent "$app_path" "$zip_path" - -python3 - "$zip_path" <<'PY' -import sys -import zipfile - -zip_path = sys.argv[1] -prefix = "Simulator Broker.app" -with zipfile.ZipFile(zip_path) as archive: - names = archive.namelist() - -if not names: - sys.stderr.write("Cask zip is empty.\n") - sys.exit(1) +if ! candidate_zip="$(mktemp "$output_dir/.${zip_name}.XXXXXX" 2>/dev/null)"; then + echo "Unable to prepare the cask ZIP candidate." >&2 + exit 1 +fi +if ! ditto -c -k --norsrc --noextattr --noacl --noqtn --keepParent "$app_path" "$candidate_zip"; then + echo "Failed to create the cask ZIP candidate." >&2 + exit 1 +fi -for name in names: - if name in {prefix, prefix + "/"} or name.startswith(prefix + "/"): - continue - sys.stderr.write(f"Cask zip must contain only {prefix}, found: {name}\n") - sys.exit(1) -PY +# The docs regression invokes this same standard-library candidate validator. +if ! python3 "$repo_root/scripts/validate_cask_zip.py" "$candidate_zip" "$app_basename"; then + exit 1 +fi +archive_hash="" if command -v shasum >/dev/null 2>&1; then - (cd "$output_dir" && shasum -a 256 "$zip_name" > "${zip_name}.sha256") + if ! archive_hash="$(shasum -a 256 "$candidate_zip" 2>/dev/null | awk '{print $1}')"; then + echo "Unable to compute the cask ZIP checksum." >&2 + exit 1 + fi elif command -v sha256sum >/dev/null 2>&1; then - (cd "$output_dir" && sha256sum "$zip_name" > "${zip_name}.sha256") + if ! archive_hash="$(sha256sum "$candidate_zip" 2>/dev/null | awk '{print $1}')"; then + echo "Unable to compute the cask ZIP checksum." >&2 + exit 1 + fi else - echo "Neither shasum nor sha256sum is available; cannot write $checksum_path" >&2 + echo "Neither shasum nor sha256sum is available; cannot write the cask ZIP checksum." >&2 + exit 1 +fi +if [[ ! "$archive_hash" =~ ^[0-9a-fA-F]{64}$ ]]; then + echo "Unable to compute the cask ZIP checksum." >&2 + exit 1 +fi + +if ! candidate_checksum="$(mktemp "$output_dir/.${zip_name}.sha256.XXXXXX" 2>/dev/null)"; then + echo "Unable to prepare the cask ZIP checksum candidate." >&2 + exit 1 +fi +if ! printf '%s %s\n' "$archive_hash" "$zip_name" > "$candidate_checksum" 2>/dev/null; then + echo "Unable to write the cask ZIP checksum candidate." >&2 + exit 1 +fi + +# Normalize both complete candidates before either final name becomes visible. +if ! chmod 0644 "$candidate_zip" "$candidate_checksum" 2>/dev/null; then + echo "Unable to publish readable cask ZIP artifacts." >&2 + exit 1 +fi +if ! mv "$candidate_zip" "$zip_path" 2>/dev/null; then + echo "Unable to publish the cask ZIP." >&2 + exit 1 +fi +candidate_zip="" +if ! mv "$candidate_checksum" "$checksum_path" 2>/dev/null; then + echo "Unable to publish the cask ZIP checksum." >&2 exit 1 fi +candidate_checksum="" +published=true printf '%s\n' "$zip_path" printf '%s\n' "$checksum_path" diff --git a/scripts/validate_cask_zip.py b/scripts/validate_cask_zip.py new file mode 100644 index 0000000..618f7f5 --- /dev/null +++ b/scripts/validate_cask_zip.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +import stat +import sys +import zipfile + + +class ContractError(Exception): + pass + + +def reject(message): + raise ContractError(message) + + +def validate_name(name, expected_root, index): + label = f"entry {index}" + if not name or name.startswith("/") or "\\" in name or "\0" in name: + reject(f"{label} has an unsafe name") + directory = name.endswith("/") + normalized = name[:-1] if directory else name + segments = normalized.split("/") + if any(segment in {"", ".", ".."} for segment in segments): + reject(f"{label} has an unsafe path segment") + if segments[0] != expected_root: + reject(f"{label} is outside the app root") + if any(segment.upper() == "__MACOSX" for segment in segments): + reject(f"{label} contains __MACOSX metadata") + if any(segment.startswith("._") for segment in segments): + reject(f"{label} contains AppleDouble metadata") + return directory + + +def validate_cask_zip(zip_path, expected_root): + if not expected_root or expected_root in {".", ".."} or any( + separator in expected_root for separator in ("/", "\\", "\0") + ): + reject("expected root must be one safe path segment") + + with zipfile.ZipFile(zip_path) as archive: + if archive.comment: + reject("archive comments are unsupported") + entries = archive.infolist() + if not entries: + reject("archive is empty") + names = [entry.orig_filename for entry in entries] + if len(set(names)) != len(names): + reject("entry names must be unique") + + root_entries = [] + regular_descendants = 0 + for index, entry in enumerate(entries, start=1): + if entry.orig_filename != entry.filename: + reject(f"entry {index} has an ambiguous NUL-suffixed name") + directory = validate_name(entry.orig_filename, expected_root, index) + if entry.comment: + reject(f"entry {index} has an unsupported comment") + if entry.flag_bits & (0x1 | 0x40): + reject(f"entry {index} is encrypted") + if entry.compress_type not in {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}: + reject(f"entry {index} uses unsupported compression") + if entry.create_system != 3: + reject(f"entry {index} lacks Unix file attributes") + + file_type = stat.S_IFMT((entry.external_attr >> 16) & 0xFFFF) + if directory: + if file_type != stat.S_IFDIR: + reject(f"entry {index} is not a directory") + elif file_type == stat.S_IFREG: + regular_descendants += 1 + else: + reject(f"entry {index} has an unsupported file type") + + if entry.orig_filename in {expected_root, f"{expected_root}/"}: + root_entries.append((entry, directory)) + + with archive.open(entry) as payload: + while payload.read(1024 * 1024): + pass + + if len(root_entries) != 1 or not root_entries[0][1]: + reject("archive must contain exactly one app root directory") + if regular_descendants == 0: + reject("archive must contain a regular app payload file") + required_entries = { + f"{expected_root}/Contents/Info.plist", + f"{expected_root}/Contents/MacOS/SimulatorBrokerApp", + f"{expected_root}/Contents/CodeResources", + f"{expected_root}/Contents/_CodeSignature/CodeResources", + } + if not required_entries.issubset(names): + reject("archive is missing an essential signed-app payload entry") + + +def main(): + if len(sys.argv) != 3: + sys.stderr.write("Cask ZIP validation failed: expected a ZIP and app root.\n") + return 1 + try: + validate_cask_zip(sys.argv[1], sys.argv[2]) + except ContractError as error: + sys.stderr.write(f"Cask ZIP validation failed: {error}.\n") + return 1 + except Exception: + sys.stderr.write("Cask ZIP validation failed: archive is unreadable or unsupported.\n") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/spec/build-and-test.md b/spec/build-and-test.md index e8bd07c..f20c035 100644 --- a/spec/build-and-test.md +++ b/spec/build-and-test.md @@ -91,15 +91,18 @@ A first extracted implementation slice now exists: and `package_cask_zip.sh`, not `package:local`). The cask pins the published zip SHA-256. - `scripts/package_cask_zip.sh` (`npm run package:cask-zip`) writes that - app-only zip with `ditto -c -k --keepParent`. It does not build, sign, - notarize, staple, tag, or publish. Version, missing-app, bundle-name, - and `CFBundleIdentifier` (`dev.codex.simulator-broker-app`) checks run - before the Darwin/`ditto` gate so `spec-only` stays portable. After - `codesign --verify --deep --strict` and Developer ID inspection it runs - `xcrun stapler validate` and refuses a missing app, a signature that - does not verify, an ad-hoc or non-Developer ID signature, the wrong - sealed identifier, an unstapled app, and a zip that contains the - distribution payload instead of `Simulator Broker.app`. + app-only zip from the immutable signed app with explicit + `ditto -c -k --norsrc --noextattr --noacl --noqtn --keepParent`; it does + not depend on ambient `DITTONORSRC` or `COPYFILE_DISABLE` and does not + build, mutate, sign, notarize, staple, tag, or publish. Version, + missing-app, bundle-name, and `CFBundleIdentifier` + (`dev.codex.simulator-broker-app`) checks run before the Darwin/`ditto` + gate so `spec-only` stays portable. After `codesign --verify --deep + --strict` and Developer ID inspection it runs `xcrun stapler validate`. + A hidden candidate must pass `scripts/validate_cask_zip.py` before its + checksum exists or either final filename becomes visible. Both candidates + have mode `0644` before ordered same-directory renames, and any failure + removes candidates plus stale/final zip and checksum paths. - public GitHub-hosted CI splits by machine: Ubuntu runs the managed-skill ownership check, `test:docs`, `test:broker-core`, and `test:harness-adoption` with a 10-minute budget; macOS runs `test:docs` and @@ -205,6 +208,30 @@ failures, never hidden exclusions. A raw-invalid candidate and its checksum are not published, and failure messages do not expose checkout or build-root paths. This contract does not prohibit generic temporary-directory guidance. +### Cask ZIP payload contract + +| ID | Requirement | Verifier | +| --- | --- | --- | +| SB-PKG-CASK-001 | Cask packaging must use the explicit `ditto` metadata-suppression flags, leave the signed app unchanged, and validate the hidden candidate through Python `zipfile` before checksum or publication. The archive must have one nonempty `Simulator Broker.app` tree with unique safe names, the essential signed-app entries, no `._*` or `__MACOSX` components, comments, encryption, or special file types, and every regular payload must be readable with a valid CRC. | `scripts/validate_cask_zip.py`; `docs/test/front-door.test.mjs` `SB-PKG-CASK-001 cask ZIP validation rejects metadata and unsafe structure`; real-macOS planted resource-fork/xattr success; leaking-`ditto` failure | +| SB-PKG-CASK-002 | Packaging removes stale final zip/checksum paths before work. It writes hidden zip/checksum candidates, sets both to `0644` before either final rename, and removes candidates and both final paths on any failure. Diagnostics must not print checkout, app, output, or temporary paths for candidate-validation failures. | `docs/test/front-door.test.mjs` real-`ditto` mode/checksum assertions and leaking-`ditto` stale-output/hidden-candidate regression | + +This defense covers the confirmed interaction: signed-app resource or xattr +metadata, default `ditto` serialization, filtered inspection, and ambient +environment state could combine to publish AppleDouble entries. The explicit +flags remove that metadata, while a separate final-candidate inventory and +readability check blocks a silent recurrence. Standard `ditto` time and numeric +UID/GID data in benign `0x5855` extra fields remains allowed; this contract does +not claim ownership normalization. + +The validator intentionally uses the platform Python standard library instead +of a second ZIP grammar or decompressor. It cross-checks referenced local +headers while reading every central-directory `ZipInfo`, but does not prove the +absence of an orphan local record that no central entry references. That +residual requires both a defect or compromise in the trusted system `ditto` +producer and a consumer that acts on unreferenced records. It is accepted here +because the smaller defense catches the observed high-consequence path without +adding a fragile archive-parser subsystem. + ### Release asset contract A complete tagged Alpha has exactly four custom GitHub Release assets: @@ -534,7 +561,7 @@ Add stronger profiles next for: - the installer prints the installed CLI path, app path when an app was installed, env helper path, any current-shell PATH warning, PATH persist result, and the next command (`command -v simbroker` after persist, or `source ""` when persist is skipped) - `bash scripts/install_local.sh --cli-only` installs the CLI runtime without invoking `xcodegen` or `xcodebuild` and without requiring an app bundle - `npm run package:cli` writes `artifacts/cli/simulator-broker--cli.tar.gz` plus a SHA-256 checksum, does not invoke XcodeGen or `xcodebuild`, emits only normalized raw USTAR payload records without macOS metadata or host ownership, and its extracted README preserves literal Markdown without executing package commands or capturing the exact checkout root -- `npm run package:cask-zip` writes `artifacts/distribution/Simulator-Broker-.zip` plus a SHA-256 checksum from a Developer ID-signed, stapled `Simulator Broker.app` using `ditto -c -k --keepParent`. It verifies the sealed signature with `codesign --verify --deep --strict`, requires `CFBundleIdentifier`/`Identifier` `dev.codex.simulator-broker-app`, runs `xcrun stapler validate` before writing the zip, and does not build, sign, notarize, staple, tag, or publish +- `npm run package:cask-zip` writes `artifacts/distribution/Simulator-Broker-.zip` plus a SHA-256 checksum from an unchanged Developer ID-signed, stapled `Simulator Broker.app` using explicit `ditto` metadata suppression. It verifies the sealed signature with `codesign --verify --deep --strict`, requires `CFBundleIdentifier`/`Identifier` `dev.codex.simulator-broker-app`, runs `xcrun stapler validate`, applies the `SB-PKG-CASK-001` final-candidate validator, publishes only mode-`0644` zip/checksum candidates, and does not build, sign, notarize, staple, tag, or publish - `.github/workflows/ci.yml` runs `test:docs`, `test:broker-core`, and `test:harness-adoption` on `ubuntu-latest` (10 minutes), then runs `test:docs` and `test:client` on `macos-latest` (15 minutes). `.github/workflows/release.yml` runs diff --git a/spec/project-structure.md b/spec/project-structure.md index 2b5abc5..7ac500d 100644 --- a/spec/project-structure.md +++ b/spec/project-structure.md @@ -40,7 +40,8 @@ Related: `spec/README.md`, `spec/global-simulator-broker.md`, `references/README `validate.sh` full-repository gate, app generation, repo-local install, CLI-only install (`install_local.sh --cli-only`), CLI tarball packaging (`package_cli.sh`) plus its raw USTAR validator (`validate_cli_tar.mjs`), - Homebrew cask zip (`package_cask_zip.sh`), npm CLI packing (`package_npm.sh`), Homebrew tap + Homebrew cask zip (`package_cask_zip.sh`) plus its standard-library candidate + validator (`validate_cask_zip.py`), npm CLI packing (`package_npm.sh`), Homebrew tap sync (`sync_homebrew_tap.sh`), distribution install, portable package creation, smoke verification, and harness bootstrap