@rightkit/release 0.6.8 → 0.6.11

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 (96) hide show
  1. package/README.md +30 -12
  2. package/admission-files.mjs +4 -1
  3. package/admission.mjs +2 -2
  4. package/advisory-exceptions.mjs +41 -5
  5. package/android-play-upload.mjs +16 -15
  6. package/app-check.mjs +105 -27
  7. package/assemble-app-command.mjs +151 -0
  8. package/assembled-app.mjs +445 -0
  9. package/bin/darwin/rightkit-swap +0 -0
  10. package/bin/darwin/rightkit-swap.sign.json +6 -0
  11. package/bin/rightkit-gate.mjs +1608 -345
  12. package/broker-pins.mjs +152 -0
  13. package/build-launch.mjs +88 -0
  14. package/build-ownership.mjs +1231 -0
  15. package/build-release.mjs +82 -28
  16. package/cargo-contract.mjs +3 -1
  17. package/cargo-ledger.mjs +62 -0
  18. package/cargo-target-shim.mjs +238 -0
  19. package/class-a-launch-guard.mjs +126 -0
  20. package/cli/right-release.mjs +31 -5
  21. package/clippy-equivalence.mjs +202 -0
  22. package/clippy-route.mjs +543 -0
  23. package/codesign-shim.mjs +11 -1
  24. package/committed-source.mjs +39 -5
  25. package/crate-workspaces.mjs +87 -0
  26. package/create-mac-updater.mjs +14 -2
  27. package/deterministic-scans.mjs +997 -71
  28. package/development-signing.mjs +4 -1
  29. package/development.mjs +120 -21
  30. package/direct-receipt.mjs +135 -0
  31. package/door-policy.mjs +71 -6
  32. package/door.mjs +6 -6
  33. package/freshness-census.mjs +257 -0
  34. package/gate-executor.mjs +14 -2
  35. package/gate-stages.mjs +91 -15
  36. package/heavy-command.mjs +3 -2
  37. package/hook-retirement.mjs +65 -0
  38. package/init-command.mjs +1 -1
  39. package/install-app.mjs +11 -2
  40. package/install-assembled-app.mjs +423 -0
  41. package/inventory-check.mjs +11 -8
  42. package/ios-dev.mjs +70 -3
  43. package/ios-upload.mjs +20 -15
  44. package/linux-artifacts.mjs +93 -0
  45. package/local-publish.mjs +224 -53
  46. package/local-qualify.mjs +5 -3
  47. package/local-runtime.mjs +5 -4
  48. package/mac-package-recipe.mjs +4 -1
  49. package/managed-cargo.mjs +47 -5
  50. package/managed-root-guard.mjs +34 -0
  51. package/mirror-root-artifact.mjs +18 -5
  52. package/native-helpers.mjs +355 -0
  53. package/ownership-class-rules.mjs +1 -1
  54. package/ownership-evidence.mjs +37 -13
  55. package/ownership-hooks.mjs +20 -59
  56. package/ownership-lockfiles.mjs +14 -1
  57. package/ownership-policy-bundle.mjs +3 -2
  58. package/ownership-staged.mjs +2 -1
  59. package/ownership.mjs +24 -0
  60. package/package-recipe-targets.mjs +84 -10
  61. package/package.json +4 -3
  62. package/pipeline-admission.mjs +4 -4
  63. package/pipeline-receipts.mjs +302 -302
  64. package/preflight.mjs +27 -4
  65. package/project-type.mjs +2 -2
  66. package/provenance-verify.mjs +115 -0
  67. package/publish-cargo.mjs +12 -9
  68. package/qa-admission-policy.json +10 -1
  69. package/recipe-build.mjs +106 -14
  70. package/release-artifact-path.mjs +341 -0
  71. package/release-config.mjs +1 -1
  72. package/release-invocation.mjs +8 -1
  73. package/release-qa-command.mjs +25 -9
  74. package/release-source-brand.mjs +17 -0
  75. package/release-source-identity.mjs +171 -0
  76. package/release.mjs +59 -22
  77. package/rightkit-versions.json +73 -47
  78. package/runtime-budgets.mjs +6 -2
  79. package/scan-digest.mjs +71 -5
  80. package/sign-updater.mjs +6 -3
  81. package/snapshot-broker.mjs +172 -0
  82. package/snapshot-check.mjs +312 -0
  83. package/snapshot-git.mjs +166 -0
  84. package/snapshot-request.mjs +363 -0
  85. package/snapshot-root-config.mjs +76 -0
  86. package/snapshot.mjs +969 -0
  87. package/source-split.mjs +73 -0
  88. package/stage-commands.mjs +60 -0
  89. package/stage-ledger.mjs +204 -0
  90. package/stage-sidecar-command.mjs +5 -1
  91. package/stage-sidecar.mjs +157 -27
  92. package/swap-helper-bundle.mjs +5 -2
  93. package/test-command.mjs +17 -4
  94. package/unit-gate.mjs +301 -0
  95. package/web-build.mjs +40 -6
  96. package/win-package-recipe.mjs +20 -7
package/build-release.mjs CHANGED
@@ -20,13 +20,16 @@ import {
20
20
  import path from "node:path";
21
21
  import { preflightOwnershipScope } from "./ownership-scope.mjs";
22
22
  import { spawn, spawnSync } from "./background-process.mjs";
23
+ import { spawnUnderOwnership } from "./build-ownership.mjs";
23
24
  import { RightKitRegistryError, SNAPSHOT_ENV, SNAPSHOT_FILE, assertManifestRegistryEvidence, loadAdmittedSnapshot, prepareRightKitResolution, registryManifestFields, rightKitLockInventory, writeAdmissionRecord, writeSnapshot } from "./rightkit-registry-resolver.mjs";
24
25
  import { formatAdmission, formatNotRun, runAdmission } from "./admission.mjs";
25
26
  import { sccacheRefusal } from "./admission-host.mjs";
26
27
  import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
27
28
  import { fileURLToPath } from "node:url";
28
- import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
29
+ import { dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
30
+ import { assertInsideSource, assertReleaseSource, gitRootOf, isSnapshotSource, qaAdmissionSourceOptions, resolveReleaseSource, sourceRootOf } from "./release-source-identity.mjs";
29
31
  import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
32
+ import { ArtifactPathError, assertSplitCommandPaths, boundTargetsEvidence, releaseCommandSteps, resolveArtifactPath as resolveSplitArtifactPath, splitArtifactContext } from "./release-artifact-path.mjs";
30
33
  import { admitSourceQa } from "./release-qa-audit.mjs";
31
34
  import { devtoolsEvidence, resolveDevtoolsAllowance } from "./devtools-policy.mjs";
32
35
  import { commandOutputPortable, releaseEnvironment, resolveReleaseLayout, runBuildStateMachine, verifySealedRelease, watchProgress } from "./release-state.mjs";
@@ -43,7 +46,6 @@ import { collectSwiftToolVersions, planSwiftBuild } from "./swift-build-context.
43
46
  import { planIosNativeBuild, resolveIosBuildContext } from "./ios-build-context.mjs";
44
47
  import { packageCommandOptional, requiredBuildExecutables, resolveProjectType, targetUsesMsix } from "./project-type.mjs";
45
48
  import { buildPathPrefix, collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
46
- import { assertCleanSource } from "./source-gate.mjs";
47
49
  import { acquireHeavyWorkSlot, heavyCommandEnvironment, summarizePreBrokerWaits, terminateProcessTree } from "./heavy-command.mjs";
48
50
  import { processGroupCpuSeconds } from "./process-liveness.mjs";
49
51
  import { cancelAndReapManagedRequest, firstManagedRequestId, releaseProgressLimits } from "./progress-control.mjs";
@@ -79,7 +81,7 @@ const ANDROID_PIPELINE_FINGERPRINT = createHash("sha256")
79
81
  .slice(0, 16);
80
82
 
81
83
  /** Assemble preflight inputs from the app's own files (mirrors release.mjs). */
82
- function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativeLayout, target, pinnedCargoLocks = [] }) {
84
+ function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativeLayout, target, pinnedCargoLocks = [], releaseSource = null, sourceRoot = repoRoot, artifactPaths = null }) {
83
85
  let version;
84
86
  try {
85
87
  version = JSON.parse(readFileSync(path.join(appRoot, "package.json"), "utf8")).version;
@@ -87,12 +89,15 @@ function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativ
87
89
  version = undefined;
88
90
  }
89
91
  // An iOS target is built from its Xcode project and local packages only; the ownership scan covers just those roots.
90
- const ownershipScope = preflightOwnershipScope({ repoRoot, appRoot, platform, target, nativeAssembly: config?.nativeAssembly });
92
+ // In a split the scope is the snapshot's (the commit's layout); the preflight reads the checkout for git, the sealed vault and the evidence.
93
+ const ownershipScope = preflightOwnershipScope({ repoRoot: sourceRoot, appRoot, platform, target, nativeAssembly: config?.nativeAssembly });
91
94
  return collectPreflight({
92
95
  platform,
93
96
  ownershipScope,
94
97
  appRoot,
95
98
  repoRoot,
99
+ releaseSource,
100
+ splitTargets: artifactPaths,
96
101
  app: config.app,
97
102
  configPath,
98
103
  // The version check is the doctor's job: by the time `build` runs, seal
@@ -139,10 +144,24 @@ if (resumeFrom && !["prepackage", "sign", "package", "post-sign", "harden"].incl
139
144
  const nativeReal = (file) => { try { return realpathSync.native(file); } catch { return file; } };
140
145
  const configPath = nativeReal(path.resolve(configName));
141
146
  const invocationRoot = path.dirname(configPath);
142
- const repoRoot = git(invocationRoot, ["rev-parse", "--show-toplevel"]);
143
- assertPrimaryReleaseCheckout(repoRoot);
144
- const relativeConfig = path.relative(nativeReal(repoRoot), nativeReal(configPath)).replaceAll("\\", "/");
145
- if (relativeConfig.startsWith("../")) fail("release config must live inside the Git repository");
147
+ // Source identity (release-source-identity.mjs, item 7 row 7.04, decision 2 Q2). Without RK_GATE_CHECKOUT the source is the checkout's HEAD,
148
+ // proven exactly as before (primary worktree, branch-attached HEAD, clean status). Inside a door run it is the SELECTED COMMIT's objects
149
+ // plus the gate's verified snapshot: the checkout may be dirty, ahead or detached, and no HEAD, status or branch of it is read.
150
+ // A split that cannot be proven is refused (never a fallback to the checkout checks).
151
+ let releaseSource;
152
+ try { releaseSource = resolveReleaseSource({ cwd: process.env.RK_GATE_CHECKOUT ? process.cwd() : invocationRoot }); }
153
+ catch (error) { fail(error.code === "RK_SOURCE_NO_REPOSITORY" ? `git rev-parse --show-toplevel failed: ${error.message}` : `${error.code ?? "RK_SOURCE_IDENTITY"}: ${error.message}`); }
154
+ const splitSource = isSnapshotSource(releaseSource);
155
+ // The checkout: every object read and every output (`.right-release`: vault, state, locks, sealed) belongs to it (GC:31, GC:65). The
156
+ // source root is where the config, the build inputs and the QA plan are read: the snapshot in a split, else the checkout itself.
157
+ const repoRoot = gitRootOf(releaseSource);
158
+ const sourceRoot = sourceRootOf(releaseSource);
159
+ try { assertReleaseSource(releaseSource, { commandId: "right-release build", skipCleanCheck: true }); } catch (error) { fail(error.message); }
160
+ try { assertInsideSource(releaseSource, configPath, "the release config"); } catch (error) { fail(error.message); }
161
+ const relativeConfig = path.relative(nativeReal(sourceRoot), nativeReal(configPath)).replaceAll("\\", "/");
162
+ if (relativeConfig.startsWith("../")) fail(splitSource ? "release config must live inside the snapshot" : "release config must live inside the Git repository");
163
+ // A split reads no dirty-checkout condition: the snapshot is the commit's bytes, proven by the door at the start and end of the stage.
164
+ const sourceProblems = (inputs, commitId) => (splitSource ? [] : dirtyBuildInputs(repoRoot, inputs, commitId));
146
165
  const appRoot = path.dirname(configPath);
147
166
  const layout = resolveReleaseLayout({ repoRoot, configPath });
148
167
  const vaultRoot = layout.vaultRoot;
@@ -181,20 +200,17 @@ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
181
200
 
182
201
  try {
183
202
  throwIfInterrupted();
184
- const commit = git(repoRoot, ["rev-parse", "HEAD"]);
203
+ const commit = releaseSource.commit;
185
204
  // A phase-resume never reads unrelated source. Its later build-input gate
186
205
  // remains strict, but requiring a globally clean mono-workspace here would
187
206
  // turn an already-built, signed installer into a needless full rebuild.
188
- if (!resumeFrom) {
189
- assertCleanSource({
190
- status: git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { trim: false }), // untrimmed: a leading space is the first record's X column (#782)
191
- commandId: "right-release build",
192
- });
193
- }
207
+ // The checkout route runs the clean-source gate here (R1, byte for byte as before); a split re-proves its door run instead and reads
208
+ // no status (R2: a split that is no longer proven refuses, it never falls back).
209
+ try { assertReleaseSource(releaseSource, { commandId: "right-release build", skipPrimaryCheckout: true, skipCleanCheck: Boolean(resumeFrom) }); } catch (error) { fail(error.message); }
194
210
  mkdirSync(path.join(vaultRoot, "locks"), { recursive: true });
195
211
  lock = acquireLock(path.join(vaultRoot, "locks", `${platform}.lock.json`));
196
212
  const shortCommit = commit.slice(0, 8);
197
- const dirtyConfig = dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit);
213
+ const dirtyConfig = sourceProblems({ include: [relativeConfig], required: [relativeConfig] }, commit);
198
214
  if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
199
215
  const config = await importReleaseConfig(configPath, { query: `commit=${commit}` });
200
216
  if (!config?.app || !config?.version) fail("release config must expose app and version");
@@ -223,11 +239,25 @@ try {
223
239
  : null;
224
240
  const nativeIos = iosContext?.nativeIos ?? false;
225
241
  const nativeLayout = iosContext ? iosContext.nativeLayout : resolveNativeCargoLayout({ appRoot, config });
242
+ // Row 7.04c. In a door split the snapshot has no target directory: (R-T4) no step the app owns may name a target-relative path, refused here, before
243
+ // anything builds; (R-T1) each Cargo manifest's target is bound once, now, through the published resolver. Checkout mode does neither.
244
+ let artifactPaths = null;
245
+ if (splitSource) {
246
+ try {
247
+ assertSplitCommandPaths(releaseCommandSteps({ target, config, root: appRoot }), splitArtifactContext({ cwd: appRoot, env: process.env, nativeLayout, config, target, snapshotRoot: sourceRoot, resolve: false }));
248
+ artifactPaths = splitArtifactContext({
249
+ cwd: appRoot, nativeLayout, config, target, snapshotRoot: sourceRoot,
250
+ env: { ...process.env, RIGHT_RELEASE_ARTIFACT_DIR: resolveArtifactDir({ app: config.app, version: config.version, managedRoot: resolveManagedRoot({ env: process.env }) }) },
251
+ });
252
+ } catch (error) { if (error instanceof ArtifactPathError) fail(error.message); throw error; }
253
+ }
226
254
  if (platform === "win" && target.signingContract === "windows-raw-exe-authenticode-before-nsis-v1") {
227
255
  assertNsisInPlaceUpgradeContract(appRoot, target.nsisUpgradeContract);
228
256
  }
229
- const { requiredInputs, buildInputs, cargoLockPaths: pinnedCargoLocks } = resolveReleaseBuildInputs({ repoRoot, appRoot, configPath, config, target });
230
- const dirtyInputs = dirtyBuildInputs(repoRoot, buildInputs, commit);
257
+ const { requiredInputs, buildInputs, cargoLockPaths: pinnedCargoLocks } = resolveReleaseBuildInputs({ repoRoot: sourceRoot, appRoot, configPath, config, target });
258
+ // R5: in a split every input resolves inside the snapshot (`hashInputs` below hashes the snapshot's files); the dirty-input gate is the checkout route's.
259
+ for (const file of requiredInputs) try { assertInsideSource(releaseSource, file, "a release build input"); } catch (error) { fail(error.message); }
260
+ const dirtyInputs = sourceProblems(buildInputs, commit);
231
261
  if (dirtyInputs.length > 0) fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
232
262
  const hasPackageJson = existsSync(path.join(appRoot, "package.json"));
233
263
  // iOS records the Xcode toolchain (and xcodegen when a project.yml is named) beside the Node and Rust tools.
@@ -315,7 +345,7 @@ try {
315
345
  // Preflight BEFORE any compile. These preconditions used to be discovered one
316
346
  // per build cycle, minutes deep, with errors that named a symptom instead of a
317
347
  // cause. Blocking problems stop here, in seconds, naming the fix.
318
- const preflight = buildPreflight({ config, configPath, appRoot, repoRoot: layout.repoRoot, platform, nativeLayout, target, pinnedCargoLocks });
348
+ const preflight = buildPreflight({ config, configPath, appRoot, repoRoot: layout.repoRoot, platform, nativeLayout, target, pinnedCargoLocks, releaseSource, sourceRoot, artifactPaths });
319
349
  const preflightBlockers = preflightFailures(preflight);
320
350
  if (preflightBlockers.length > 0) {
321
351
  fail(`preflight found ${preflightBlockers.length} blocking problem(s):\n${formatPreflight(preflightBlockers)}`);
@@ -330,7 +360,8 @@ try {
330
360
  }
331
361
  // Hardening step 4: source QA is recorded, not enforced. A missing or rejected receipt warns loudly and the outcome
332
362
  // is sealed into the manifest (`qa.status`) for `right-release qa audit`. Dev lanes (install-app --dev) never reach this file.
333
- const qaAdmission = admitSourceQa({ repoRoot: layout.repoRoot, releaseId, platform, configName: path.relative(process.cwd(), configPath) || path.basename(configPath), releaseConfigSha256: hashFile(configPath) });
363
+ // A split compares the source QA receipt with the SELECTED commit (its tree, repository identity and lockfile closure from its objects), not HEAD.
364
+ const qaAdmission = admitSourceQa({ repoRoot: layout.repoRoot, releaseId, platform, configName: path.relative(process.cwd(), configPath) || path.basename(configPath), releaseConfigSha256: hashFile(configPath), ...qaAdmissionSourceOptions(releaseSource) });
334
365
 
335
366
  // A tool being INSTALLED is not the same as it being the one PATH picks. The
336
367
  // vendored-OpenSSL build shells out to `perl`, and under Git Bash/MSYS that
@@ -342,6 +373,7 @@ try {
342
373
  ...releaseEnvironment({ root: vaultRoot, cacheRoot: sharedCacheRoot, platform, architecture: cacheIdentity.architecture, app: config.app, cacheKey, kind: "release", appRoot, mode: cacheMode }),
343
374
  ...(pathPrefix ? { PATH: `${pathPrefix}${path.delimiter}${process.env.PATH ?? ""}` } : {}),
344
375
  RIGHT_RELEASE_REPO_ROOT: layout.repoRoot,
376
+ ...(splitSource ? { RIGHT_RELEASE_SOURCE_ROOT: sourceRoot } : {}),
345
377
  RIGHT_RELEASE_APP_ROOT: layout.appRoot,
346
378
  RIGHTKIT_MANAGED_ROOT: resolveManagedRoot({ env: process.env }),
347
379
  RIGHT_RELEASE_ARTIFACT_DIR: resolveArtifactDir({ app: config.app, version: config.version, managedRoot: resolveManagedRoot({ env: process.env }) }),
@@ -352,6 +384,9 @@ try {
352
384
  delete env[name];
353
385
  }
354
386
  }
387
+ // A split's Cargo target is the one the gate bound and handed this process (row 7.09, R-T1): Cache V2's shared target does not replace it, or
388
+ // the bound target the seal reads would not be the one Cargo wrote.
389
+ if (splitSource && !managedCargoTarget && typeof process.env.CARGO_TARGET_DIR === "string" && path.isAbsolute(process.env.CARGO_TARGET_DIR)) env.CARGO_TARGET_DIR = process.env.CARGO_TARGET_DIR;
355
390
  if (cacheMode !== "shared") delete env.RIGHT_RELEASE_CACHE_OWNER;
356
391
  if (cacheMode === "shared" && env.RIGHT_RELEASE_CACHE_OWNER !== "rightkit-v2") fail("shared cache ownership token was not configured");
357
392
  if (cacheMode !== "shared" && !commandExists("sccache")) delete env.RUSTC_WRAPPER;
@@ -494,6 +529,9 @@ try {
494
529
  // cost ~5.9 GB of warm broker intermediates. The bridge's brokerManaged
495
530
  // flag already knows to leave a real directory untouched instead.
496
531
  if (swiftPlan) { /* SwiftPM: no Rust target link */ }
532
+ // A split writes no link into the snapshot (a link is never a ledger entry, decision 2 Q1 rule 2): Cargo writes the cache target
533
+ // directly (CARGO_TARGET_DIR) and the seal resolves `src-tauri/target/...` paths against it (sealRelease managedCargoTarget).
534
+ else if (splitSource) { /* no target link in the snapshot */ }
497
535
  else if (cacheMode === "shared" || managedCargoTarget) targetBridge.ensure();
498
536
  else {
499
537
  const linked = ensureLegacyTargetLink({ targetLink, cacheTarget, fs: { existsSync, realpathSync, renameSync, symlinkSync } });
@@ -537,7 +575,7 @@ try {
537
575
  });
538
576
  },
539
577
  seal: async ({ sealedDir }) => {
540
- sealRelease({ configRoot: appRoot, managedCargoTarget, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot, rightkitRegistryFields: rightKitResolution ? registryManifestFields(rightKitResolution.snapshot, RELEASE_TOOL_VERSION) : undefined });
578
+ sealRelease({ configRoot: appRoot, managedCargoTarget, artifactPaths, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot, rightkitRegistryFields: rightKitResolution ? registryManifestFields(rightKitResolution.snapshot, RELEASE_TOOL_VERSION) : undefined, splitSealSource: splitSource ? { commit: releaseSource.commit, tree: releaseSource.tree, snapshotAppRoot: appRoot, checkoutAppRoot: releaseSource.checkoutAppRoot } : null });
541
579
  verifySealedRelease(sealedDir);
542
580
  if (cacheMode === "shared") markCacheEntrySuccessful({ layout: sharedLayout });
543
581
  checkpoint(stateRoot, "sealed");
@@ -564,8 +602,8 @@ async function admit() {
564
602
  let commit;
565
603
  let config;
566
604
  try {
567
- commit = git(invocationRoot, ["rev-parse", "HEAD"]);
568
- const dirtyConfig = dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit);
605
+ commit = releaseSource.commit;
606
+ const dirtyConfig = sourceProblems({ include: [relativeConfig], required: [relativeConfig] }, commit);
569
607
  if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
570
608
  try { config = await importReleaseConfig(configPath, { query: `commit=${commit}` }); } catch (error) { fail(`release config could not be loaded (${configPath}): ${error instanceof Error ? error.message : String(error)}`); }
571
609
  } catch (error) {
@@ -578,7 +616,7 @@ async function admit() {
578
616
  && existsSync(path.join(repoRoot, ".right-release", "sealed", `${config.app}-${config.version}-${commit.slice(0, 8)}`, platformName, "release-manifest.json"));
579
617
  const reuse = Boolean(resumeFrom) || Boolean(sealed);
580
618
  const result = await runAdmission({
581
- root: appRoot, repoRoot, configPath, config: config ?? {}, platform, commit, skipChecks,
619
+ root: appRoot, repoRoot: sourceRoot, releaseSource, configPath, config: config ?? {}, platform, commit, skipChecks,
582
620
  tiers: reuse ? ["files", "host"] : ["files", "host", "network"],
583
621
  registry: async (ctx) => {
584
622
  const inputs = ctx.buildInputs();
@@ -630,7 +668,7 @@ function sqlCipherFeatures(text) {
630
668
  .filter((value) => /sqlcipher|openssl/i.test(value));
631
669
  }
632
670
 
633
- function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot, rightkitRegistryFields }) {
671
+ function sealRelease({ configRoot, managedCargoTarget, artifactPaths = null, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot, rightkitRegistryFields, splitSealSource = null }) {
634
672
  // Every app's release config names installer/updater artifacts
635
673
  // relative to its resolved native target. On a broker-managed host that
636
674
  // directory is never populated (the broker owns CARGO_TARGET_DIR and
@@ -641,7 +679,11 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, n
641
679
  const targetPrefix = "src-tauri/target/";
642
680
  // A Cargo-less iOS app has no native layout and no managed cargo target: only the default prefix is ever compared.
643
681
  const effectiveTargetPrefix = nativeLayout?.targetPrefix ?? targetPrefix;
644
- const resolveArtifactPath = (file) => {
682
+ const resolveArtifactPath = artifactPaths?.mode === "split" ? (file) => {
683
+ // Row 7.04c (R-T2): a split's declared artifact is read in place in the bound target (or the snapshot, or the managed artifact directory),
684
+ // through the one resolver; the prefix remap below is the checkout route's.
685
+ try { return resolveSplitArtifactPath(file, artifactPaths); } catch (error) { if (error instanceof ArtifactPathError) fail(error.message); throw error; }
686
+ } : (file) => {
645
687
  const normalized = file.replaceAll(path.win32.sep, "/");
646
688
  if (managedCargoTarget && normalized.startsWith(targetPrefix)) {
647
689
  return path.join(managedCargoTarget, normalized.slice(targetPrefix.length));
@@ -792,6 +834,10 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, n
792
834
  app: config.app,
793
835
  version: config.version,
794
836
  commit,
837
+ // A split seal names the selected commit AND its tree and says its bytes came from the door's snapshot (decision 2 Q2 item 5).
838
+ ...(splitSealSource ? { tree: splitSealSource.tree, source: "snapshot" } : {}),
839
+ // The Cargo targets this split bound (R-T1); the door cross-checks them against its own resolution (RK_SPLIT_TARGET_DRIFT).
840
+ ...(artifactPaths?.mode === "split" ? { boundTargets: boundTargetsEvidence(artifactPaths) } : {}),
795
841
  ownershipEvidence,
796
842
  // The exact registry resolution (identities, digests, registries, policy revision, observation time, observed latest)
797
843
  // this release was built against. Upload and resume consume it; neither resolves again.
@@ -813,7 +859,15 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, n
813
859
  if (present.length !== signingIdentity.receiptInputs.length) fail("missing current-build macOS signing or notarization receipt");
814
860
  validateNotarizationReceipt({ receiptPath: signingIdentity.receiptInputs[1], archive: resolveArtifactPath(target.notarize.file) });
815
861
  }
816
- return present.map((file) => ({ file, sha256: hashFile(file) }));
862
+ // A split records each receipt relative to a base the checkout reproduces (its app root), never the snapshot's: the snapshot's bytes are
863
+ // replaced at the next sync. A receipt written INSIDE the snapshot cannot be re-read later, so it is refused (configure sign.receipt under the state root).
864
+ const recorded = (file) => {
865
+ if (!splitSealSource) return file;
866
+ const rel = (base) => path.relative(base, file).replaceAll("\\", "/");
867
+ if (!rel(splitSealSource.snapshotAppRoot).startsWith("../") && rel(splitSealSource.snapshotAppRoot) !== "..") fail(`signing receipt ${file} is inside the snapshot, which the next sync replaces; write it under the state root (${stateRoot}) so the seal can name it`);
868
+ return rel(splitSealSource.checkoutAppRoot);
869
+ };
870
+ return present.map((file) => ({ file: recorded(file), sha256: hashFile(file) }));
817
871
  })() } : null,
818
872
  files,
819
873
  routes,
@@ -875,7 +929,7 @@ async function runProgress(cmd, runArgs, cwd, env, watchDir, diagnosticPath = nu
875
929
  const settleResolve = () => { if (!settled) { settled = true; resolve(); } };
876
930
  const settleReject = (error) => { if (!settled) { settled = true; reject(error); } };
877
931
  const useShell = process.platform === "win32" && !/\.(?:exe|com)$/i.test(cmd);
878
- child = spawn(cmd, runArgs, { cwd, env, stdio: ["inherit", "pipe", "pipe"], windowsHide: true, shell: useShell, detached: process.platform !== "win32" });
932
+ child = spawnUnderOwnership(spawn, cmd, runArgs, { cwd, env, stdio: ["inherit", "pipe", "pipe"], windowsHide: true, shell: useShell, detached: process.platform !== "win32" });
879
933
  try {
880
934
  if (child.pid && heavySlot) heavySlot.trackChild(child.pid, cmd);
881
935
  } catch (error) {
@@ -2,7 +2,7 @@ import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "./background-process.mjs";
5
- import { managedRuntimeClient, runCargoSync } from "./managed-cargo.mjs";
5
+ import { directPlatform, managedRuntimeClient, runCargoSync } from "./managed-cargo.mjs";
6
6
 
7
7
  const SKIP_DIRECTORIES = new Set([".audit", ".cache", ".git", ".claude", ".right-release", ".worktrees", "node_modules", "target", "vendor"]);
8
8
  const CRATES_IO_SOURCES = new Set([
@@ -12,6 +12,7 @@ const CRATES_IO_SOURCES = new Set([
12
12
  const CACHE_V2_ENVIRONMENT = ["CARGO_HOME", "CARGO_TARGET_DIR", "RUSTC_WRAPPER", "SCCACHE_DIR", "SCCACHE_BASEDIRS", "RIGHT_RELEASE_CACHE_OWNER"];
13
13
 
14
14
  export function managedCargoShimOnPath(env = process.env, platform = process.platform, exists = existsSync) {
15
+ if (directPlatform(platform)) return false; // Linux never uses a RustRight shim (managed-cargo directPlatform)
15
16
  const pathApi = platform === "win32" ? path.win32 : path.posix;
16
17
  const delimiter = platform === "win32" ? ";" : ":";
17
18
  const extensions = platform === "win32"
@@ -29,6 +30,7 @@ export function managedCargoShimOnPath(env = process.env, platform = process.pla
29
30
  }
30
31
 
31
32
  export function brokerManagedHost(env = process.env, platform = process.platform, exists = existsSync) {
33
+ if (directPlatform(platform)) return false; // Linux is never broker-managed, whatever socket or control root the env names
32
34
  return Boolean(managedRuntimeClient(env, platform, exists)) || Boolean(env.RIGHTKIT_BUILD_BROKER_SOCKET) || managedCargoShimOnPath(env, platform, exists);
33
35
  }
34
36
 
@@ -0,0 +1,62 @@
1
+ // The gate-owned ledger of every call the runtime cargo wrapper decides (item 7 rows 7.10 and 7.24, Sage decision C).
2
+ //
3
+ // The wrapper (cargo-target-shim.mjs) appends one record BEFORE it acts (`pending`) and one after (`ran`, `verified`, `unverified` or
4
+ // `refused`), to `<ledger dir>/<scan token>.jsonl`. The gate (runScans) reads the file of each scan after it ended and fails the scan
5
+ // on anything but a completed, successful call: a refused call fails its unit even when the test swallowed cargo's exit status, and a
6
+ // call that never completed (the wrapper was killed, or the record was never finished) is unverified, never skipped. Records the gate
7
+ // cannot attribute to a scan (the scan token was cleared) fail the run as a row of their own.
8
+ import crypto from "node:crypto";
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+
12
+ export const LEDGER_SCHEMA = "rightkit.cargo-ledger/1";
13
+ export const SCAN_TOKEN_ENV = "RIGHTKIT_CARGO_SCAN";
14
+ export const UNSCOPED = "unscoped";
15
+ /** Statuses that are a completed success: a direct call that ran, a broker call whose signed receipt verified. */
16
+ export const OK_STATUSES = Object.freeze(["ran", "verified"]);
17
+
18
+ /** The token a scan's calls are filed under: stable per scan id, never the id itself. */
19
+ export const scanTokenOf = (scanId) => crypto.createHash("sha256").update(String(scanId)).digest("hex").slice(0, 16);
20
+
21
+ const tokenFile = (dir, token) => path.join(dir, `${token}.jsonl`);
22
+ const validToken = (token) => (typeof token === "string" && /^[0-9a-f]{16}$/.test(token) ? token : UNSCOPED);
23
+
24
+ /** Appends one record. Throws when it cannot be written: a call that cannot be recorded does not run. */
25
+ export function appendLedger(dir, token, entry) {
26
+ if (!dir || !path.isAbsolute(dir)) throw new Error("the cargo ledger directory is not set");
27
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
28
+ const fd = fs.openSync(tokenFile(dir, validToken(token)), fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW ?? 0), 0o600);
29
+ try { fs.writeSync(fd, `${JSON.stringify({ schema: LEDGER_SCHEMA, at: new Date().toISOString(), ...entry })}\n`); } finally { fs.closeSync(fd); }
30
+ }
31
+
32
+ /**
33
+ * The calls filed under `token`, one per call id (a later record replaces an earlier one), and the problems found: an unreadable
34
+ * line, and every call that is not a completed success.
35
+ */
36
+ export function readLedger(dir, token) {
37
+ const file = tokenFile(dir, validToken(token));
38
+ let text = "";
39
+ try { text = fs.readFileSync(file, "utf8"); } catch (e) { if (e.code === "ENOENT") return { entries: [], problems: [] }; throw e; }
40
+ const byId = new Map(); const problems = [];
41
+ for (const line of text.split("\n")) {
42
+ if (!line.trim()) continue;
43
+ let entry;
44
+ try { entry = JSON.parse(line); } catch { problems.push(`an unreadable ledger record in ${path.basename(file)}`); continue; }
45
+ if (entry?.schema !== LEDGER_SCHEMA || typeof entry.id !== "string") { problems.push(`a malformed ledger record in ${path.basename(file)}`); continue; }
46
+ byId.set(entry.id, { ...(byId.get(entry.id) ?? {}), ...entry });
47
+ }
48
+ const entries = [...byId.values()];
49
+ for (const e of entries) {
50
+ if (OK_STATUSES.includes(e.status)) continue;
51
+ const call = `cargo ${(e.argv ?? []).join(" ")}`.trim();
52
+ problems.push(e.status === "pending" ? `${call} (from ${e.cwd}) never completed: the wrapper recorded it and no outcome, so it is unverified` : `${call} (from ${e.cwd}) was ${e.status}${e.code ? ` (${e.code})` : ""}${e.message ? `: ${e.message}` : ""}`);
53
+ }
54
+ return { entries, problems };
55
+ }
56
+
57
+ /** Ledger files nobody claimed: the scan token was cleared or invented by the code under test. */
58
+ export function unclaimedLedgers(dir, claimedTokens) {
59
+ let names = [];
60
+ try { names = fs.readdirSync(dir); } catch (e) { if (e.code === "ENOENT") return []; throw e; }
61
+ return names.filter((n) => n.endsWith(".jsonl")).map((n) => n.slice(0, -".jsonl".length)).filter((t) => !claimedTokens.has(t));
62
+ }
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env node
2
+ // The runtime cargo wrapper of a commit-snapshot compile (item 7, GATE-CONTRACT §1 "Broker interface", output directory). Two modes:
3
+ // direct the DIRECT POSIX (Linux) host: each call runs the real cargo bound to its workspace's checkout target directory.
4
+ // broker the Mac (decision C, rows 7.10 and 7.24): each call goes through the RustRight client as its own snapshot request with a
5
+ // fresh carrier (snapshot-broker.mjs runBrokerCargo); no cargo ever runs outside the broker, and its signed receipt must verify.
6
+ // Both modes append every call to the gate-owned ledger (cargo-ledger.mjs) before and after it acts, and the gate fails the scan on any
7
+ // refused, unverified or unfinished call even when the test ignored the exit status. On a Windows host there is no wrapper: a child
8
+ // started without a shell finds only an .exe, so the static walk (deterministic-scans.mjs cargoReachOf) is the fail-closed baseline
9
+ // there (a refused unit is FAIL or BLOCKED with a code, never skipped).
10
+ //
11
+ // The gate puts this `cargo` first on PATH and names it in CARGO for every scan. A test, script or build tool that runs `cargo` or
12
+ // $CARGO comes back here, with one accepted exception (lead re-decision F7, row 7.10). MEASURED, cargo 1.98.0 on WSL2
13
+ // (/Volumes/D/scratch/rk-l7b/wsl-debug1.sh, wsl-debug3.sh): a real cargo hands a build script its OWN path in CARGO, not the file
14
+ // the CARGO variable names, and `--config env.CARGO` does not change that. A cargo a build script starts through $CARGO therefore
15
+ // bypasses this wrapper. It is accepted as follows: it inherits the parent's bound CARGO_TARGET_DIR (the same as a primary-checkout
16
+ // build), so its outputs land in the bound checkout target; the per-scan sentinel and the after-compile snapshot verify stay as
17
+ // backstops; and it is UNOBSERVED: never in the ledger, and every scan result of a wrapped compile carries a note that says so
18
+ // (deterministic-scans.mjs UNOBSERVED_NESTED_CARGO_NOTE). Each invocation resolves its Cargo workspace
19
+ // (`cargo locate-project --workspace`, from its working directory or --manifest-path) and runs with that workspace's ORIGINAL checkout
20
+ // target directory as CARGO_TARGET_DIR. Refused with an RK_* code (the unit fails): a workspace the compile did not resolve (outside
21
+ // the snapshot, a fixture, a temporary project), an option that moves the workspace, configuration or output, a command that writes
22
+ // no bound output or rewrites the commit (clean, install, publish, update, an alias or plugin), a command that reads a workspace
23
+ // outside the bound ones or may write its Cargo.lock (tree, fetch, pkgid... without --locked/--frozen; fmt only as --check). A cargo
24
+ // that bypasses PATH and CARGO (an absolute path, `rustup run`, a reset PATH) inherits the scan-wide CARGO_TARGET_DIR: the one
25
+ // workspace's checkout target, or with several workspaces a per-scan sentinel directory that must stay empty (runScans fails the
26
+ // scan with RK_CARGO_UNBOUND otherwise). In broker mode the sentinel is always set: there is no checkout target to inherit, so any
27
+ // write into it is a cargo that skipped the wrapper. An absolute cargo that also passes its own absolute target directory has
28
+ // overridden the value the gate passed: a residual of the trusted per-user model, not refused (map 7.12).
29
+ import fs from "node:fs";
30
+ import path from "node:path";
31
+ import { createHash } from "node:crypto";
32
+ import { spawnSync } from "./background-process.mjs";
33
+ import { fileURLToPath } from "node:url";
34
+ import { SCAN_TOKEN_ENV, appendLedger } from "./cargo-ledger.mjs";
35
+ import { BROKER_REFUSAL_STATUS, runBrokerCargo } from "./snapshot-broker.mjs";
36
+
37
+ export const SHIM_MAP_SCHEMA = "rightkit.cargo-target-shim/3";
38
+ // Exit status of a refusal: cargo's own error status, so every caller reads it as a failed cargo.
39
+ export const SHIM_REFUSAL_STATUS = BROKER_REFUSAL_STATUS;
40
+ // Cargo commands that compile or report on a workspace: run bound to its checkout target directory.
41
+ const COMPILE = new Set(["build", "b", "check", "c", "test", "t", "bench", "run", "r", "doc", "d", "rustc", "rustdoc", "clippy", "metadata", "package"]);
42
+ // Commands that only read a workspace but may resolve and write its Cargo.lock: bound, and only with --locked or --frozen.
43
+ const LOCKED = new Set(["fetch", "tree", "pkgid", "verify-project", "read-manifest", "locate-project", "deny"]);
44
+ // Commands with no lock flag (cargo fmt, cargo-audit): bound, and only where the workspace's committed Cargo.lock already exists.
45
+ const LOCKFILE = new Set(["fmt", "audit"]);
46
+ // Plugins whose options are their own, not cargo's (cargo-deny's --config is its config file, not a cargo redirect).
47
+ const PLUGIN = new Set(["deny", "audit"]);
48
+ const BOUND = new Set([...COMPILE, ...LOCKED, ...LOCKFILE]);
49
+ // Commands that read no workspace at all: run unchanged.
50
+ const PASSTHROUGH = new Set(["version", "help"]);
51
+ // Global options that take a value (`--color always`, `--explain E0001`); the subcommand is the first word after them.
52
+ const GLOBAL_VALUE = new Set(["--color", "--explain"]);
53
+ // Options that move the working directory, the configuration or the output (a short cluster holding C or Z too).
54
+ const REDIRECT = /^(--config|-Z|-C)(=|$)|^-[A-Za-z]*[CZ]/;
55
+
56
+ const refusal = (code, message) => ({ refuse: { code, message } });
57
+
58
+ /**
59
+ * What one cargo invocation may do: `{ exec: { args, env, workspace, targetDir } }` (bound), `{ exec: { args, env } }` (a command
60
+ * that writes nothing), or `{ refuse: { code, message } }`. `locate(manifestPath | null, cwd, toolchain | null)` returns the absolute
61
+ * path of the workspace root's Cargo.toml or throws; `realpath` resolves a directory. `map.shim` (the wrapper's own path) is set as
62
+ * CARGO for the real cargo, so its build scripts and tests reach the wrapper again.
63
+ */
64
+ export function decideCargoInvocation({ args, cwd, env, map, locate, realpath = (p) => fs.realpathSync.native(p), exists = fs.existsSync }) {
65
+ const words = [...args];
66
+ const toolchain = words[0]?.startsWith("+") ? words[0] : null;
67
+ const withCargo = (e) => (map.shim ? { ...e, CARGO: map.shim } : e);
68
+ let i = toolchain ? 1 : 0;
69
+ let sub = null;
70
+ for (; i < words.length; i += 1) {
71
+ const w = words[i];
72
+ if (w === "--") break;
73
+ if (REDIRECT.test(w)) return refusal("RK_CARGO_REDIRECT", `\`cargo ${words.join(" ")}\` passes ${w}, which moves its working directory, configuration or output; a snapshot compile's outputs must be the checkout's`);
74
+ if (GLOBAL_VALUE.has(w)) { i += 1; continue; }
75
+ if (w.startsWith("-")) continue;
76
+ sub = w;
77
+ break;
78
+ }
79
+ const end = sub === null ? -1 : words.indexOf("--", i + 1);
80
+ const own = sub === null ? [] : words.slice(i + 1, end < 0 ? undefined : end);
81
+ const globals = words.slice(toolchain ? 1 : 0, sub === null ? undefined : i);
82
+ if (sub === "fmt" && !own.includes("--check")) return refusal("RK_CARGO_UNBOUND_COMMAND", "`cargo fmt` without --check rewrites tracked files; a snapshot compile changes no file of the commit");
83
+ if (sub === null || PASSTHROUGH.has(sub)) return { exec: { args: words, env: withCargo({ ...env }) } };
84
+ if (!BOUND.has(sub)) return refusal("RK_CARGO_UNBOUND_COMMAND", `\`cargo ${sub}\` is not a command a snapshot compile binds (${[...BOUND].join(", ")}; or ${[...PASSTHROUGH].join(", ")}, which read no workspace): an alias, plugin, clean, install, publish or lock rewrite is refused`);
85
+ if (LOCKED.has(sub) && ![...globals, ...own].some((w) => w === "--locked" || w === "--frozen")) return refusal("RK_CARGO_UNBOUND_COMMAND", `\`cargo ${sub}\` without --locked or --frozen may write a Cargo.lock; a snapshot compile changes no file of the commit and writes nothing outside its bound outputs`);
86
+ if (!PLUGIN.has(sub)) for (const w of own) if (REDIRECT.test(w)) return refusal("RK_CARGO_REDIRECT", `\`cargo ${sub}\` passes ${w}, which moves its working directory, configuration or output; a snapshot compile's outputs must be the checkout's`);
87
+ const valueOf = (name) => {
88
+ const at = own.findIndex((w) => w === name || w.startsWith(`${name}=`));
89
+ if (at < 0) return undefined;
90
+ return own[at].includes("=") ? own[at].slice(name.length + 1) : (own[at + 1] ?? "");
91
+ };
92
+ const manifestPath = valueOf("--manifest-path");
93
+ const broker = map.mode === "broker";
94
+ // The RustRight client submits the call from the working directory and refuses to rewrite it (attestation), and the broker pins the
95
+ // output: a manifest path or a target directory of the caller's has no place in a broker call.
96
+ if (broker && manifestPath !== undefined) return refusal("RK_CARGO_BROKER_MANIFEST_PATH", `\`cargo ${sub}\` passes --manifest-path; a broker call runs from the workspace's own directory (the client refuses to rewrite the invocation), so run it there`);
97
+ if (broker && valueOf("--target-dir") !== undefined) return refusal("RK_CARGO_REDIRECT", `\`cargo ${sub}\` passes --target-dir; the broker pins a snapshot compile's output directory`);
98
+ let workspace;
99
+ try {
100
+ const manifest = locate(manifestPath === undefined ? null : path.resolve(cwd, manifestPath), cwd, toolchain);
101
+ if (typeof manifest !== "string" || !path.isAbsolute(manifest)) throw new Error(`locate-project reported ${JSON.stringify(manifest)}`);
102
+ workspace = realpath(path.dirname(manifest));
103
+ } catch (error) {
104
+ return refusal("RK_CARGO_UNBOUND_WORKSPACE", `\`cargo ${sub}\` from ${cwd}${manifestPath === undefined ? "" : ` (--manifest-path ${manifestPath})`}: its Cargo workspace cannot be resolved (${error.message}); a snapshot compile's outputs must be the checkout's`);
105
+ }
106
+ const targetDir = Object.hasOwn(map.workspaces ?? {}, workspace) ? map.workspaces[workspace] : null;
107
+ if (!targetDir) return refusal("RK_CARGO_UNBOUND_WORKSPACE", `\`cargo ${sub}\` builds the Cargo workspace ${workspace}, which is not one this snapshot compile resolved (${Object.keys(map.workspaces ?? {}).join(", ") || "none"}); its outputs would not be the checkout's`);
108
+ if (LOCKFILE.has(sub) && !exists(path.join(workspace, "Cargo.lock"))) return refusal("RK_CARGO_UNBOUND_COMMAND", `\`cargo ${sub}\` has no --locked, and workspace ${workspace} has no committed Cargo.lock it would only read; it may write one`);
109
+ // Broker mode: `targetDir` is the workspace's counterpart in the primary checkout (the gate's snapshot-to-checkout map).
110
+ if (broker) return { exec: { args: words, env: { ...env }, workspace, checkoutWorkspace: targetDir } };
111
+ const explicit = valueOf("--target-dir");
112
+ if (explicit !== undefined && path.resolve(cwd, explicit) !== targetDir) return refusal("RK_CARGO_REDIRECT", `\`cargo ${sub}\` passes --target-dir ${explicit}; workspace ${workspace} writes only to the checkout's ${targetDir}`);
113
+ return { exec: { args: words, env: withCargo({ ...env, CARGO_TARGET_DIR: targetDir, CARGO_BUILD_TARGET_DIR: targetDir }), workspace, targetDir } };
114
+ }
115
+
116
+ /**
117
+ * Writes the wrapper for one snapshot compile under `base`: the map file and `bin/cargo`, which runs this module with node. The
118
+ * directory is named by the sha256 of the map and this module's bytes (`identity`), so the same workspaces, targets and wrapper give
119
+ * the same path (and the same CARGO in a scan's cache key) and any change gives another. `targetDirs` is the gate's { [cargoDir]:
120
+ * absolute checkout target directory }; each workspace is keyed by its real path in the snapshot. `realCargo` is the absolute cargo
121
+ * the wrapper runs (never itself). POSIX hosts only: a Windows child started without a shell finds only an .exe on PATH.
122
+ *
123
+ * Broker mode (`broker: { checkoutDirs, section }`, the Mac): `checkoutDirs` is the gate's { [cargoDir]: absolute path of that
124
+ * workspace in the primary checkout } (the snapshot-to-checkout map, the only source of a request's primaryCwd) and `section` the
125
+ * public part of the gate's snapshot broker (brokerSection). `realCargo` then only runs `version` and `help`; it may be null.
126
+ */
127
+ export function installCargoShim({ base, snapshotRoot, targetDirs, broker = null, realCargo, node = process.execPath, platform = process.platform, realpath = (p) => fs.realpathSync.native(p), onDir = () => {}, write = (file, text, mode) => fs.writeFileSync(file, text, { mode }) }) {
128
+ if (platform === "win32") throw Object.assign(new Error("the runtime cargo wrapper needs a POSIX host: a Windows child finds only an .exe on PATH"), { code: "RK_CARGO_SHIM_UNSUPPORTED" });
129
+ const mode = broker ? "broker" : "direct";
130
+ if (mode === "direct" && (typeof realCargo !== "string" || !path.isAbsolute(realCargo))) throw Object.assign(new Error(`no absolute cargo to wrap (${JSON.stringify(realCargo)})`), { code: "RK_CARGO_SHIM_UNSUPPORTED" });
131
+ if (mode === "broker" && ((realCargo !== null && realCargo !== undefined && !path.isAbsolute(realCargo)) || !broker.section || !broker.checkoutDirs)) throw Object.assign(new Error("a broker wrapper needs the gate's checkout map and broker section"), { code: "RK_CARGO_SHIM_UNSUPPORTED" });
132
+ const workspaces = {};
133
+ for (const [cargoDir, targetDir] of Object.entries(broker ? broker.checkoutDirs : targetDirs ?? {})) workspaces[realpath(path.join(snapshotRoot, ...(cargoDir === "." ? [] : cargoDir.split("/"))))] = targetDir;
134
+ const self = fileURLToPath(import.meta.url);
135
+ const sorted = Object.fromEntries(Object.entries(workspaces).sort(([a], [b]) => (a < b ? -1 : 1)));
136
+ const identityHash = createHash("sha256").update(JSON.stringify({ realCargo: realCargo ?? null, node, workspaces: sorted, ...(broker ? { mode, section: broker.section } : {}) })).update(fs.readFileSync(self));
137
+ // The broker half of the wrapper is part of its identity: any change to the code that carries and verifies a call is another wrapper.
138
+ if (broker) for (const name of ["snapshot-broker.mjs", "snapshot-request.mjs", "provenance-verify.mjs", "cargo-ledger.mjs"]) identityHash.update(fs.readFileSync(new URL(`./${name}`, import.meta.url)));
139
+ const identity = identityHash.digest("hex");
140
+ const dir = path.join(base, `rk-cargo-shim-${identity.slice(0, 16)}`);
141
+ const bin = path.join(dir, "bin");
142
+ const shim = path.join(bin, "cargo");
143
+ const mapFile = path.join(dir, "cargo-target-map.json");
144
+ const ledgerDir = path.join(dir, "ledger");
145
+ // The caller learns the directory before anything is written into it (its cleanup owns it from here), and a failed install removes
146
+ // what it wrote before it throws.
147
+ onDir(dir);
148
+ try {
149
+ fs.mkdirSync(bin, { recursive: true });
150
+ fs.mkdirSync(ledgerDir, { recursive: true, mode: 0o700 });
151
+ const quote = (s) => `'${String(s).replaceAll("'", "'\\''")}'`;
152
+ const put = (file, text, mode) => { const tmp = `${file}.${process.pid}.tmp`; write(tmp, text, mode); fs.renameSync(tmp, file); };
153
+ const sentinel = mode === "broker" || Object.keys(sorted).length > 1 ? path.join(dir, "unbound") : null;
154
+ const section = broker ? { ...broker.section, sentinel } : null;
155
+ put(mapFile, `${JSON.stringify({ schema: SHIM_MAP_SCHEMA, mode, identity, realCargo: realCargo ?? null, shim, ledgerDir, workspaces: sorted, ...(section ? { broker: section } : {}) }, null, 2)}\n`, 0o644);
156
+ put(shim, `#!/bin/sh\nexec ${quote(node)} ${quote(self)} --map ${quote(mapFile)} -- "$@"\n`, 0o755);
157
+ return { dir, bin, shim, mapFile, ledgerDir, mode, workspaces: sorted, identity, sentinel };
158
+ } catch (error) {
159
+ fs.rmSync(dir, { recursive: true, force: true });
160
+ throw error;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * The environment every scan of a wrapped snapshot compile starts from: the wrapper first on PATH and named in CARGO, and, when the
166
+ * snapshot has exactly one Cargo workspace (direct mode), its checkout target as the scan-wide CARGO_TARGET_DIR, so a cargo that
167
+ * bypasses the wrapper (an absolute path, `rustup run`, a reset PATH) still writes there. Otherwise runScans gives each scan its own
168
+ * empty sentinel as CARGO_TARGET_DIR instead (installed.sentinel; options.cargoSentinel), which must still be empty afterwards.
169
+ * Broker mode has no checkout target to inherit and always uses the sentinel.
170
+ */
171
+ export function shimScanEnv(env, installed) {
172
+ const targets = Object.values(installed.workspaces);
173
+ const pathKey = Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH";
174
+ return { ...env, [pathKey]: `${installed.bin}${path.delimiter}${env[pathKey] ?? ""}`, CARGO: installed.shim, ...(installed.mode !== "broker" && targets.length === 1 ? { CARGO_TARGET_DIR: targets[0] } : {}) };
175
+ }
176
+
177
+ /**
178
+ * The workspace root Cargo resolves for a manifest or a directory, from the files alone (broker mode: no cargo runs outside the
179
+ * broker, not even `locate-project`): the nearest enclosing Cargo.toml that declares `[workspace]`, else the nearest Cargo.toml.
180
+ */
181
+ export function locateWorkspaceByWalk(manifestPath, cwd, { read = (f) => fs.readFileSync(f, "utf8"), exists = fs.existsSync } = {}) {
182
+ let nearest = null;
183
+ for (let dir = manifestPath ? path.dirname(manifestPath) : cwd; ; dir = path.dirname(dir)) {
184
+ const manifest = path.join(dir, "Cargo.toml");
185
+ if (exists(manifest)) {
186
+ nearest ??= manifest;
187
+ let text = "";
188
+ try { text = read(manifest); } catch { text = ""; }
189
+ if (/^\s*\[workspace(?:\.[^\]]*)?\]/m.test(text)) return manifest;
190
+ }
191
+ if (path.dirname(dir) === dir) break;
192
+ }
193
+ if (nearest) return nearest;
194
+ throw new Error("could not find `Cargo.toml` in the working directory or any parent");
195
+ }
196
+
197
+ /** The wrapper itself: `cargo-target-shim.mjs --map <file> -- <cargo args>`. Returns the exit status. */
198
+ export function runShim(argv, { env = process.env, cwd = process.cwd(), spawn = spawnSync, err = (m) => process.stderr.write(`${m}\n`) } = {}) {
199
+ const sep = argv.indexOf("--");
200
+ const mapAt = argv.indexOf("--map");
201
+ if (mapAt < 0 || sep < 0 || mapAt > sep) { err("rightkit-gate: RK_CARGO_SHIM_ERROR: usage: cargo-target-shim.mjs --map <file> -- <cargo args>"); return SHIM_REFUSAL_STATUS; }
202
+ let map;
203
+ try { map = JSON.parse(fs.readFileSync(argv[mapAt + 1], "utf8")); if (map?.schema !== SHIM_MAP_SCHEMA) throw new Error(`schema ${JSON.stringify(map?.schema)}`); }
204
+ catch (error) { err(`rightkit-gate: RK_CARGO_SHIM_ERROR: the cargo target map cannot be read (${error.message}); nothing ran`); return SHIM_REFUSAL_STATUS; }
205
+ const words = argv.slice(sep + 1);
206
+ const token = env[SCAN_TOKEN_ENV];
207
+ const id = `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
208
+ const broker = map.mode === "broker";
209
+ const base = { id, mode: broker ? "broker" : "direct", cwd, argv: words };
210
+ // A call that cannot be recorded does not run: the ledger is how the gate learns what a test did, whatever the test does with the status.
211
+ const record = (entry) => appendLedger(map.ledgerDir, token, entry);
212
+ try { record({ ...base, status: "pending" }); } catch (error) { err(`rightkit-gate: RK_CARGO_LEDGER_ERROR: the call cannot be recorded (${error.message}); it does not run`); return SHIM_REFUSAL_STATUS; }
213
+ const refuse = (why) => { record({ ...base, status: "refused", code: why.code, message: why.message }); err(`rightkit-gate: ${why.code}: ${why.message}`); return SHIM_REFUSAL_STATUS; };
214
+ // locate-project runs the real cargo with CARGO pointing at it, never at the wrapper (direct mode). Broker mode reads the files only.
215
+ const locateEnv = { ...env, CARGO: map.realCargo };
216
+ const locate = broker ? (manifestPath, from) => locateWorkspaceByWalk(manifestPath, from) : (manifestPath, from, toolchain) => {
217
+ const r = spawn(map.realCargo, [...(toolchain ? [toolchain] : []), "locate-project", "--workspace", "--message-format", "plain", ...(manifestPath ? ["--manifest-path", manifestPath] : [])], { cwd: from, env: locateEnv, encoding: "utf8" });
218
+ if (r.error) throw r.error;
219
+ if (r.status !== 0) throw new Error(String(r.stderr || `exit ${r.status}`).trim().split(/\r?\n/).at(-1));
220
+ return String(r.stdout).trim();
221
+ };
222
+ let decision;
223
+ try { decision = decideCargoInvocation({ args: words, cwd, env, map, locate }); } catch (error) { return refuse({ code: "RK_CARGO_SHIM_ERROR", message: error.message }); }
224
+ if (decision.refuse) return refuse(decision.refuse);
225
+ if (broker && decision.exec.workspace) {
226
+ // The `pending` record above stays this call's until the broker call finishes: its records carry the same call id.
227
+ return runBrokerCargo({ words: decision.exec.args, cwd, env, section: map.broker, workspace: decision.exec.workspace, checkoutWorkspace: decision.exec.checkoutWorkspace, record: (entry) => record({ ...entry, id }), spawn, err });
228
+ }
229
+ // A call that reads no workspace (version, help) runs as it is: the real cargo in direct mode, the host's cargo or the client in broker mode.
230
+ const viaClient = broker && !map.realCargo;
231
+ const command = viaClient ? map.broker.client : map.realCargo;
232
+ const r = spawn(command, viaClient ? ["cargo", ...decision.exec.args] : decision.exec.args, { cwd, env: decision.exec.env, stdio: "inherit" });
233
+ if (r.error) return refuse({ code: "RK_CARGO_SHIM_ERROR", message: `${command} did not start (${r.error.message})` });
234
+ record({ ...base, status: "ran", exit: r.status ?? null });
235
+ return r.status ?? (r.signal ? 128 : SHIM_REFUSAL_STATUS);
236
+ }
237
+
238
+ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))) process.exit(runShim(process.argv.slice(2)));