diff --git a/app/project.yml b/app/project.yml index 0c55004..fcfb046 100644 --- a/app/project.yml +++ b/app/project.yml @@ -21,6 +21,13 @@ targets: INFOPLIST_FILE: Generated/SimulatorBrokerApp-Info.plist PRODUCT_BUNDLE_IDENTIFIER: dev.codex.simulator-broker-app PRODUCT_NAME: SimulatorBrokerApp + configs: + Release: + DEBUG_INFORMATION_FORMAT: dwarf-with-dsym + DEPLOYMENT_POSTPROCESSING: "YES" + STRIP_INSTALLED_PRODUCT: "YES" + STRIP_STYLE: debugging + STRIP_SWIFT_SYMBOLS: "NO" SimulatorBrokerAppTests: type: bundle.unit-test platform: macOS diff --git a/client/test/release-app-paths.test.mjs b/client/test/release-app-paths.test.mjs new file mode 100644 index 0000000..dd669ff --- /dev/null +++ b/client/test/release-app-paths.test.mjs @@ -0,0 +1,112 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const skipMacOS = process.platform !== "darwin"; +const verifierPath = path.resolve("script/verify_release_app_binary.sh"); + +function run(command, args) { + return spawnSync(command, args, { + encoding: "utf8", + }); +} + +function compileSlice(root, architecture, marker, { debuggingSymbols = false } = {}) { + const variant = [ + marker === null ? "clean" : "leaking", + debuggingSymbols ? "debug" : null, + ].filter(Boolean).join("-"); + const sourcePath = path.join(root, `${architecture}-${variant}.c`); + const executablePath = path.join(root, `${architecture}-${variant}`); + const markerDeclaration = marker === null + ? "" + : `__attribute__((used)) static const char local_build_root[] = ${JSON.stringify(`${marker}/`)};\n`; + + fs.writeFileSync(sourcePath, `${markerDeclaration}int main(void) { return 0; }\n`); + const args = [ + "clang", + "-arch", + architecture, + "-mmacosx-version-min=14.0", + "-Os", + sourcePath, + "-o", + executablePath, + ]; + if (debuggingSymbols) { + args.splice(6, 0, "-g"); + } + const result = run("xcrun", args); + assert.equal(result.status, 0, result.stderr); + return executablePath; +} + +function createUniversal(root, name, ...slices) { + const executablePath = path.join(root, name); + const result = run("xcrun", ["lipo", "-create", ...slices, "-output", executablePath]); + assert.equal(result.status, 0, result.stderr); + return executablePath; +} + +test("Release executable verifier rejects leaked paths and unstripped or unexpected content", { skip: skipMacOS }, (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "simbroker-release-path-test-")); + const forbiddenRoot = path.join(root, "synthetic-local-build-root"); + t.after(() => fs.rmSync(root, { force: true, recursive: true })); + + const cleanArm64 = compileSlice(root, "arm64", null); + const cleanX86 = compileSlice(root, "x86_64", null); + const leakingArm64 = compileSlice(root, "arm64", forbiddenRoot); + const leakingX86 = compileSlice(root, "x86_64", forbiddenRoot); + const leakingArm64e = compileSlice(root, "arm64e", forbiddenRoot); + const debugArm64 = compileSlice(root, "arm64", null, { debuggingSymbols: true }); + + const cleanUniversal = createUniversal(root, "clean-universal", cleanArm64, cleanX86); + const cleanResult = run("bash", [verifierPath, cleanUniversal, forbiddenRoot]); + assert.equal(cleanResult.status, 0, cleanResult.stderr); + assert.match(cleanResult.stdout, /passed for arm64 and x86_64/); + + const arm64Leak = createUniversal(root, "arm64-leak", leakingArm64, cleanX86); + const arm64Result = run("bash", [verifierPath, arm64Leak, forbiddenRoot]); + assert.notEqual(arm64Result.status, 0); + assert.match(arm64Result.stderr, /arm64 slice contains the local build root/); + assert.equal(arm64Result.stderr.includes(forbiddenRoot), false); + + const x86Leak = createUniversal(root, "x86-leak", cleanArm64, leakingX86); + const x86Result = run("bash", [verifierPath, x86Leak, forbiddenRoot]); + assert.notEqual(x86Result.status, 0); + assert.match(x86Result.stderr, /x86_64 slice contains the local build root/); + assert.equal(x86Result.stderr.includes(forbiddenRoot), false); + + const universalOnlyLeak = path.join(root, "universal-only-leak"); + fs.copyFileSync(cleanUniversal, universalOnlyLeak); + fs.appendFileSync(universalOnlyLeak, `${forbiddenRoot}/`); + const universalResult = run("bash", [verifierPath, universalOnlyLeak, forbiddenRoot]); + assert.notEqual(universalResult.status, 0); + assert.match(universalResult.stderr, /universal file contains the local build root/); + assert.equal(universalResult.stderr.includes(forbiddenRoot), false); + + const unexpectedArchitecture = createUniversal( + root, + "unexpected-architecture", + cleanArm64, + cleanX86, + leakingArm64e, + ); + const unexpectedArchitectureResult = run("bash", [verifierPath, unexpectedArchitecture, forbiddenRoot]); + assert.notEqual(unexpectedArchitectureResult.status, 0); + assert.match(unexpectedArchitectureResult.stderr, /must contain exactly arm64 and x86_64 slices/); + assert.equal(unexpectedArchitectureResult.stderr.includes(forbiddenRoot), false); + + const unstrippedUniversal = createUniversal(root, "unstripped-universal", debugArm64, cleanX86); + const unstrippedResult = run("bash", [verifierPath, unstrippedUniversal, forbiddenRoot]); + assert.notEqual(unstrippedResult.status, 0); + assert.match(unstrippedResult.stderr, /arm64 slice retains debugging symbols/); + assert.equal(unstrippedResult.stderr.includes(root), false); + + const missingArchitectureResult = run("bash", [verifierPath, cleanArm64, forbiddenRoot]); + assert.notEqual(missingArchitectureResult.status, 0); + assert.match(missingArchitectureResult.stderr, /must contain exactly arm64 and x86_64 slices/); +}); diff --git a/script/verify_release_app_binary.sh b/script/verify_release_app_binary.sh new file mode 100755 index 0000000..c2a38fd --- /dev/null +++ b/script/verify_release_app_binary.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: bash script/verify_release_app_binary.sh + +Verify that a universal macOS Release executable contains arm64 and x86_64 +only, contains no debugging-symbol records, and does not embed the exact local +build root in either architecture or the universal container. +EOF +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +if [[ "$#" -ne 2 ]]; then + usage >&2 + exit 2 +fi + +executable_path="$1" +local_build_root="${2%/}" + +[[ -f "$executable_path" ]] || die "Release executable was not found." +[[ -n "$local_build_root" && "$local_build_root" == /* && "$local_build_root" != "/" ]] \ + || die "Local build root must be a non-root absolute path." +[[ "$local_build_root" != *$'\n'* && "$local_build_root" != *$'\r'* ]] \ + || die "Local build root must not contain line breaks." +command -v node >/dev/null 2>&1 || die "Node.js is required to verify Release executable bytes." +command -v xcrun >/dev/null 2>&1 || die "xcrun is required to inspect the Release executable." + +scan_raw_bytes() { + local candidate_path="$1" + local candidate_label="$2" + + node --input-type=module - "$candidate_path" "$local_build_root" "$candidate_label" <<'NODE' +import fs from "node:fs"; + +const [, , executablePath, localBuildRoot, candidateLabel] = process.argv; +const bytes = fs.readFileSync(executablePath); +const forbiddenPrefix = Buffer.from(`${localBuildRoot}/`, "utf8"); + +if (bytes.indexOf(forbiddenPrefix) !== -1) { + process.stderr.write(`error: Release executable ${candidateLabel} contains the local build root.\n`); + process.exit(1); +} +NODE +} + +architecture_output="$(xcrun lipo "$executable_path" -archs 2>/dev/null)" \ + || die "Could not inspect the Release executable architectures." +read -r -a architectures <<<"$architecture_output" +if [[ "${#architectures[@]}" -ne 2 ]]; then + die "Release executable must contain exactly arm64 and x86_64 slices." +fi +has_arm64=0 +has_x86_64=0 +for architecture in "${architectures[@]}"; do + case "$architecture" in + arm64) + has_arm64=1 + ;; + x86_64) + has_x86_64=1 + ;; + *) + die "Release executable must contain exactly arm64 and x86_64 slices." + ;; + esac +done +if [[ "$has_arm64" -ne 1 || "$has_x86_64" -ne 1 ]]; then + die "Release executable must contain exactly arm64 and x86_64 slices." +fi + +slice_root="$(mktemp -d "${TMPDIR:-/tmp}/simbroker-release-binary.XXXXXX")" +cleanup() { + rm -f \ + "$slice_root/arm64" \ + "$slice_root/arm64.nm" \ + "$slice_root/arm64.otool" \ + "$slice_root/x86_64" \ + "$slice_root/x86_64.nm" \ + "$slice_root/x86_64.otool" + rmdir "$slice_root" 2>/dev/null || true +} +trap cleanup EXIT + +for architecture in arm64 x86_64; do + slice_path="$slice_root/$architecture" + if ! xcrun lipo "$executable_path" -thin "$architecture" -output "$slice_path" >/dev/null 2>&1; then + die "Could not inspect the Release executable $architecture slice." + fi + + scan_raw_bytes "$slice_path" "$architecture slice" + + symbols_path="$slice_root/$architecture.nm" + if ! xcrun nm -ap "$slice_path" >"$symbols_path" 2>/dev/null; then + die "Could not inspect the Release executable $architecture symbols." + fi + node --input-type=module - "$symbols_path" "$architecture" <<'NODE' +import fs from "node:fs"; + +const [, , symbolsPath, architecture] = process.argv; +const symbols = fs.readFileSync(symbolsPath, "utf8"); +const debuggingSymbol = /^[0-9A-Fa-f]+\s+-\s+[0-9A-Fa-f]{2}\s+[0-9A-Fa-f]{4}\s+/mu; + +if (debuggingSymbol.test(symbols)) { + process.stderr.write(`error: Release executable ${architecture} slice retains debugging symbols.\n`); + process.exit(1); +} + +if (/(?:^|\s)\/\S/mu.test(symbols)) { + process.stderr.write(`error: Release executable ${architecture} slice retains an absolute-path symbol.\n`); + process.exit(1); +} +NODE + + load_commands_path="$slice_root/$architecture.otool" + if ! xcrun otool -l "$slice_path" >"$load_commands_path" 2>/dev/null; then + die "Could not inspect the Release executable $architecture load commands." + fi + node --input-type=module - "$load_commands_path" "$architecture" <<'NODE' +import fs from "node:fs"; + +const [, , loadCommandsPath, architecture] = process.argv; +const loadCommands = fs.readFileSync(loadCommandsPath, "utf8"); + +if (/^\s*(?:segname|sectname)\s+__DWARF(?:\s|$)/mu.test(loadCommands)) { + process.stderr.write(`error: Release executable ${architecture} slice retains embedded DWARF.\n`); + process.exit(1); +} +NODE +done + +scan_raw_bytes "$executable_path" "universal file" + +echo "Release executable local-build-root check passed for arm64 and x86_64." diff --git a/scripts/package_distribution.sh b/scripts/package_distribution.sh index d537099..4e00b0e 100755 --- a/scripts/package_distribution.sh +++ b/scripts/package_distribution.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -repo_root="$(cd "$(dirname "$0")/.." && pwd)" +repo_root="$(cd "$(dirname "$0")/.." && pwd -P)" source "$repo_root/script/require_macos_build_prereqs.sh" output_dir="$repo_root/artifacts/distribution" archive_name="SimulatorBroker-macOS-distribution" @@ -13,6 +13,7 @@ signing_identity="${SIMBROKER_DISTRIBUTION_SIGNING_IDENTITY:-}" team_id="${SIMBROKER_DISTRIBUTION_TEAM_ID:-}" notarytool_profile="${SIMBROKER_NOTARYTOOL_PROFILE:-}" tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/simbroker-package-distribution.XXXXXX")" +release_build_root="" cleanup() { rm -rf "$tmp_root" @@ -96,6 +97,41 @@ if (report.ok) { EOF } +resolve_release_build_root() { + local project_path="$repo_root/app/SimulatorBrokerApp.xcodeproj" + local settings_path="$tmp_root/release-build-settings.txt" + local source_root + local physical_build_root + + require_macos_build_prereqs + if [[ ! -d "$project_path" ]]; then + bash "$repo_root/scripts/generate_app_project.sh" >&2 + fi + + if ! xcodebuild \ + -project "$project_path" \ + -target SimulatorBrokerApp \ + -configuration Release \ + -showBuildSettings \ + CODE_SIGNING_ALLOWED=NO >"$settings_path" 2>/dev/null; then + echo "Could not resolve the Xcode-visible Release source root." >&2 + exit 1 + fi + + source_root="$(sed -n 's/^[[:space:]]*SRCROOT = //p' "$settings_path" | head -n 1)" + if [[ -z "$source_root" || ! -d "$source_root" ]]; then + echo "Could not resolve the Xcode-visible Release source root." >&2 + exit 1 + fi + + release_build_root="$(cd "$source_root/.." && pwd -L)" + physical_build_root="$(cd "$release_build_root" && pwd -P)" + if [[ "$physical_build_root" != "$repo_root" ]]; then + echo "Xcode Release source root does not match the current repository." >&2 + exit 1 + fi +} + usage() { cat <<'EOF' Usage: bash scripts/package_distribution.sh [options] @@ -297,6 +333,10 @@ Optional install flags: EOF scan_distribution_public_surface "$bundle_root" "$archive_name" +resolve_release_build_root +bash "$repo_root/script/verify_release_app_binary.sh" \ + "$distribution_app_path/Contents/MacOS/SimulatorBrokerApp" \ + "$release_build_root" codesign_sign_exit_code=0 if run_with_output_capture \ diff --git a/spec/build-and-test.md b/spec/build-and-test.md index f9ddd7a..4dc7285 100644 --- a/spec/build-and-test.md +++ b/spec/build-and-test.md @@ -116,7 +116,22 @@ A first extracted implementation slice now exists: staging or signing, including with `--skip-build`, the script requires the app's `SimulatorBrokerExpectedRuntimeVersion` `Info.plist` value to exactly match the root `package.json` version. A stale or missing value fails before - `codesign` so a version bump cannot silently reuse an older app build. + `codesign` so a version bump cannot silently reuse an older app build. The + XcodeGen Release configuration generates a `dwarf-with-dsym` companion and + deployment-postprocesses the app executable with debugging-symbol stripping; + Swift-symbol stripping stays disabled. This removes source and DerivedData + paths from the shipped executable while retaining the external dSYM for + symbolication. Immediately before signing, `package_distribution.sh` + resolves Xcode's visible source root, requires exactly the `arm64` and + `x86_64` slices, raw-byte scans each slice plus the universal container for + that exact root, and rejects root-independently when either slice still has + debugging-symbol records, absolute-path symbol names, or embedded `__DWARF`. + The structural checks keep `--skip-build` safe when a same-version app came + from a checkout that was subsequently moved or renamed. + This binary gate is intentionally separate from the text public-surface + scanner because NUL-containing Mach-O metadata and `strings` output can both + miss the embedded paths. The dSYM remains in DerivedData and is not copied + into the distribution payload or archive. The default public-surface scan reads index blobs only for dirty or missing worktree files. `git diff-files` exit `1` is the dirty-name list; only a real git failure fails the scan. A clean @@ -476,6 +491,15 @@ Add stronger profiles next for: home path or prohibited local broker artifact and applies optional rules from ignored `.public-safety.local` without printing matched values - `npm run test:app:build` isolates compile-time failures with the same XcodeGen and derived-data settings used by the full suite +- Release app builds create both `arm64` and `x86_64` slices, retain a + UUID-matched external dSYM, and strip debugging symbols from only the shipped + executable. `script/verify_release_app_binary.sh` requires exactly those two + architectures, raw-byte scans both slices and the universal container for + the exact Xcode-visible build root, and independently rejects retained + debugging-symbol records. Its compiled fixture proves clean, arm64-only + leak, x86_64-only leak, universal-container-only leak, extra or missing + architecture, and stale unstripped-build outcomes without printing the + forbidden path - `npm run test:app:focus -- ` reruns one named XCTest scope and writes a stable `xcresult` bundle under `artifacts/app-tests/` unless the caller overrides the path explicitly - `./script/build_and_run.sh` is the canonical local macOS app run loop, stops only the app instance launched from the current checkout's built app path, and `./script/build_and_run.sh --verify` proves that built app launches as a foreground `.app` - `./script/build_and_run.sh --telemetry` proves the app emits filterable `AppLifecycle` and `Refresh` unified logs during a live run, while `bash scripts/test_app.sh` exercises `Setup` and `Commands` events in focused app tests