@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.
- package/README.md +30 -12
- package/admission-files.mjs +4 -1
- package/admission.mjs +2 -2
- package/advisory-exceptions.mjs +41 -5
- package/android-play-upload.mjs +16 -15
- package/app-check.mjs +105 -27
- package/assemble-app-command.mjs +151 -0
- package/assembled-app.mjs +445 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/bin/rightkit-gate.mjs +1608 -345
- package/broker-pins.mjs +152 -0
- package/build-launch.mjs +88 -0
- package/build-ownership.mjs +1231 -0
- package/build-release.mjs +82 -28
- package/cargo-contract.mjs +3 -1
- package/cargo-ledger.mjs +62 -0
- package/cargo-target-shim.mjs +238 -0
- package/class-a-launch-guard.mjs +126 -0
- package/cli/right-release.mjs +31 -5
- package/clippy-equivalence.mjs +202 -0
- package/clippy-route.mjs +543 -0
- package/codesign-shim.mjs +11 -1
- package/committed-source.mjs +39 -5
- package/crate-workspaces.mjs +87 -0
- package/create-mac-updater.mjs +14 -2
- package/deterministic-scans.mjs +997 -71
- package/development-signing.mjs +4 -1
- package/development.mjs +120 -21
- package/direct-receipt.mjs +135 -0
- package/door-policy.mjs +71 -6
- package/door.mjs +6 -6
- package/freshness-census.mjs +257 -0
- package/gate-executor.mjs +14 -2
- package/gate-stages.mjs +91 -15
- package/heavy-command.mjs +3 -2
- package/hook-retirement.mjs +65 -0
- package/init-command.mjs +1 -1
- package/install-app.mjs +11 -2
- package/install-assembled-app.mjs +423 -0
- package/inventory-check.mjs +11 -8
- package/ios-dev.mjs +70 -3
- package/ios-upload.mjs +20 -15
- package/linux-artifacts.mjs +93 -0
- package/local-publish.mjs +224 -53
- package/local-qualify.mjs +5 -3
- package/local-runtime.mjs +5 -4
- package/mac-package-recipe.mjs +4 -1
- package/managed-cargo.mjs +47 -5
- package/managed-root-guard.mjs +34 -0
- package/mirror-root-artifact.mjs +18 -5
- package/native-helpers.mjs +355 -0
- package/ownership-class-rules.mjs +1 -1
- package/ownership-evidence.mjs +37 -13
- package/ownership-hooks.mjs +20 -59
- package/ownership-lockfiles.mjs +14 -1
- package/ownership-policy-bundle.mjs +3 -2
- package/ownership-staged.mjs +2 -1
- package/ownership.mjs +24 -0
- package/package-recipe-targets.mjs +84 -10
- package/package.json +4 -3
- package/pipeline-admission.mjs +4 -4
- package/pipeline-receipts.mjs +302 -302
- package/preflight.mjs +27 -4
- package/project-type.mjs +2 -2
- package/provenance-verify.mjs +115 -0
- package/publish-cargo.mjs +12 -9
- package/qa-admission-policy.json +10 -1
- package/recipe-build.mjs +106 -14
- package/release-artifact-path.mjs +341 -0
- package/release-config.mjs +1 -1
- package/release-invocation.mjs +8 -1
- package/release-qa-command.mjs +25 -9
- package/release-source-brand.mjs +17 -0
- package/release-source-identity.mjs +171 -0
- package/release.mjs +59 -22
- package/rightkit-versions.json +73 -47
- package/runtime-budgets.mjs +6 -2
- package/scan-digest.mjs +71 -5
- package/sign-updater.mjs +6 -3
- package/snapshot-broker.mjs +172 -0
- package/snapshot-check.mjs +312 -0
- package/snapshot-git.mjs +166 -0
- package/snapshot-request.mjs +363 -0
- package/snapshot-root-config.mjs +76 -0
- package/snapshot.mjs +969 -0
- package/source-split.mjs +73 -0
- package/stage-commands.mjs +60 -0
- package/stage-ledger.mjs +204 -0
- package/stage-sidecar-command.mjs +5 -1
- package/stage-sidecar.mjs +157 -27
- package/swap-helper-bundle.mjs +5 -2
- package/test-command.mjs +17 -4
- package/unit-gate.mjs +301 -0
- package/web-build.mjs +40 -6
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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 =
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
568
|
-
const dirtyConfig =
|
|
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
|
-
|
|
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
|
|
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) {
|
package/cargo-contract.mjs
CHANGED
|
@@ -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
|
|
package/cargo-ledger.mjs
ADDED
|
@@ -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)));
|