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
7 changes: 7 additions & 0 deletions app/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions client/test/release-app-paths.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
140 changes: 140 additions & 0 deletions script/verify_release_app_binary.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
set -euo pipefail

usage() {
cat <<'EOF'
Usage: bash script/verify_release_app_binary.sh <executable> <local-build-root>

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."
42 changes: 41 additions & 1 deletion scripts/package_distribution.sh
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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 \
Expand Down
26 changes: 25 additions & 1 deletion spec/build-and-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 -- <filter>` 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
Expand Down