@rightkit/release 0.6.13 → 0.6.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +49 -1
  2. package/adapter-env.mjs +165 -0
  3. package/android-ndk.mjs +5 -1
  4. package/android-release.mjs +73 -8
  5. package/app-check.mjs +22 -8
  6. package/app-closure.mjs +214 -0
  7. package/assemble-app-command.mjs +24 -7
  8. package/atomic-write.mjs +57 -0
  9. package/bin/darwin/rightkit-swap +0 -0
  10. package/bin/rightkit-gate.mjs +1190 -337
  11. package/broker-client.mjs +106 -0
  12. package/broker-exception.mjs +211 -0
  13. package/broker-handshake.mjs +160 -0
  14. package/broker-pins.mjs +27 -8
  15. package/broker-wire.mjs +262 -0
  16. package/build-launch.mjs +364 -12
  17. package/build-ownership.mjs +117 -49
  18. package/build-release.mjs +3 -2
  19. package/cache-command.mjs +7 -2
  20. package/candidate-build-receipt.mjs +96 -0
  21. package/candidate-receipt-command.mjs +29 -0
  22. package/cargo-contract.mjs +4 -3
  23. package/cargo-ledger.mjs +1 -1
  24. package/cargo-license-notices.mjs +2 -2
  25. package/cargo-shapes.mjs +332 -0
  26. package/cargo-target-shim.mjs +44 -5
  27. package/cargo-target.mjs +5 -0
  28. package/carried-adapter-run.mjs +31 -0
  29. package/carried-cargo-run.mjs +9 -0
  30. package/carried-cargo.mjs +237 -0
  31. package/carrier-census.mjs +124 -0
  32. package/class-a-launch-guard.mjs +266 -33
  33. package/cli/right-release.mjs +2 -0
  34. package/clippy-route.mjs +23 -11
  35. package/compile-inputs.mjs +9 -12
  36. package/control-plane.mjs +18 -0
  37. package/deps-cargo-lock.mjs +208 -0
  38. package/deps.mjs +5 -0
  39. package/deterministic-scans.mjs +273 -44
  40. package/development-signing.mjs +14 -1
  41. package/development.mjs +109 -24
  42. package/direct-origin.mjs +107 -0
  43. package/disk-command.mjs +1 -0
  44. package/disk-policy.mjs +18 -3
  45. package/freshness-census.mjs +206 -58
  46. package/gate-executor.mjs +30 -12
  47. package/gate-stages.mjs +93 -16
  48. package/github-release.mjs +2 -1
  49. package/github-upload-gate.mjs +2 -1
  50. package/install-tauri-authenticode.mjs +346 -0
  51. package/ios-dev.mjs +77 -23
  52. package/ios-package-recipe.mjs +100 -41
  53. package/linux-artifacts.mjs +17 -2
  54. package/linux-qualifier.mjs +83 -0
  55. package/local-publish.mjs +36 -14
  56. package/local-runtime.mjs +2 -2
  57. package/lock-identity.mjs +138 -0
  58. package/lsclean.sh +0 -0
  59. package/managed-cargo.mjs +151 -44
  60. package/managed-roots.mjs +130 -12
  61. package/media-sidecar-catalog.mjs +1 -1
  62. package/native-adapters.mjs +411 -0
  63. package/native-helpers.mjs +26 -18
  64. package/native-tool-planning.mjs +94 -0
  65. package/ownership-mark.mjs +182 -0
  66. package/pack-digest.mjs +122 -0
  67. package/package.json +12 -12
  68. package/platform-dir.mjs +14 -0
  69. package/pnpm-adapter-grammar.mjs +226 -0
  70. package/progress-control.mjs +7 -2
  71. package/provenance-real-receipt.pins.json +7 -0
  72. package/provenance-verify.mjs +262 -1
  73. package/publication-outcomes.mjs +21 -0
  74. package/publish-update.mjs +2 -1
  75. package/qa-journeys-prebuilt-run.mjs +18 -0
  76. package/qa-journeys-prebuilt.mjs +148 -0
  77. package/qa-snapshot-run.mjs +53 -0
  78. package/qa-snapshot.mjs +164 -0
  79. package/qualification-verify.mjs +144 -0
  80. package/qualify-reuse.mjs +38 -0
  81. package/recipe-build.mjs +16 -3
  82. package/release-qa-audit.mjs +2 -2
  83. package/release-qa-command.mjs +2 -1
  84. package/release-qa-receipt.mjs +3 -2
  85. package/release-state.mjs +3 -2
  86. package/release.mjs +14 -3
  87. package/rightkit-drift.mjs +27 -5
  88. package/rightkit-versions.json +14 -10
  89. package/rustright-names.mjs +32 -0
  90. package/scan-tool-enrolment.mjs +167 -0
  91. package/sign-windows.mjs +212 -112
  92. package/signing-files.mjs +35 -0
  93. package/signing-identity.mjs +7 -0
  94. package/snapshot-broker.mjs +528 -57
  95. package/snapshot-check.mjs +95 -11
  96. package/snapshot-metadata.mjs +123 -0
  97. package/snapshot-request.mjs +389 -96
  98. package/stage-binary.mjs +22 -4
  99. package/stage-commands.mjs +56 -13
  100. package/stage-sidecar.mjs +93 -21
  101. package/suite-signing-policy.mjs +657 -0
  102. package/suite-signing.mjs +223 -0
  103. package/swift-scratch.mjs +25 -0
  104. package/tauri-locked.mjs +25 -0
  105. package/test-command.mjs +164 -29
  106. package/unit-gate.mjs +201 -31
  107. package/upload-large.mjs +0 -0
  108. package/web-build.mjs +2 -2
  109. package/win-package-recipe.mjs +26 -2
@@ -0,0 +1,411 @@
1
+ // GB-51 / RA-07, RA-08, RA-09: the constrained adapters of the native build tools (xcodegen, xcodebuild, Gradle, CMake): the reference registrations RustRight registers EXACTLY
2
+ // (rustright-policy native_adapters.rs `reference_spec`, RR-NATIVE 96b6334: capability names, grammars, environment), and the gate's launch mapping onto them. Each is a snapshot-bound
3
+ // adapter job: a pinned executable identity (the registration's), a committed cwd (the snapshot's counterpart of the step's directory), an argument grammar judged BEFORE the send by
4
+ // the same parser the pnpm registration uses (pnpm-adapter-grammar.mjs, a port of RustRight's adapter scan), no environment, and an output root the BROKER owns: a caller never names
5
+ // where the build writes (`-derivedDataPath`, `-archivePath`, `-exportPath`, `-resultBundlePath`, a cmake build tree or install prefix, a Gradle user or cache home or init script) and
6
+ // never supplies a signing identity (signing stays in the provisioned identity and session with its evidence handed off). The broker rewrites the caller's words into the one managed call
7
+ // (`managed_call`): every write under its per-project state, the receipt's `executable.managedOutputs` naming each location by role. Nested processes (the compiler the tool starts) are
8
+ // attributed to the job by the broker's capability token; cancellation reaps the tree. Nothing here is a direct-session exemption: an unsupported custom command, an unregistered tool
9
+ // or an output override refuses at planning, naming the capability.
10
+ //
11
+ // What the grammar cannot express is mirrored here from the broker (`native_call_problem`), so the gate sends exactly what the broker accepts and nothing it refuses:
12
+ // - the call's SHAPE (`nativeShapeProblem`): xcodebuild `-exportArchive` stands alone with its options file, `archive` is the only action of its call, a test plan or selection needs a
13
+ // test action and selections are `Target[/Class[/test]]`; cmake `--build` / `--install` are modes that must be the FIRST argument and never name a directory (the broker names the
14
+ // one managed build tree), a configure takes no `--config`/`--target`/`--parallel`, and no `-D` entry moves an output (`CMAKE_INSTALL_PREFIX`, `*_OUTPUT_DIRECTORY`, ...);
15
+ // - what only the file system can say (`nativeCallFsProblem`): the Gradle wrapper's pinned distribution, and an export options file that is a regular, small, XML, non-uploading file
16
+ // of the snapshot;
17
+ // - signing: no secret rides an argument or an environment key. The one transient Android signing-properties file is a typed FILE handoff (`signingFileProblem` mirrors the broker's
18
+ // `signing_file_problem` and `SigningFileLease`); RustRight has no request field that carries it yet, so a broker route refuses it before launch (`nativeSigningFileRefusal`);
19
+ // - the receipt's output mapping (`nativeManagedOutputs`, `nativeOutputsFromReceipt`): the roles and absolute paths under the job's managed state, read back, never guessed.
20
+ //
21
+ // The capability names are the contract with RustRight (docs/GATE-CONTRACT.md "Native tool adapters"): a broker advertises `adapter:<name>` in its capabilities, and the gate
22
+ // plans a stage that uses the tool only when it does.
23
+ import { createHash } from "node:crypto";
24
+ import nodeFs from "node:fs";
25
+ import path from "node:path";
26
+ import { SIGNING_FILE_ENV } from "./adapter-env.mjs";
27
+ import { adapterArgvRefusal } from "./pnpm-adapter-grammar.mjs";
28
+
29
+ const flags = (map) => Object.entries(map).map(([name, value]) => ({ name, value }));
30
+ const freeze = (o) => Object.freeze(JSON.parse(JSON.stringify(o)));
31
+
32
+ /** The reference registrations, keyed by tool. `registration` is the shape of RustRight's `adapters.<id>` entry (the fields the gate judges). */
33
+ export const NATIVE_ADAPTERS = Object.freeze({
34
+ xcodegen: freeze({
35
+ adapter: "rightkit-xcodegen", capability: "adapter:rightkit-xcodegen", executable: "xcodegen (digest-pinned by the registration)",
36
+ registration: { tool: "xcodegen", lane: "BUILD", compile: false, snapshot: true, snapshotSubcommands: ["generate"], subcommands: ["generate"],
37
+ flags: flags({ "--spec": "relpath", "--project": "relpath", "--quiet": "none", "--use-cache": "none" }), positionals: "none", env: [] },
38
+ }),
39
+ xcodebuild: freeze({
40
+ adapter: "rightkit-xcodebuild", capability: "adapter:rightkit-xcodebuild", executable: "xcodebuild (the selected Xcode's, digest-pinned by the registration)",
41
+ registration: { tool: "xcodebuild", lane: "BUILD", compile: true, snapshot: true, subcommands: null,
42
+ // A test plan is a bare name of a plan the project declares; `-exportArchive` reads one snapshot-relative options file; `-only-testing:` / `-skip-testing:` carry their identifier in the same word.
43
+ flags: flags({ "-project": "relpath", "-scheme": "any", "-configuration": "identifier", "-destination": "any", "-sdk": "identifier", "-quiet": "none", "-allowProvisioningUpdates": "none", "-allowProvisioningDeviceRegistration": "none", "-testPlan": "identifier", "-exportArchive": "none", "-exportOptionsPlist": "relpath" }),
44
+ flagPrefixes: { "-only-testing:": "any", "-skip-testing:": "any" },
45
+ positionals: "actionOrBuildSetting", positionalValues: ["build", "build-for-testing", "test", "test-without-building", "archive"], env: [] },
46
+ }),
47
+ gradle: freeze({
48
+ adapter: "rightkit-gradle", capability: "adapter:rightkit-gradle", executable: "the committed Gradle wrapper (its distributionSha256Sum pinned in gradle-wrapper.properties)",
49
+ registration: { tool: "gradle", lane: "BUILD", compile: true, snapshot: true, subcommands: null,
50
+ flags: flags({ "--offline": "none", "--no-daemon": "none", "--console": "identifier", "--stacktrace": "none", "--quiet": "none", "-q": "none", "-p": "relpath", "--project-dir": "relpath" }),
51
+ positionals: "gradleTask", env: [] },
52
+ }),
53
+ cmake: freeze({
54
+ adapter: "rightkit-cmake", capability: "adapter:rightkit-cmake", executable: "cmake (digest-pinned by the registration)",
55
+ registration: { tool: "cmake", lane: "BUILD", compile: true, snapshot: true, subcommands: null,
56
+ // `--build` and `--install` only select the mode (first argument): the broker names the one managed build tree, so there is no `-B` and no directory.
57
+ flags: flags({ "-S": "relpath", "-G": "any", "-A": "identifier", "--build": "none", "--config": "identifier", "--target": "any", "--parallel": "none", "--install": "none" }),
58
+ flagPrefixes: { "-D": "cmakeDefine" }, positionals: "none", env: [] },
59
+ }),
60
+ });
61
+
62
+ export const NATIVE_ADAPTER_CAPABILITIES = Object.freeze(Object.values(NATIVE_ADAPTERS).map((a) => a.capability));
63
+ const base = (command) => path.basename(String(command ?? "").replaceAll("\\", "/")).replace(/\.(exe|cmd|bat)$/i, "").toLowerCase();
64
+ /** The native tool a command names (`./gradlew` is Gradle), or null. */
65
+ export const nativeToolOf = (command) => { const b = base(command); const tool = b === "gradlew" ? "gradle" : b; return Object.hasOwn(NATIVE_ADAPTERS, tool) ? tool : null; };
66
+
67
+ /** The grammar of a tool's registration, in the shape the argument parser takes. */
68
+ export function nativeGrammar(tool) {
69
+ const { adapter, registration } = NATIVE_ADAPTERS[tool];
70
+ return Object.freeze({
71
+ adapter, subcommands: registration.subcommands, flags: Object.freeze(Object.fromEntries(registration.flags.map((f) => [f.name, f.value]))), positionals: registration.positionals,
72
+ positionalValues: registration.positionalValues, flagPrefixes: registration.flagPrefixes, env: Object.freeze([...registration.env]), opaqueAfterTarget: Object.freeze([]), opaqueTargets: Object.freeze({}),
73
+ snapshotSubcommands: Object.freeze([...(registration.snapshotSubcommands ?? [])]), scratchRoot: undefined,
74
+ });
75
+ }
76
+
77
+ // Flags whose value is a path the gate generated as an absolute one under the step's directory: a snapshot-bound adapter takes it relative to the job's cwd.
78
+ const PATH_VALUED = Object.freeze({ xcodegen: ["--spec", "--project"], xcodebuild: ["-project", "-exportOptionsPlist"], gradle: ["-p", "--project-dir"], cmake: ["-S"] });
79
+ // Options that would move where a build writes (the broker owns the output root: they are not registered, so the broker would refuse them anyway); refused here naming the canonical route.
80
+ const OUTPUT_OWNED = Object.freeze({
81
+ xcodebuild: ["-derivedDataPath", "-resultBundlePath", "-resultStreamPath", "-archivePath", "-exportPath", "-clonedSourcePackagesDirPath", "-packageCachePath"],
82
+ gradle: ["--gradle-user-home", "-g", "--project-cache-dir", "--init-script", "-I", "--include-build"],
83
+ cmake: ["-B", "--install-prefix", "--toolchain", "--preset", "--workflow"],
84
+ xcodegen: ["--cache-path"],
85
+ });
86
+
87
+ /** The words of a caller's argv read the way the broker reads them (native_adapters.rs `split_words`, itself `adapter::scan`): flag names, each valued flag's value, and the positionals. */
88
+ function splitWords(tool, caller) {
89
+ const { registration } = NATIVE_ADAPTERS[tool];
90
+ const valued = new Set(registration.flags.filter((flag) => flag.value !== "none").map((flag) => flag.name));
91
+ const prefixes = Object.keys(registration.flagPrefixes ?? {});
92
+ const found = { flags: [], values: [], positionals: [] };
93
+ let index = 0;
94
+ while (index < caller.length) {
95
+ const word = caller[index];
96
+ index += 1;
97
+ if (word === "--") { found.positionals.push(...caller.slice(index)); break; }
98
+ if (!word.startsWith("-") || word === "-") { found.positionals.push(word); continue; }
99
+ const prefix = prefixes.find((p) => word.startsWith(p) && word.length > p.length);
100
+ if (prefix !== undefined) { found.flags.push(prefix); found.values.push([prefix, word.slice(prefix.length)]); continue; }
101
+ if (word.startsWith("--") && word.includes("=")) { const at = word.indexOf("="); found.flags.push(word.slice(0, at)); found.values.push([word.slice(0, at), word.slice(at + 1)]); continue; }
102
+ found.flags.push(word);
103
+ if (valued.has(word)) { if (index < caller.length) found.values.push([word, caller[index]]); index += 1; }
104
+ }
105
+ return found;
106
+ }
107
+ const hasFlag = (words, flag) => words.flags.includes(flag);
108
+
109
+ /** `Target`, `Target/Class` or `Target/Class/test` (a test may end in `()`), each a bare name (native_adapters.rs `test_identifier_ok`). */
110
+ const testIdentifierOk = (value) => {
111
+ const parts = value.split("/");
112
+ return value !== "" && value.length <= 256 && parts.length <= 3 && parts.every((part) => { const name = part.endsWith("()") ? part.slice(0, -2) : part; return /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(name); });
113
+ };
114
+
115
+ /** What an xcodebuild call is, or why it has no managed form (native_adapters.rs `xcodebuild_mode`): "export" | "archive" | "test" | "build", or `{ problem }`. */
116
+ function xcodebuildMode(words) {
117
+ const actions = words.positionals.filter((word) => !word.includes("="));
118
+ const tests = actions.some((action) => ["test", "test-without-building", "build-for-testing"].includes(action));
119
+ const archives = actions.includes("archive");
120
+ const selecting = hasFlag(words, "-testPlan") || hasFlag(words, "-only-testing:") || hasFlag(words, "-skip-testing:");
121
+ for (const [name, value] of words.values) if ((name === "-only-testing:" || name === "-skip-testing:") && !testIdentifierOk(value)) return { problem: `${name}${value} is not a test identifier (Target, Target/Class or Target/Class/test)` };
122
+ if (hasFlag(words, "-exportArchive")) {
123
+ if (words.positionals.length) return { problem: "-exportArchive takes no action or build setting" };
124
+ for (const flag of ["-project", "-scheme", "-configuration", "-destination", "-sdk"]) if (hasFlag(words, flag)) return { problem: `-exportArchive reads the managed archive; ${flag} belongs to a build` };
125
+ if (selecting) return { problem: "a test plan or selection does not belong to -exportArchive" };
126
+ if (!hasFlag(words, "-exportOptionsPlist")) return { problem: "-exportArchive needs -exportOptionsPlist" };
127
+ return "export";
128
+ }
129
+ if (hasFlag(words, "-exportOptionsPlist")) return { problem: "-exportOptionsPlist belongs to -exportArchive" };
130
+ if (archives && actions.length > 1) return { problem: "archive is the only action of its call" };
131
+ if (selecting && !tests) return { problem: "a test plan or selection needs build-for-testing, test or test-without-building" };
132
+ return archives ? "archive" : tests ? "test" : "build";
133
+ }
134
+
135
+ /** What a cmake call is, or why it has no managed form (native_adapters.rs `cmake_mode`): "configure" | "build" | "install", or `{ problem }`. cmake reads `--build`/`--install` only as its first argument. */
136
+ function cmakeMode(words, caller) {
137
+ const build = hasFlag(words, "--build");
138
+ const install = hasFlag(words, "--install");
139
+ if (build && install) return { problem: "cmake takes --build or --install, not both" };
140
+ const mode = build ? "build" : install ? "install" : "configure";
141
+ if (mode === "configure") {
142
+ for (const flag of ["--config", "--target", "--parallel"]) if (hasFlag(words, flag)) return { problem: `${flag} belongs to --build or --install, not to a configure` };
143
+ return mode;
144
+ }
145
+ const selector = build ? "--build" : "--install";
146
+ if (caller[0] !== selector) return { problem: `${selector} must be the first argument: cmake reads its mode there` };
147
+ for (const flag of ["-S", "-G", "-A", "-D"]) if (hasFlag(words, flag)) return { problem: `${flag} configures; it does not belong to ${selector}` };
148
+ return mode;
149
+ }
150
+
151
+ /** CMake cache entries that move where a build or an install writes: the broker owns the build tree and the install prefix (native_adapters.rs `cmake_output_define`). */
152
+ const cmakeOutputDefine = (name) => {
153
+ const upper = name.toUpperCase();
154
+ return ["CMAKE_INSTALL_PREFIX", "CMAKE_STAGING_PREFIX", "EXECUTABLE_OUTPUT_PATH", "LIBRARY_OUTPUT_PATH", "FETCHCONTENT_BASE_DIR"].includes(upper)
155
+ || ["CMAKE_RUNTIME_OUTPUT_DIRECTORY", "CMAKE_LIBRARY_OUTPUT_DIRECTORY", "CMAKE_ARCHIVE_OUTPUT_DIRECTORY", "CMAKE_PDB_OUTPUT_DIRECTORY", "CMAKE_COMPILE_PDB_OUTPUT_DIRECTORY"].some((family) => upper.startsWith(family))
156
+ || (upper.startsWith("CMAKE_") && upper.endsWith("_MODULE_DIRECTORY"));
157
+ };
158
+
159
+ const relativeShape = (value) => value !== "" && !value.includes("\0") && !/^[/\\]/.test(value) && !(value.length >= 2 && value[1] === ":") && value.split(/[\\/]/).every((part) => part !== "..");
160
+
161
+ /**
162
+ * Why a call has no managed form, judged on the argv alone (native_adapters.rs `managed_call` modes and `native_call_problem`'s shape rules), or null: the mode rules of xcodebuild and
163
+ * cmake, test selections, an output-moving `-D` entry and a non-relative `-exportOptionsPlist`. Pure: the file-system rules are `nativeCallFsProblem`.
164
+ */
165
+ export function nativeShapeProblem(tool, args) {
166
+ if (!Object.hasOwn(NATIVE_ADAPTERS, tool)) return `${tool} is not a native build tool`;
167
+ const caller = args.map(String);
168
+ const words = splitWords(tool, caller);
169
+ if (tool === "xcodebuild") {
170
+ const mode = xcodebuildMode(words);
171
+ if (typeof mode !== "string") return mode.problem;
172
+ const plist = words.values.find(([name]) => name === "-exportOptionsPlist")?.[1];
173
+ if (plist !== undefined && !relativeShape(plist)) return `-exportOptionsPlist ${JSON.stringify(plist)} must be a relative path with no \`..\``;
174
+ }
175
+ if (tool === "cmake") {
176
+ const mode = cmakeMode(words, caller);
177
+ if (typeof mode !== "string") return mode.problem;
178
+ for (const [name, value] of words.values) {
179
+ if (name !== "-D") continue;
180
+ const entry = value.split(/[=:]/)[0];
181
+ if (cmakeOutputDefine(entry)) return `-D${entry} moves where CMake writes; the broker owns the build tree and the install prefix`;
182
+ }
183
+ }
184
+ return null;
185
+ }
186
+
187
+ /**
188
+ * The argv a carried native-tool step sends, or a typed refusal naming the capability: a path flag the gate wrote as an absolute path inside `cwd` is made relative to it (one outside
189
+ * `cwd` is refused: a snapshot-bound call names only the snapshot); an option that would move the build's OUTPUT (the broker owns the output root) is refused with the canonical route
190
+ * named, never stripped or rewritten; everything left is judged by the tool's registration (snapshot-bound mode) and by the broker's shape rules (`nativeShapeProblem`).
191
+ * `{ args }` or `{ refusal: { code, message } }`. Pure: it reads no file (planning calls it with a directory that does not exist); the file-system rules are `nativeCallFsProblem`.
192
+ */
193
+ export function nativeToolLaunch(tool, args, { cwd }) {
194
+ const entry = NATIVE_ADAPTERS[tool];
195
+ const words = args.map(String);
196
+ const refuse = (message) => ({ refusal: { code: "RK_NATIVE_TOOL_REFUSED", message: `${tool} ${words.join(" ")}: ${message}; the broker's ${entry.adapter} adapter (capability ${entry.capability}) would refuse it (rightkit#1241), so it is not sent` } });
197
+ const nameOf = (word) => (word.includes("=") && word.startsWith("--") ? word.slice(0, word.indexOf("=")) : word);
198
+ const owned = words.find((word) => (OUTPUT_OWNED[tool] ?? []).includes(nameOf(word)) || (tool === "cmake" && /^-B./.test(word)));
199
+ if (owned !== undefined) return refuse(`${nameOf(owned)} names where the build writes, and the broker owns the output root (it supplies the one managed location); send the step without it`);
200
+ const pathValued = PATH_VALUED[tool] ?? [];
201
+ const relative = (flag, value) => {
202
+ if (!(path.isAbsolute(value) || path.win32.isAbsolute(value))) return { value };
203
+ const rel = path.relative(path.resolve(cwd), path.resolve(value));
204
+ if (rel === "") return { value: "." };
205
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return { problem: `${flag} ${value} is outside the step's directory ${cwd}` };
206
+ return { value: rel.split(path.sep).join("/") };
207
+ };
208
+ const out = [];
209
+ for (let i = 0; i < words.length; i += 1) {
210
+ const word = words[i];
211
+ const inline = word.startsWith("--") && word.includes("=") && pathValued.includes(word.slice(0, word.indexOf("=")));
212
+ if (inline) {
213
+ const flag = word.slice(0, word.indexOf("="));
214
+ const made = relative(flag, word.slice(flag.length + 1));
215
+ if (made.problem) return refuse(made.problem);
216
+ out.push(`${flag}=${made.value}`);
217
+ continue;
218
+ }
219
+ out.push(word);
220
+ if (!pathValued.includes(word) || i + 1 >= words.length) continue;
221
+ i += 1;
222
+ const made = relative(word, words[i]);
223
+ if (made.problem) return refuse(made.problem);
224
+ out.push(made.value);
225
+ }
226
+ const problem = adapterArgvRefusal(out, nativeGrammar(tool), { snapshot: true }) ?? nativeShapeProblem(tool, out);
227
+ return problem ? refuse(problem) : { args: out };
228
+ }
229
+
230
+ /** Why a committed Gradle wrapper cannot be an adapter's pinned executable: its properties carry no `distributionSha256Sum`. `readFile(relative)` reads the commit's bytes; null when it can. */
231
+ export function gradleWrapperPinProblem(readFile, cwd = ".") {
232
+ const properties = path.posix.join(String(cwd).replaceAll("\\", "/"), "gradle", "wrapper", "gradle-wrapper.properties");
233
+ let text = null;
234
+ try { text = readFile(properties.replace(/^\.\//, "")); } catch { text = null; }
235
+ if (typeof text !== "string") return `${properties} is not in the commit, so the Gradle wrapper has no pinned identity`;
236
+ return /^\s*distributionSha256Sum\s*=\s*[0-9a-f]{64}\s*$/m.test(text) ? null : `${properties} carries no distributionSha256Sum, so the Gradle distribution the wrapper downloads is not pinned`;
237
+ }
238
+
239
+ /** The export options an `-exportArchive` reads: a regular file (never a link) of the snapshot, small, an XML property list, that does not ask for an upload (native_adapters.rs `export_options_problem`). */
240
+ function exportOptionsProblem(file, fsOps) {
241
+ let meta;
242
+ try { meta = fsOps.lstatSync(file); } catch { return `${file} is not in the snapshot`; }
243
+ if (!meta.isFile()) return `${file} is not a regular file`;
244
+ if (meta.size > 65_536) return `${file} is larger than an export options file`;
245
+ let text;
246
+ try { text = fsOps.readFileSync(file, "utf8"); } catch { return `${file} is not a text property list`; }
247
+ if (!text.replace(/^/, "").trimStart().startsWith("<?xml")) return `${file} must be an XML property list`;
248
+ const key = "<key>destination</key>";
249
+ for (let at = text.indexOf(key); at >= 0; at = text.indexOf(key, at + key.length)) {
250
+ const rest = text.slice(at + key.length).trimStart();
251
+ const value = rest.startsWith("<string>") && rest.includes("</string>") ? rest.slice("<string>".length, rest.indexOf("</string>")).trim() : null;
252
+ if (value === "upload") return `${file} asks the export to upload; a build does not publish`;
253
+ }
254
+ return null;
255
+ }
256
+
257
+ /**
258
+ * What only the file system can say about a call, judged where the call runs (native_adapters.rs `native_call_problem`'s second half), or null. `cwd` is the step's directory in the snapshot.
259
+ * A Gradle call needs the wrapper's distribution pinned (`RK_ADAPTER_PIN_REQUIRED` at the broker: `pin: true`); an `-exportArchive` needs its options file. `{ message, pin }` or null.
260
+ */
261
+ export function nativeCallFsProblem(tool, args, { cwd, fsOps = nodeFs } = {}) {
262
+ const caller = args.map(String);
263
+ if (tool === "gradle") {
264
+ let project = path.resolve(cwd);
265
+ for (let i = 0; i < caller.length; i += 1) {
266
+ if ((caller[i] === "-p" || caller[i] === "--project-dir") && i + 1 < caller.length) { project = path.resolve(cwd, caller[i + 1]); i += 1; }
267
+ else if (caller[i].startsWith("--project-dir=")) project = path.resolve(cwd, caller[i].slice("--project-dir=".length));
268
+ }
269
+ const message = gradleWrapperPinProblem((relative) => fsOps.readFileSync(relative, "utf8"), project);
270
+ return message ? { message, pin: true } : null;
271
+ }
272
+ if (tool === "xcodebuild") {
273
+ const plist = splitWords(tool, caller).values.find(([name]) => name === "-exportOptionsPlist")?.[1];
274
+ if (plist === undefined) return null;
275
+ if (!relativeShape(plist)) return { message: `-exportOptionsPlist ${JSON.stringify(plist)} must be a relative path with no \`..\``, pin: false };
276
+ const message = exportOptionsProblem(path.resolve(cwd, plist), fsOps);
277
+ return message ? { message, pin: false } : null;
278
+ }
279
+ return null;
280
+ }
281
+
282
+ // ---- the signing-properties file (RA-08) ----------------------------------------------------------------------------------------------------------------------------
283
+ /** The environment variable an Android release hands its Gradle command the path of the transient signing-properties file in (android-release.mjs). Only its PATH ever travels. */
284
+ export const NATIVE_SIGNING_FILE_ENV = SIGNING_FILE_ENV;
285
+ /** The typed capability a broker would advertise for carrying that file (a lease bound to the owned signing phase). RustRight defines none today (its `SigningFileLease` has no request field). */
286
+ export const NATIVE_SIGNING_FILE_CAPABILITY = "native-signing-file-v1";
287
+ export const NATIVE_SIGNING_FILE_UNSUPPORTED = "RK_NATIVE_SIGNING_FILE_UNSUPPORTED";
288
+ /** The largest signing-properties file a lease binds (native_adapters.rs SIGNING_FILE_MAX_BYTES). */
289
+ export const SIGNING_FILE_MAX_BYTES = 16 * 1024;
290
+
291
+ /**
292
+ * Why `file` is not an acceptable transient signing-properties file of the provisioned signer session (native_adapters.rs `signing_file_problem`, mirrored), or null: an absolute path without
293
+ * `.` or `..` segments, a plain file name, a regular file that is not a link, non-empty and small, reachable only by its owner (POSIX), in a directory not writable by others whose real path is
294
+ * inside the canonical `signerRoot` (the host's own configuration, never a request's). The file holds the secrets; they never ride an argument, an environment value, a carrier or a log.
295
+ */
296
+ export function signingFileProblem(file, { signerRoot, fsOps = nodeFs, platform = process.platform } = {}) {
297
+ const shown = String(file);
298
+ if (typeof file !== "string" || shown.includes("\0") || !path.isAbsolute(shown) || shown.split(/[\\/]/).some((part) => part === "." || part === "..")) return `signing file ${shown} must be an absolute path without \`.\` or \`..\``;
299
+ const name = path.basename(shown);
300
+ if (name.length > 128 || name.startsWith(".") || !/^[A-Za-z0-9._-]+$/.test(name)) return `signing file name ${JSON.stringify(name)} must be 1 to 128 of A-Z, a-z, 0-9, '.', '_' and '-', not starting with '.'`;
301
+ let root;
302
+ try { root = fsOps.realpathSync(signerRoot); } catch { return `the signer root ${signerRoot} cannot be resolved`; }
303
+ let meta;
304
+ try { meta = fsOps.lstatSync(shown); } catch (error) { return `signing file ${shown} is not readable: ${error.code ?? error.message}`; }
305
+ if (!meta.isFile()) return `signing file ${shown} is not a regular file (a link or a directory is refused)`;
306
+ if (meta.size === 0 || meta.size > SIGNING_FILE_MAX_BYTES) return `signing file ${shown} must hold 1 to ${SIGNING_FILE_MAX_BYTES} bytes`;
307
+ let parent;
308
+ try { parent = fsOps.realpathSync(path.dirname(shown)); } catch { return `the directory of signing file ${shown} cannot be resolved`; }
309
+ if (parent !== root && !parent.startsWith(root.endsWith(path.sep) ? root : root + path.sep)) return `signing file ${shown} is not inside the signer root ${root}`;
310
+ if (platform !== "win32") {
311
+ if ((meta.mode & 0o077) !== 0) return `signing file ${shown} must be reachable by its owner only`;
312
+ let dir;
313
+ try { dir = fsOps.statSync(parent); } catch { return `the directory of signing file ${shown} is writable by others`; }
314
+ if ((dir.mode & 0o022) !== 0) return `the directory of signing file ${shown} is writable by others`;
315
+ }
316
+ return null;
317
+ }
318
+
319
+ /**
320
+ * The refusal of a signing-file handoff on a broker route, or null when no file is handed. The broker's grammar carries no signing identity and every adapter's `env` is empty, and RustRight
321
+ * has no request field that binds the file to the owned signing phase (its `SigningFileLease` is not wired to a request; the signed Android release phase is an open policy), so the only
322
+ * honest answer today is a typed refusal BEFORE launch: the path never rides RUSTRIGHT_ADAPTER_ENV, an argument or the client's environment, and nothing is sent. A host without a broker
323
+ * (a direct Linux host) never reaches this: its Gradle runs in the gate's own session with the path in its environment, as before.
324
+ */
325
+ export function nativeSigningFileRefusal(tool, signingFile, { signerRoot } = {}) {
326
+ if (signingFile === undefined || signingFile === null || signingFile === "") return null;
327
+ const entry = NATIVE_ADAPTERS[tool];
328
+ const shape = signerRoot === undefined ? null : signingFileProblem(signingFile, { signerRoot });
329
+ return { code: NATIVE_SIGNING_FILE_UNSUPPORTED, message: `${tool}: a signing-properties file handoff (${NATIVE_SIGNING_FILE_ENV}, path only) needs the broker capability ${NATIVE_SIGNING_FILE_CAPABILITY}, which no RustRight broker advertises: its ${entry?.adapter ?? tool} adapter admits no environment, and the signed Android release phase is an open policy (the release task itself is refused by the grammar)${shape ? `; the file is also not acceptable (${shape})` : ""}; nothing was sent, and the path is never forwarded in an argument or an environment` };
330
+ }
331
+
332
+ // ---- the managed output mapping (RA-07, RA-09) ------------------------------------------------------------------------------------------------------------------------
333
+ /** The leaves under a call's managed state, one per managed location (native_adapters.rs LEAF_*). */
334
+ export const NATIVE_STATE_LEAVES = Object.freeze({
335
+ xcodegenCache: "cache", derivedData: "derived-data", archive: "archive/App.xcarchive", export: "export", results: "results",
336
+ gradleHome: "gradle-user-home", gradleCache: "project-cache", gradleBuild: "gradle-build", gradleInit: "output.init.gradle", cmakeBuild: "cmake-build", cmakeInstall: "cmake-install",
337
+ });
338
+
339
+ /** The token naming a job's run-unique leaves (service.rs `native_run_token`): the job id itself when it is 1 to 64 of A-Z, a-z, 0-9, `_`, `-`; else `j` plus 32 hex of its SHA-256. */
340
+ export const nativeRunToken = (jobId) => (typeof jobId === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(jobId) ? jobId : `j${createHash("sha256").update(String(jobId)).digest("hex").slice(0, 32)}`);
341
+
342
+ /** The one managed directory of a tool's state for a call run in `relativeCwd` (snapshot-relative): `<outputDir>/native/<tool>/<sha256 of the components joined by "/">` (native_output_dir). */
343
+ export function nativeStateDir(outputDir, tool, relativeCwd = "") {
344
+ const key = String(relativeCwd).split(/[\\/]/).filter((part) => part !== "" && part !== ".").join("/");
345
+ return path.join(outputDir, "native", tool, createHash("sha256").update(key).digest("hex"));
346
+ }
347
+
348
+ /** Whether a call writes managed state at all (xcodegen without `--use-cache` writes only the snapshot inputs it exists to generate). */
349
+ export const nativeStateNeeded = (tool, args) => tool !== "xcodegen" || args.map(String).includes("--use-cache");
350
+
351
+ /**
352
+ * The locations a call may write, by role, exactly as the broker's `managed_call` names them in the receipt (`[{ role, path }]`, in the broker's order), for the call's SENT args under `state`.
353
+ * `jobId` names the run-unique result bundle of a test action; without it that one entry is `{ role: "result-bundle", path: null, unresolved: true }` (the directory is `results`).
354
+ * `{ problem }` when the call has no managed form.
355
+ */
356
+ export function nativeManagedOutputs(tool, args, { state, jobId } = {}) {
357
+ const caller = args.map(String);
358
+ const problem = nativeShapeProblem(tool, caller);
359
+ if (problem) return { problem };
360
+ const at = (leaf) => path.join(state, leaf);
361
+ const words = splitWords(tool, caller);
362
+ const L = NATIVE_STATE_LEAVES;
363
+ const out = [];
364
+ const add = (role, file, extra = {}) => out.push({ role, path: file, ...extra });
365
+ if (tool === "xcodegen") { if (hasFlag(words, "--use-cache")) add("cache", at(L.xcodegenCache)); }
366
+ else if (tool === "xcodebuild") {
367
+ const mode = xcodebuildMode(words);
368
+ if (mode === "export") { add("archive", at(L.archive)); add("export", at(L.export)); }
369
+ else {
370
+ add("derived-data", at(L.derivedData));
371
+ if (mode === "archive") add("archive", at(L.archive));
372
+ if (mode === "test") add("result-bundle", jobId === undefined ? null : path.join(at(L.results), `${nativeRunToken(jobId)}.xcresult`), jobId === undefined ? { unresolved: true } : {});
373
+ }
374
+ } else if (tool === "gradle") {
375
+ add("gradle-user-home", at(L.gradleHome)); add("project-cache", at(L.gradleCache)); add("build-tree", at(L.gradleBuild)); add("init-script", at(L.gradleInit));
376
+ } else if (tool === "cmake") {
377
+ const mode = cmakeMode(words, caller);
378
+ add("build-tree", at(L.cmakeBuild));
379
+ if (mode !== "build") add("install-tree", at(L.cmakeInstall));
380
+ }
381
+ return { outputs: out };
382
+ }
383
+
384
+ /**
385
+ * The receipt's output mapping read back (RA-07, RA-09): the signed receipt's `executable.managedOutputs` (`{ tool, state, outputs: [{ role, path }] }`, which the broker writes from the
386
+ * call it really spawned) is accepted only when it is exactly what the SENT args map to under the job's verified `outputDir` and the step's snapshot-relative directory: the same tool, the
387
+ * same state directory, the same roles and paths in the same order. `{ ok: true, state, outputs, byRole }` or `{ ok: false, problem }`; a caller consumes `byRole` (role to absolute path),
388
+ * never a path it composed itself.
389
+ */
390
+ export function nativeOutputsFromReceipt(managed, { tool, args, outputDir, relativeCwd = "", jobId }) {
391
+ const bad = (problem) => ({ ok: false, problem });
392
+ if (managed === null || typeof managed !== "object" || !Array.isArray(managed.outputs)) return bad("the receipt carries no managedOutputs (the broker did not report the native call's output mapping)");
393
+ if (typeof outputDir !== "string" || outputDir === "") return bad("the job's verified output directory is not known");
394
+ const state = nativeStateDir(outputDir, tool, relativeCwd);
395
+ const expected = nativeManagedOutputs(tool, args, { state, jobId });
396
+ if (expected.problem) return bad(expected.problem);
397
+ if (managed.tool !== tool) return bad(`the receipt maps a ${JSON.stringify(managed.tool)} call, not ${tool}`);
398
+ if (typeof managed.state !== "string" || path.normalize(managed.state) !== path.normalize(state)) return bad(`the receipt's state ${JSON.stringify(managed.state)} is not the job's managed state ${state}`);
399
+ if (managed.outputs.length !== expected.outputs.length) return bad(`the receipt maps ${managed.outputs.length} output(s), the call's grammar maps ${expected.outputs.length}`);
400
+ for (let i = 0; i < expected.outputs.length; i += 1) {
401
+ const got = managed.outputs[i];
402
+ const exp = expected.outputs[i];
403
+ if (got?.role !== exp.role) return bad(`receipt output ${i} is role ${JSON.stringify(got?.role)}, expected ${exp.role}`);
404
+ if (typeof got.path !== "string") return bad(`receipt output ${exp.role} has no path`);
405
+ if (!exp.unresolved && path.normalize(got.path) !== path.normalize(exp.path)) return bad(`receipt output ${exp.role} is ${got.path}, expected ${exp.path}`);
406
+ if (exp.unresolved && path.dirname(path.normalize(got.path)) !== path.join(state, NATIVE_STATE_LEAVES.results)) return bad(`receipt output ${exp.role} ${got.path} is not under ${path.join(state, NATIVE_STATE_LEAVES.results)}`);
407
+ }
408
+ const outputs = managed.outputs.map(({ role, path: file }) => ({ role, path: file }));
409
+ return { ok: true, state, outputs, byRole: Object.fromEntries(outputs.map(({ role, path: file }) => [role, file])) };
410
+ }
411
+
@@ -2,8 +2,7 @@
2
2
  //
3
3
  // nativeHelpers: [{
4
4
  // name: "menubar", // unique label (logs, receipts)
5
- // package: "macos-native/Pkg" // app-relative SwiftPM package dir ...
6
- // // sharedPackage: "cloudkit", // ... or a RightKit shared Swift package key (exactly one of the two)
5
+ // package: "macos-native/Pkg" // app-relative SwiftPM package dir (committed in the app; a shared helper is pinned by URL + tag in its Package.swift)
7
6
  // product: "MenuBar", // SwiftPM executable product
8
7
  // kind: "app" | "executable",
9
8
  // embed: "Contents/Library/LoginItems/MenuBar.app", // path inside the .app
@@ -24,10 +23,10 @@
24
23
  // nested bundles itself, and signNativeHelpers below does the same for any other caller. Every external effect is injectable.
25
24
  import { createHash } from "node:crypto";
26
25
  import { buildLaunch } from "./build-launch.mjs";
26
+ import { carriedCargoMapOf } from "./carried-cargo.mjs";
27
27
  import * as nodeFs from "node:fs";
28
28
  import os from "node:os";
29
29
  import path from "node:path";
30
- import { fileURLToPath } from "node:url";
31
30
  import { spawnSync as nodeSpawnSync } from "./background-process.mjs";
32
31
  import { signMacosEntries } from "./mac-sign-core.mjs";
33
32
  import { discoverNestedCode } from "./swift-mac-app.mjs";
@@ -38,12 +37,10 @@ export class NativeHelpersError extends Error {
38
37
  const fail = (code, message, details) => { throw new NativeHelpersError(code, message, details); };
39
38
  const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
40
39
 
41
- export const NATIVE_HELPER_KEYS = Object.freeze(["name", "package", "sharedPackage", "product", "kind", "embed", "universal", "entitlements", "executable", "info", "resources", "appIntents", "provisioningProfile"]);
40
+ export const NATIVE_HELPER_KEYS = Object.freeze(["name", "package", "product", "kind", "embed", "universal", "entitlements", "executable", "info", "resources", "appIntents", "provisioningProfile"]);
42
41
  const INFO_KEYS = ["bundleId", "name", "displayName", "uiElement", "minimumSystemVersion"];
43
42
  const APP_INTENTS_KEYS = ["moduleName", "deploymentTarget"];
44
43
  const RESOURCE_KEYS = ["from", "to"];
45
- /** RightKit shared Swift packages, keyed, relative to the RightKit checkout root (env RIGHTKIT_SWIFT_ROOT overrides the root). */
46
- export const SHARED_SWIFT_PACKAGES = Object.freeze({ cloudkit: "swift/RightKitCloudKitHelper" });
47
44
  /** Where a helper may be embedded: only roots the inside-out signing discovery walks. */
48
45
  const EMBED_ROOTS = ["Contents/Library/", "Contents/Helpers/", "Contents/XPCServices/", "Contents/PlugIns/", "Contents/MacOS/"];
49
46
  const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
@@ -71,13 +68,14 @@ export function validateNativeHelpers(list, { label = "nativeHelpers" } = {}) {
71
68
  list.forEach((raw, index) => {
72
69
  const at = `${label}[${index}]`;
73
70
  if (!isObject(raw)) { errors.push(`${at} must be an object`); return; }
74
- unknown(raw, NATIVE_HELPER_KEYS, at, errors);
71
+ // `sharedPackage` (a RightKit-checkout package resolved outside the commit) is retired (rightkit#1302): refuse it with its replacement.
72
+ const { sharedPackage, ...declared } = raw;
73
+ if (sharedPackage !== undefined) errors.push(`${at}.sharedPackage is retired: it resolved a package from RIGHTKIT_SWIFT_ROOT or the installed RightKit package root, outside the commit snapshot. Declare the helper with \`package: "<the app's SwiftPM package directory>"\` and \`product: "rightkit-cloudkit-helper"\`, with the shared helper pinned by URL and tag through that package's own Package.swift`);
74
+ unknown(declared, NATIVE_HELPER_KEYS, at, errors);
75
75
  const helper = {};
76
76
  if (typeof raw.name !== "string" || !NAME.test(raw.name)) errors.push(`${at}.name must be 1-64 characters of letters, digits, '.', '_' and '-'`); else helper.name = raw.name;
77
- if ((raw.package === undefined) === (raw.sharedPackage === undefined)) errors.push(`${at} needs exactly one of package or sharedPackage`);
78
- else if (raw.package !== undefined) { const value = relative(raw.package, `${at}.package`, errors); if (value) helper.package = value; }
79
- else if (typeof raw.sharedPackage !== "string" || !Object.hasOwn(SHARED_SWIFT_PACKAGES, raw.sharedPackage)) errors.push(`${at}.sharedPackage must be one of ${Object.keys(SHARED_SWIFT_PACKAGES).join(", ")}`);
80
- else helper.sharedPackage = raw.sharedPackage;
77
+ if (raw.package === undefined) { if (sharedPackage === undefined) errors.push(`${at}.package is required (the app-relative SwiftPM package directory)`); }
78
+ else { const value = relative(raw.package, `${at}.package`, errors); if (value) helper.package = value; }
81
79
  if (typeof raw.product !== "string" || !PRODUCT.test(raw.product)) errors.push(`${at}.product must be a SwiftPM product name`); else helper.product = raw.product;
82
80
  if (raw.kind !== "app" && raw.kind !== "executable") errors.push(`${at}.kind must be "app" or "executable"`); else helper.kind = raw.kind;
83
81
  if (typeof raw.universal !== "boolean") errors.push(`${at}.universal is required and must be true or false`); else helper.universal = raw.universal;
@@ -197,10 +195,10 @@ function findFile(fs, root, predicate) {
197
195
  * options: appRoot (absolute), app (absolute .app), helpers (validated or raw), version (required with a kind "app" helper),
198
196
  * forceArch ("arm64"|"x86_64": build only that slice for every helper, e.g. a dev build or a thin release triple),
199
197
  * configuration ("release"|"debug"), env, spawn, fs, log, replaceExisting (default false: an existing embed path is an error),
200
- * sharedRoot, hostArch, tmpdir. Without forceArch a universal helper gets both slices and a thin one the host arch.
198
+ * hostArch, tmpdir. Without forceArch a universal helper gets both slices and a thin one the host arch.
201
199
  * Returns { helpers: [{ name, embed, path, source, archs, sha256 }], events: [...] }.
202
200
  */
203
- export function buildAndEmbedNativeHelpers({ appRoot, app, helpers: rawHelpers, version, forceArch, configuration = "release", env = process.env, spawn = nodeSpawnSync, fs = nodeFs, log = () => {}, replaceExisting = false, sharedRoot, hostArch = process.arch === "arm64" ? "arm64" : "x86_64", tmpdir = os.tmpdir(), packageOwner = (pkg) => { const result = nodeSpawnSync("git", ["-C", pkg, "rev-parse", "--show-toplevel"], { encoding: "utf8", windowsHide: true }); return result.status === 0 ? String(result.stdout).trim() : null; } } = {}) {
201
+ export function buildAndEmbedNativeHelpers({ appRoot, app, helpers: rawHelpers, version, forceArch, configuration = "release", env = process.env, spawn = nodeSpawnSync, fs = nodeFs, log = () => {}, replaceExisting = false, hostArch = process.arch === "arm64" ? "arm64" : "x86_64", tmpdir = os.tmpdir(), packageOwner = (pkg) => { const result = nodeSpawnSync("git", ["-C", pkg, "rev-parse", "--show-toplevel"], { encoding: "utf8", windowsHide: true }); return result.status === 0 ? String(result.stdout).trim() : null; } } = {}) {
204
202
  if (typeof appRoot !== "string" || !path.isAbsolute(appRoot)) fail("NATIVE_HELPERS_INVALID", "appRoot must be an absolute path");
205
203
  if (typeof app !== "string" || !path.isAbsolute(app) || !app.replace(/\/+$/, "").endsWith(".app")) fail("NATIVE_HELPERS_INVALID", "app must be the absolute path of the .app bundle");
206
204
  if (!["release", "debug"].includes(configuration)) fail("NATIVE_HELPERS_INVALID", "configuration must be release or debug");
@@ -208,9 +206,14 @@ export function buildAndEmbedNativeHelpers({ appRoot, app, helpers: rawHelpers,
208
206
  const helpers = validateNativeHelpers(rawHelpers);
209
207
  if (helpers.some((helper) => helper.kind === "app") && (typeof version !== "string" || !VERSION.test(version))) fail("NATIVE_HELPERS_INVALID", "a kind \"app\" helper needs the app version (1-3 numeric parts) for its Info.plist");
210
208
  if (!fs.existsSync(path.join(app, "Contents", "Info.plist"))) fail("NATIVE_HELPERS_INPUT_MISSING", `${app} is not a built .app (no Contents/Info.plist); helpers are embedded after the app build`);
211
- const root = sharedRoot ?? env.RIGHTKIT_SWIFT_ROOT ?? fileURLToPath(new URL("../../", import.meta.url));
212
209
  const events = [];
213
210
  const results = [];
211
+ // The commit snapshot a carried stage builds from (the gate's carrier map names it), or null: not a snapshot stage on a broker host.
212
+ const carrier = carriedCargoMapOf(env);
213
+ if (carrier?.error) fail("NATIVE_HELPERS_SNAPSHOT_SOURCE", `native helpers: ${carrier.error}; a snapshot stage's Swift build is never run around its carrier`);
214
+ const realOf = (target) => { try { return (fs.realpathSync?.native ?? fs.realpathSync)(target); } catch { return path.resolve(target); } };
215
+ const insideDir = (child, parent) => { const rel = path.relative(parent, child); return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); };
216
+ const snapshotRoot = carrier?.map ? realOf(carrier.map.broker.snapshot.path) : null;
214
217
  const run = (what, cmd, args, { capture = false, cwd = appRoot } = {}) => {
215
218
  log(`native-helpers: ${what}: ${cmd} ${args.join(" ")}`);
216
219
  const launch = cmd === "swift" ? buildLaunch(cmd, args, { cwd, env, exists: fs.existsSync }) : { command: cmd, args };
@@ -219,14 +222,19 @@ export function buildAndEmbedNativeHelpers({ appRoot, app, helpers: rawHelpers,
219
222
  return result;
220
223
  };
221
224
  for (const helper of helpers) {
222
- const pkgDir = helper.package ? path.resolve(appRoot, helper.package) : path.resolve(root, SHARED_SWIFT_PACKAGES[helper.sharedPackage]);
223
- if (helper.package && path.relative(appRoot, pkgDir).startsWith("..")) fail("NATIVE_HELPERS_INVALID", `${helper.name}: package escapes the app root`);
224
- if (!fs.existsSync(path.join(pkgDir, "Package.swift"))) fail("NATIVE_HELPERS_INPUT_MISSING", `${helper.name}: no Package.swift in ${pkgDir}${helper.sharedPackage ? " (set RIGHTKIT_SWIFT_ROOT to the RightKit checkout)" : ""}`);
225
+ const pkgDir = path.resolve(appRoot, helper.package);
226
+ if (path.relative(appRoot, pkgDir).startsWith("..")) fail("NATIVE_HELPERS_INVALID", `${helper.name}: package escapes the app root`);
227
+ // A snapshot stage on a broker host builds only what the commit holds (GATE-CONTRACT "Commit-only build"): a package outside the snapshot
228
+ // (a symlink out of the app) is refused here, with the code and what to supply, before a broker job could refuse its working directory or a
229
+ // build could read an uncommitted file.
230
+ if (snapshotRoot !== null && !insideDir(realOf(pkgDir), snapshotRoot)) fail("NATIVE_HELPERS_SNAPSHOT_SOURCE", `${helper.name}: the Swift package ${pkgDir} is outside this stage's commit snapshot (${snapshotRoot}); a snapshot stage builds only committed source; declare a package inside the app's own repository.`, { helper: helper.name, package: pkgDir, snapshot: snapshotRoot });
231
+ if (!fs.existsSync(path.join(pkgDir, "Package.swift"))) fail("NATIVE_HELPERS_INPUT_MISSING", `${helper.name}: no Package.swift in ${pkgDir}`);
225
232
  const entitlements = helper.entitlements ? path.resolve(appRoot, helper.entitlements) : undefined;
226
233
  if (entitlements && !fs.existsSync(entitlements)) fail("NATIVE_HELPERS_INPUT_MISSING", `${helper.name}: entitlements file not found: ${entitlements}`);
227
234
  const archs = forceArch ? [forceArch] : helper.universal ? [...ARCHS] : [hostArch];
228
235
  // Each Swift job is admitted against the checkout that owns its package, including shared RightKit helpers.
229
- const packageRoot = packageOwner(pkgDir) || appRoot;
236
+ // On a snapshot stage the job's directory is the snapshot (the package is inside it, checked above), never a repository the package may sit in.
237
+ const packageRoot = snapshotRoot ?? (packageOwner(pkgDir) || appRoot);
230
238
  const relativePackage = path.relative(packageRoot, pkgDir);
231
239
  const packageArg = relativePackage === "" ? "." : relativePackage;
232
240
  const swiftArgs = ["build", "-c", configuration, "--package-path", packageArg, "--product", helper.product, ...archs.flatMap((arch) => ["--arch", arch])];