@rightkit/release 0.6.13 → 0.6.15

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 (111) hide show
  1. package/README.md +49 -1
  2. package/adapter-env.mjs +165 -0
  3. package/android-ndk.mjs +5 -1
  4. package/android-release.mjs +73 -8
  5. package/app-check.mjs +22 -8
  6. package/app-closure.mjs +214 -0
  7. package/assemble-app-command.mjs +24 -7
  8. package/atomic-write.mjs +57 -0
  9. package/bin/rightkit-gate.mjs +1193 -337
  10. package/broker-client.mjs +106 -0
  11. package/broker-exception.mjs +211 -0
  12. package/broker-handshake.mjs +160 -0
  13. package/broker-pins.mjs +27 -8
  14. package/broker-wire.mjs +262 -0
  15. package/build-launch.mjs +364 -12
  16. package/build-ownership.mjs +117 -49
  17. package/build-release.mjs +3 -2
  18. package/cache-command.mjs +7 -2
  19. package/candidate-build-receipt.mjs +96 -0
  20. package/candidate-receipt-command.mjs +29 -0
  21. package/cargo-contract.mjs +4 -3
  22. package/cargo-ledger.mjs +1 -1
  23. package/cargo-license-notices.mjs +2 -2
  24. package/cargo-shapes.mjs +332 -0
  25. package/cargo-target-shim.mjs +44 -5
  26. package/cargo-target.mjs +5 -0
  27. package/carried-adapter-run.mjs +31 -0
  28. package/carried-cargo-run.mjs +9 -0
  29. package/carried-cargo.mjs +237 -0
  30. package/carrier-census.mjs +124 -0
  31. package/class-a-launch-guard.mjs +266 -33
  32. package/cli/right-release.mjs +2 -0
  33. package/clippy-route.mjs +23 -11
  34. package/compile-inputs.mjs +9 -12
  35. package/control-plane.mjs +18 -0
  36. package/deps-cargo-lock.mjs +208 -0
  37. package/deps.mjs +5 -0
  38. package/deterministic-scans.mjs +273 -44
  39. package/development-signing.mjs +14 -1
  40. package/development.mjs +112 -26
  41. package/direct-origin.mjs +107 -0
  42. package/disk-command.mjs +1 -0
  43. package/disk-policy.mjs +18 -3
  44. package/freshness-census.mjs +206 -58
  45. package/gate-executor.mjs +30 -12
  46. package/gate-stages.mjs +93 -16
  47. package/github-release.mjs +2 -1
  48. package/github-upload-gate.mjs +2 -1
  49. package/install-tauri-authenticode.mjs +346 -0
  50. package/ios-dev.mjs +77 -23
  51. package/ios-package-recipe.mjs +100 -41
  52. package/linux-artifacts.mjs +17 -2
  53. package/linux-qualifier.mjs +83 -0
  54. package/local-publish.mjs +36 -14
  55. package/local-runtime.mjs +2 -2
  56. package/lock-identity.mjs +138 -0
  57. package/lsclean.sh +0 -0
  58. package/managed-cargo.mjs +161 -44
  59. package/managed-roots.mjs +130 -12
  60. package/media-sidecar-catalog.mjs +1 -1
  61. package/native-adapters.mjs +411 -0
  62. package/native-helpers.mjs +26 -18
  63. package/native-tool-planning.mjs +94 -0
  64. package/ownership-mark.mjs +182 -0
  65. package/pack-digest.mjs +122 -0
  66. package/package.json +12 -12
  67. package/pipeline-receipts.mjs +302 -302
  68. package/platform-dir.mjs +14 -0
  69. package/pnpm-adapter-grammar.mjs +226 -0
  70. package/progress-control.mjs +7 -2
  71. package/provenance-real-receipt.pins.json +7 -0
  72. package/provenance-verify.mjs +262 -1
  73. package/publication-outcomes.mjs +21 -0
  74. package/publish-update.mjs +2 -1
  75. package/qa-journeys-prebuilt-run.mjs +18 -0
  76. package/qa-journeys-prebuilt.mjs +148 -0
  77. package/qa-snapshot-run.mjs +53 -0
  78. package/qa-snapshot.mjs +164 -0
  79. package/qualification-verify.mjs +144 -0
  80. package/qualify-reuse.mjs +38 -0
  81. package/recipe-build.mjs +16 -3
  82. package/release-qa-audit.mjs +2 -2
  83. package/release-qa-command.mjs +2 -1
  84. package/release-qa-receipt.mjs +3 -2
  85. package/release-state.mjs +3 -2
  86. package/release.mjs +14 -3
  87. package/rightkit-drift.mjs +27 -5
  88. package/rightkit-versions.json +15 -11
  89. package/rustright-names.mjs +32 -0
  90. package/scan-tool-enrolment.mjs +167 -0
  91. package/sign-windows.mjs +212 -112
  92. package/signing-files.mjs +35 -0
  93. package/signing-identity.mjs +7 -0
  94. package/snapshot-broker.mjs +528 -57
  95. package/snapshot-check.mjs +95 -11
  96. package/snapshot-metadata.mjs +123 -0
  97. package/snapshot-request.mjs +389 -96
  98. package/stage-binary.mjs +22 -4
  99. package/stage-commands.mjs +56 -13
  100. package/stage-sidecar.mjs +93 -21
  101. package/suite-signing-policy.mjs +657 -0
  102. package/suite-signing.mjs +223 -0
  103. package/swift-scratch.mjs +25 -0
  104. package/tauri-locked.mjs +25 -0
  105. package/test-command.mjs +164 -29
  106. package/unit-gate.mjs +201 -31
  107. package/upload-large.mjs +0 -0
  108. package/web-build.mjs +2 -2
  109. package/win-package-recipe.mjs +26 -2
  110. package/bin/darwin/rightkit-swap +0 -0
  111. package/bin/darwin/rightkit-swap.sign.json +0 -6
@@ -0,0 +1,237 @@
1
+ // The carried seam of right-release's own broker calls on a broker host (rightkit#1301; GATE-CONTRACT "Commit-only build", item 7 rows
2
+ // 7.20 and 7.24-R). ONE model: every compile of a snapshot stage is either a carried request of its own or nested under one.
3
+ //
4
+ // A right-release child of a snapshot door stage (dev, test, stage-sidecar, build, qa) reaches the broker in two carried ways:
5
+ // - its Cargo calls (`runCargoSync`, which stage-sidecar, cargo-target and development use; a `cargo` step through build-launch.mjs):
6
+ // this module's `runCarriedCargo`, one carried request per call through `carryBrokerCargo` (snapshot-broker.mjs): fresh nonce,
7
+ // `primaryCwd` from the gate's snapshot-to-checkout map, the signed receipt verified, a ledger record either way. The call runs from the
8
+ // manifest's directory and passes no `--manifest-path` and no output directory (the RustRight client refuses to rewrite an attested
9
+ // call, and the broker pins the output), so `--manifest-path <p>` is turned into a working directory here, and the output directory the
10
+ // caller needs is the receipt's `outputDir`, never a carrier-less `cargo metadata`;
11
+ // - its pnpm, node and QA steps (build-launch.mjs routes them to carried-adapter-run.mjs): `runCarriedAdapter`, one carried ADAPTER
12
+ // request per step through `carryBrokerAdapter` (rustright#190, snapshot-adapter-v1). The Tauri CLI, and any other child that compiles,
13
+ // runs NESTED under that job (the broker attributes each compile by the parent's capability token); the broker signs the list of those
14
+ // children (request id, argv, cwd, exit, outputDir) in the job's receipt, and the gate verifies it. A Tauri bundle's root is under the
15
+ // `outputDir` of the signed child that built it (`carriedOutputDir`), never a directory the child reported.
16
+ // There is no PATH wrapper and no stage grant: the carrier is the map and the ledger only, on every platform. The gate hands the child the
17
+ // map file through `RIGHTKIT_CARRIED_CARGO_MAP` and reads the ledger after the child ends: a refused, unverified or unfinished call fails
18
+ // the stage, and every verified call, nested children included, is verified again from the broker's receipt.
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { spawnSync } from "./background-process.mjs";
22
+ import { appendLedger, readLedger, SCAN_TOKEN_ENV, UNSCOPED } from "./cargo-ledger.mjs";
23
+ import { SHIM_MAP_SCHEMA, locateWorkspaceByWalk } from "./cargo-target-shim.mjs";
24
+ import { BROKER_REFUSAL_STATUS, carryBrokerAdapter, carryBrokerCargo, verifyBrokerLedger } from "./snapshot-broker.mjs";
25
+ import { splitCarriedArgs } from "./snapshot-request.mjs";
26
+
27
+ /** The environment variable that names the gate-written map of a snapshot stage's carried Cargo calls. */
28
+ export const CARRIED_CARGO_MAP_ENV = "RIGHTKIT_CARRIED_CARGO_MAP";
29
+ export const CARRIER_MISSING_CODE = "RK_CARGO_CARRIER_MISSING";
30
+
31
+ /** `null` (no carrier in this environment), `{ map }` or `{ error }` (a map that cannot be read: the call is refused, never run direct). */
32
+ export function carriedCargoMapOf(env = process.env) {
33
+ const file = env?.[CARRIED_CARGO_MAP_ENV];
34
+ if (typeof file !== "string" || !file) return null;
35
+ try {
36
+ const map = JSON.parse(fs.readFileSync(file, "utf8"));
37
+ if (map?.schema !== SHIM_MAP_SCHEMA || map.mode !== "broker" || !map.broker || typeof map.ledgerDir !== "string" || !map.workspaces) throw new Error("not a broker-mode cargo target map");
38
+ return { map };
39
+ } catch (error) { return { error: `the carried cargo map ${file} cannot be read (${error.message})` }; }
40
+ }
41
+
42
+ const refused = (code, message) => {
43
+ const text = `rightkit-gate: ${code}: ${message}`;
44
+ return { status: BROKER_REFUSAL_STATUS, signal: null, stdout: "", stderr: `${text}\n`, error: null, refusal: { code, message } };
45
+ };
46
+
47
+ /**
48
+ * One right-release Cargo call, carried. `args` are the cargo arguments (without `cargo`); `options` is the spawn options of the call
49
+ * (`cwd`, `env`, `stdio`). Returns a `spawnSync`-shaped result with `outputDir` (the receipt's) when the call verified.
50
+ */
51
+ export function runCarriedCargo(args, options = {}, { map, spawn = spawnSync, err = (m) => process.stderr.write(`${m}\n`) } = {}) {
52
+ const env = options.env ?? process.env;
53
+ const cwd = path.resolve(options.cwd ?? process.cwd());
54
+ const token = env[SCAN_TOKEN_ENV];
55
+ let from = cwd;
56
+ const original = [...args];
57
+ const record = (entry) => appendLedger(map.ledgerDir, token, entry);
58
+ const base = { id: `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`, mode: "broker", cwd, argv: original };
59
+ const refuse = (code, message) => {
60
+ try { record({ ...base, status: "refused", code, message }); } catch { /* the refusal is returned either way */ }
61
+ err(`rightkit-gate: ${code}: ${message}`);
62
+ return refused(code, message);
63
+ };
64
+ // The same function stage-commands.mjs uses to say what a stage's signed receipts must show (splitCarriedArgs, snapshot-request.mjs).
65
+ const split = splitCarriedArgs(original);
66
+ if (split.refusal) return refuse(split.refusal.code, split.refusal.message);
67
+ const { words } = split;
68
+ if (split.manifestPath !== null) from = path.dirname(path.resolve(cwd, split.manifestPath));
69
+ let workspace;
70
+ try {
71
+ const manifest = locateWorkspaceByWalk(null, fs.realpathSync.native(from));
72
+ workspace = fs.realpathSync.native(path.dirname(manifest));
73
+ } catch (error) { return refuse("RK_CARGO_UNBOUND_WORKSPACE", `\`cargo ${words.join(" ")}\` from ${from}: its Cargo workspace cannot be resolved (${error.message}); a snapshot stage's Cargo calls run in the commit's workspaces`); }
74
+ if (!Object.hasOwn(map.workspaces, workspace)) return refuse("RK_CARGO_UNBOUND_WORKSPACE", `\`cargo ${words.join(" ")}\` builds the Cargo workspace ${workspace}, which is not one this snapshot stage resolved (${Object.keys(map.workspaces).join(", ") || "none"})`);
75
+ const capture = options.stdio === "inherit" ? "inherit" : "capture";
76
+ const result = carryBrokerCargo({ words, cwd: from, env, section: map.broker, workspace, checkoutWorkspace: map.workspaces[workspace], record: (entry) => record({ ...entry, id: base.id }), spawn, err, stdio: capture });
77
+ return { status: result.status, signal: null, stdout: result.stdout, stderr: result.stderr, error: null, ...(result.outcome?.status === "verified" ? { outputDir: result.outcome.outputDir, requestId: result.outcome.requestId, requestDigest: result.outcome.requestDigest } : {}), ...(result.outcome?.status === "refused" ? { refusal: { code: result.outcome.code, message: result.outcome.message } } : {}) };
78
+ }
79
+
80
+ /**
81
+ * One right-release pnpm, node or QA step, carried as a broker adapter job. `adapter` is the registered adapter (`rightkit-pnpm`,
82
+ * `rightkit-node`, `rightkit-qa`), `args` the adapter's arguments (what follows `--` in `rustright run <adapter> -- <args>`); `options` is
83
+ * the spawn options of the step (`cwd`, `env`, `stdio`). Returns a `spawnSync`-shaped result with `outputDir` (the job's) and
84
+ * `nestedChildren` (the verified signed list), `requestId` and, for a native build tool, `executable.managedOutputs` (the output mapping the broker signed) when the call verified.
85
+ */
86
+ export function runCarriedAdapter(adapter, args, options = {}, { map, spawn = spawnSync, err = (m) => process.stderr.write(`${m}\n`) } = {}) {
87
+ const env = options.env ?? process.env;
88
+ const cwd = path.resolve(options.cwd ?? process.cwd());
89
+ const token = env[SCAN_TOKEN_ENV];
90
+ const result = carryBrokerAdapter({ adapter, words: args, cwd, env, section: map.broker, record: (entry) => appendLedger(map.ledgerDir, token, entry), spawn, err, stdio: options.stdio === "inherit" ? "inherit" : "capture" });
91
+ const verified = result.outcome?.status === "verified";
92
+ return {
93
+ status: result.status, signal: null, stdout: result.stdout, stderr: result.stderr, error: null,
94
+ ...(verified ? { outputDir: result.outcome.outputDir, requestId: result.outcome.requestId, nestedChildren: result.outcome.nestedChildren, ...(result.executable ? { executable: result.executable } : {}) } : {}),
95
+ ...(result.outcome?.status === "refused" ? { refusal: { code: result.outcome.code, message: result.outcome.message } } : {}),
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Writes the carrier of one snapshot stage's children under `base` and returns what to hand them: the map and the ledger only, on every
101
+ * platform (no PATH wrapper, no stage grant: a compile a child makes is nested under a carried adapter job, rustright#190). `checkoutDirs`
102
+ * is the gate's { [cargoDir]: absolute path of that workspace in the primary checkout }; `section` the public part of the gate's
103
+ * snapshot broker (brokerSection). Returns `{ env, ledgerDir, dir, mode, problems(sb), cleanup() }`; `env` is the delta to add.
104
+ */
105
+ export function installCarrier({ base, snapshotRoot, checkoutDirs, section, known = {}, realpath = (p) => fs.realpathSync.native(p), onDir = () => {} }) {
106
+ const dir = fs.mkdtempSync(path.join(base, "rk-carrier-"));
107
+ onDir(dir);
108
+ try {
109
+ const ledgerDir = path.join(dir, "ledger");
110
+ fs.mkdirSync(ledgerDir, { recursive: true, mode: 0o700 });
111
+ const workspaces = {};
112
+ for (const [cargoDir, checkoutDir] of Object.entries(checkoutDirs)) workspaces[realpath(path.join(snapshotRoot, ...(cargoDir === "." ? [] : cargoDir.split("/"))))] = checkoutDir;
113
+ const mapFile = path.join(dir, "cargo-target-map.json");
114
+ fs.writeFileSync(mapFile, `${JSON.stringify({ schema: SHIM_MAP_SCHEMA, mode: "broker", identity: path.basename(dir), realCargo: null, shim: null, ledgerDir, workspaces, ...(Object.keys(known).length ? { knownOutputDirs: known } : {}), broker: { ...section, sentinel: path.join(dir, "unbound") } }, null, 2)}\n`, { mode: 0o644 });
115
+ return { env: { [CARRIED_CARGO_MAP_ENV]: mapFile }, ledgerDir, dir, mode: "map", mapFile, shim: null, ...finish(ledgerDir, dir, workspaces) };
116
+ } catch (error) { fs.rmSync(dir, { recursive: true, force: true }); throw error; }
117
+ }
118
+
119
+ /** The checkout workspace root that holds `file`, the deepest one (a workspace can sit inside another's directory). */
120
+ const deepestRoot = (roots, file) => roots.filter((r) => file === r || file.startsWith(`${r}${path.sep}`)).sort((a, b) => b.length - a.length)[0] ?? null;
121
+
122
+ /** The snapshot workspace root that holds `file`, the deepest one, as a key of `workspaces` (`{ [snapshot root]: checkout root }`). */
123
+ const deepestSnapshotRoot = (workspaces, file) => {
124
+ let real = file;
125
+ try { real = fs.realpathSync.native(file); } catch { /* as signed */ }
126
+ return Object.keys(workspaces).filter((r) => real === r || real.startsWith(`${r}${path.sep}`)).sort((a, b) => b.length - a.length)[0] ?? null;
127
+ };
128
+
129
+ /** The verified calls' signed output directories: a cargo call's, an adapter job's own and each of its nested children's. */
130
+ const outputsOf = (entry) => [
131
+ ...(typeof entry.outputDir === "string" ? [entry.outputDir] : []),
132
+ ...(entry.shim === "adapter" && Array.isArray(entry.nestedChildren) ? entry.nestedChildren.map((c) => c.outputDir).filter((d) => typeof d === "string") : []), // a child that compiles nothing has outputDir null
133
+ ];
134
+
135
+ function finish(ledgerDir, dir, workspaces = {}) {
136
+ const checkoutRoots = Object.values(workspaces);
137
+ return {
138
+ /** Every call of the stage that is not a completed, verified success, and every verified call the gate cannot verify again itself. */
139
+ async problems(sb) {
140
+ const { entries, problems } = readLedger(ledgerDir, UNSCOPED);
141
+ const out = [...problems];
142
+ if (sb) out.push(...await verifyBrokerLedger(entries, sb));
143
+ return out;
144
+ },
145
+ /** The verified calls' output directories, in call order (the receipts'; an adapter job's nested children's included). */
146
+ outputDirs() { return readLedger(ledgerDir, UNSCOPED).entries.filter((e) => e.status === "verified").flatMap(outputsOf); },
147
+ /** The verified compiles' output directories by checkout workspace root: `[[root, outputDir]]`, the last call of a workspace winning. A nested child counts for the workspace its cwd is in. */
148
+ outputsByWorkspace() {
149
+ const out = new Map();
150
+ for (const e of readLedger(ledgerDir, UNSCOPED).entries) {
151
+ if (e.status !== "verified") continue;
152
+ if (e.shim === "adapter") {
153
+ for (const child of Array.isArray(e.nestedChildren) ? e.nestedChildren : []) {
154
+ const snap = typeof child.cwd === "string" ? deepestSnapshotRoot(workspaces, child.cwd) : null;
155
+ if (snap && typeof child.outputDir === "string") out.set(workspaces[snap], child.outputDir);
156
+ }
157
+ continue;
158
+ }
159
+ if (typeof e.outputDir !== "string" || typeof e.workspace !== "string") continue;
160
+ const root = deepestRoot(checkoutRoots, e.workspace);
161
+ if (root) out.set(root, e.outputDir);
162
+ }
163
+ return [...out];
164
+ },
165
+ cleanup() { fs.rmSync(dir, { recursive: true, force: true }); },
166
+ };
167
+ }
168
+
169
+ /** The ids of the calls the stage's ledger holds now, whatever their outcome (a snapshot to diff after a step), or an empty set when there is no carrier. */
170
+ export function carriedLedgerIds(env = process.env) {
171
+ const carried = carriedCargoMapOf(env);
172
+ if (!carried?.map) return new Set();
173
+ try { return new Set(readLedger(carried.map.ledgerDir, env[SCAN_TOKEN_ENV]).entries.map((e) => e.id)); } catch { return new Set(); }
174
+ }
175
+
176
+ /**
177
+ * The output directories of the verified calls added to the ledger since `before` (carriedLedgerIds): a cargo call's receipt, an adapter job's own and
178
+ * each of its nested children's, in call order. A call that was refused, unverified or unfinished contributes none.
179
+ */
180
+ export function carriedOutputDirsSince(env = process.env, before = new Set()) {
181
+ const carried = carriedCargoMapOf(env);
182
+ if (!carried?.map) return [];
183
+ let entries = [];
184
+ try { entries = readLedger(carried.map.ledgerDir, env[SCAN_TOKEN_ENV]).entries; } catch { return []; }
185
+ return [...new Set(entries.filter((e) => e.status === "verified" && !before.has(e.id)).flatMap(outputsOf))];
186
+ }
187
+
188
+ /**
189
+ * The output directory the broker signed for the carried ADAPTER job of `adapter` (a native tool such as `rightkit-xcodebuild`) added to the ledger since `before`, or null: the last such
190
+ * verified call's own `outputDir` (the one managed root the adapter owns). A call that was refused, unverified or unfinished names none.
191
+ */
192
+ export function carriedAdapterOutputDir(env, adapter, before = new Set()) {
193
+ const carried = carriedCargoMapOf(env);
194
+ if (!carried?.map) return null;
195
+ let entries = [];
196
+ try { entries = readLedger(carried.map.ledgerDir, env[SCAN_TOKEN_ENV]).entries; } catch { return null; }
197
+ const mine = entries.filter((e) => e.status === "verified" && e.shim === "adapter" && e.adapter === adapter && !before.has(e.id) && typeof e.outputDir === "string");
198
+ return mine.length ? mine.at(-1).outputDir : null;
199
+ }
200
+
201
+ /**
202
+ * The output directory of the workspace a manifest belongs to, from a signed receipt, or null: a verified call this child already made
203
+ * in that workspace (a build, whose receipt names the broker's pinned `outputDir`, or a nested child of a carried adapter job, whose entry in
204
+ * the job's signed list names the compile's own `outputDir`), else one an earlier stage of the door run
205
+ * verified (the map's `knownOutputDirs`, seeded by the gate). Asking Cargo for it (a read-only `metadata` call) is a snapshot request
206
+ * the broker does not accept today (rightkit#1301 F1), so a caller asks here first and only then falls back to a carried call.
207
+ */
208
+ export function carriedOutputDir(env, manifestPath, { preferNested = false } = {}) {
209
+ const carried = carriedCargoMapOf(env);
210
+ if (!carried?.map) return null;
211
+ const { map } = carried;
212
+ let root;
213
+ try {
214
+ const manifest = locateWorkspaceByWalk(null, fs.realpathSync.native(path.dirname(path.resolve(manifestPath))));
215
+ root = map.workspaces[fs.realpathSync.native(path.dirname(manifest))];
216
+ } catch { return null; }
217
+ if (!root) return null;
218
+ let entries = [];
219
+ try { entries = readLedger(map.ledgerDir, env[SCAN_TOKEN_ENV]).entries; } catch { entries = []; }
220
+ // The signed output directories of this workspace's compiles, in call order: a carried cargo call's receipt, or a nested child of a
221
+ // carried adapter job (the Tauri CLI's own compile), by the directory the child ran in.
222
+ const mine = []; const nested = [];
223
+ for (const e of entries) {
224
+ if (e.status !== "verified") continue;
225
+ if (e.shim === "adapter") {
226
+ for (const child of Array.isArray(e.nestedChildren) ? e.nestedChildren : []) {
227
+ const snap = typeof child.cwd === "string" ? deepestSnapshotRoot(map.workspaces, child.cwd) : null;
228
+ if (snap && map.workspaces[snap] === root && typeof child.outputDir === "string") { mine.push(child.outputDir); nested.push(child.outputDir); }
229
+ }
230
+ } else if (typeof e.outputDir === "string" && typeof e.workspace === "string" && deepestRoot(Object.values(map.workspaces), e.workspace) === root) mine.push(e.outputDir);
231
+ }
232
+ // A Tauri bundle's root is under the compile the Tauri CLI itself made: the nested child's, when one is signed, whatever carried Cargo call came after it.
233
+ if (preferNested && nested.length) return nested.at(-1);
234
+ if (mine.length) return mine.at(-1);
235
+ const known = map.knownOutputDirs?.[root];
236
+ return typeof known === "string" && path.isAbsolute(known) ? known : null;
237
+ }
@@ -0,0 +1,124 @@
1
+ // GB-76: owner-aware retirement of the snapshot-request carriers a crashed gate left behind (BR16, B34; BROKER-CONTRACT section 16).
2
+ //
3
+ // A carrier is written under `<control root>/snapshot-requests/<name>.json` and removed by its caller's `finally`; a gate that crashed between the write, the send and the cleanup left the file.
4
+ // RustRight's wire for the creator record: BEFORE the carrier the gate writes `<name>.json.owner.json`
5
+ // { "schema": "rustright.carrier-owner/1", "gate": { "pid", "startTimeMs" }, "nonce", "handle", "createdAtMs" }
6
+ // (its own process identity as RustRight reads it, the call's attestation nonce and ownership handle, the creation time in UNIX epoch milliseconds), then the carrier, and removes both in its `finally`.
7
+ // The broker's maintenance pass (`carrier_sweep::sweep_carriers`) retires a carrier only when its owner record parses and names this schema, its gate identity is read as gone or reused (a live gate
8
+ // keeps its carrier until the call has been finished for an hour, an unreadable identity keeps it for ever) and the broker holds no active or unknown submission under its handle. Age alone never
9
+ // retires anything. `sweepSnapshotRequests` is the gate's own sweep over the same wire and the same rules: nothing is deleted without proof, every entry is accounted for, `grant-*.json` and the
10
+ // owner records themselves are never carriers, links are never followed, and removal is compare-token (the owner record and the carrier must still be byte-identical to what was judged).
11
+ import crypto from "node:crypto";
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { processStartTimeMs, selfStartTimeMs } from "./lock-identity.mjs";
15
+
16
+ export const CARRIER_OWNER_SCHEMA = "rustright.carrier-owner/1";
17
+ export const CARRIER_DIR = "snapshot-requests";
18
+ /** A carrier whose call has been finished this long is retired even when its gate lives, when the broker holds nothing under it (RustRight FINISHED_CARRIER_RETENTION_MS). */
19
+ export const FINISHED_CARRIER_RETENTION_MS = 60 * 60 * 1000;
20
+ /** A carrier of a dead gate that the broker never saw stays this long before the gate retires it (the client the gate started may still be finishing). */
21
+ export const CARRIER_RETENTION_MS = 15 * 60 * 1000;
22
+ const MAX_CARRIER_BYTES = 4 * 1024 * 1024;
23
+ const MAX_OWNER_BYTES = 64 * 1024;
24
+ const OWNER_SUFFIX = ".owner.json";
25
+ const OWNER_KEYS = ["createdAtMs", "gate", "handle", "nonce", "schema"];
26
+ const digest = (data) => crypto.createHash("sha256").update(data).digest("hex");
27
+ /** The owner record's file: the carrier's own name, extension included, then `.owner.json`. */
28
+ export const ownerFileOf = (carrierFile) => `${carrierFile}${OWNER_SUFFIX}`;
29
+
30
+ /**
31
+ * Writes the creator record for the carrier at `file` (owner-only, exclusive, BEFORE the carrier itself) and returns it. `identity()` is `{ pid, startTimeMs }` (startTimeMs null when this host cannot
32
+ * read it: the broker then keeps the carrier for ever, which is safe).
33
+ */
34
+ export function writeCarrierOwner({ file, handle, nonce, identity = () => ({ pid: process.pid, startTimeMs: selfStartTimeMs() }), now = Date.now() }) {
35
+ const me = identity();
36
+ const record = { schema: CARRIER_OWNER_SCHEMA, gate: { pid: me.pid, startTimeMs: me.startTimeMs ?? null }, nonce, handle, createdAtMs: now };
37
+ fs.writeFileSync(ownerFileOf(file), JSON.stringify(record), { flag: "wx", mode: 0o600 });
38
+ return record;
39
+ }
40
+
41
+ /** Removes a carrier and its owner record after its call (the normal path: nothing is left for a sweep). */
42
+ export function removeCarrier(file) {
43
+ fs.rmSync(file, { force: true });
44
+ fs.rmSync(ownerFileOf(file), { force: true });
45
+ }
46
+
47
+ const readBounded = (file, max) => {
48
+ const meta = fs.lstatSync(file);
49
+ if (meta.isSymbolicLink() || !meta.isFile()) return { problem: "not a regular file" };
50
+ if (meta.size > max) return { problem: `larger than ${max} bytes` };
51
+ return { bytes: fs.readFileSync(file) };
52
+ };
53
+
54
+ /** The owner record `bytes` hold, exactly RustRight's shape (it denies unknown fields), or `{ problem }`. */
55
+ function parseOwner(bytes) {
56
+ let value; try { value = JSON.parse(bytes.toString("utf8")); } catch { return { problem: "not JSON" }; }
57
+ const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
58
+ if (!object(value) || Object.keys(value).sort().join() !== OWNER_KEYS.join()) return { problem: "not the fields of rustright.carrier-owner/1" };
59
+ if (value.schema !== CARRIER_OWNER_SCHEMA) return { problem: "not a rustright.carrier-owner/1 record" };
60
+ if (!object(value.gate) || Object.keys(value.gate).sort().join() !== "pid,startTimeMs" || !Number.isInteger(value.gate.pid) || value.gate.pid < 1 || value.gate.pid > 0xffffffff || !(value.gate.startTimeMs === null || (Number.isSafeInteger(value.gate.startTimeMs) && value.gate.startTimeMs >= 0))) return { problem: "the gate identity is not {pid, startTimeMs}" };
61
+ if (typeof value.nonce !== "string" || value.nonce === "" || typeof value.handle !== "string" || value.handle === "" || !Number.isSafeInteger(value.createdAtMs) || value.createdAtMs < 0) return { problem: "nonce, handle or createdAtMs is not valid" };
62
+ return { owner: value };
63
+ }
64
+
65
+ /** Whether the gate a record names is the same process, gone, reused (the pid runs a process that started at another time) or unreadable, as RustRight's `identity_state` judges it. */
66
+ export function gateState(gate, probe) {
67
+ let seen; try { seen = probe(gate.pid); } catch { return "unverifiable"; }
68
+ if (!seen?.alive) return "gone";
69
+ if (gate.startTimeMs === null) return "unverifiable";
70
+ if (!Number.isFinite(seen.startTimeMs)) return "unverifiable";
71
+ return seen.startTimeMs === gate.startTimeMs ? "same" : "reused";
72
+ }
73
+
74
+ const livePid = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } };
75
+ const hostProbe = (pid) => (livePid(pid) ? { alive: true, startTimeMs: pid === process.pid ? selfStartTimeMs() : processStartTimeMs(pid) } : { alive: false, startTimeMs: null });
76
+
77
+ /**
78
+ * Sweeps one carrier directory. `lease({ snapshot, handle })` is the broker's lease status (build-ownership.mjs brokerLeaseState) and must answer for the exact sub-handle; `identity.probe(pid)`
79
+ * is `{ alive, startTimeMs }`. Returns `{ retired: [file], retained: [{ file, why }], corrupt: [{ file, why }], examined }`: every entry is accounted for; nothing is deleted without proof.
80
+ */
81
+ export function sweepSnapshotRequests({ controlRoot, lease, snapshot = null, now = Date.now(), retentionMs = CARRIER_RETENTION_MS, limit = 256, identity = { probe: hostProbe } }) {
82
+ const dir = path.join(controlRoot, CARRIER_DIR);
83
+ const out = { retired: [], retained: [], corrupt: [], examined: 0 };
84
+ let dirMeta; try { dirMeta = fs.lstatSync(dir); } catch (e) { if (e.code === "ENOENT") return out; throw e; }
85
+ if (dirMeta.isSymbolicLink() || !dirMeta.isDirectory()) { out.corrupt.push({ file: dir, why: "the carrier directory is not a real directory" }); return out; }
86
+ const names = fs.readdirSync(dir).filter((n) => n.endsWith(".json") && !n.endsWith(OWNER_SUFFIX) && !n.startsWith(".") && !n.startsWith("grant-")).sort().slice(0, limit);
87
+ for (const name of names) {
88
+ const file = path.join(dir, name);
89
+ out.examined += 1;
90
+ const keep = (why) => out.retained.push({ file, why });
91
+ const corrupt = (why) => out.corrupt.push({ file, why });
92
+ let carrier; let ownerBytes;
93
+ try {
94
+ carrier = readBounded(file, MAX_CARRIER_BYTES);
95
+ if (carrier.problem) { corrupt(`carrier ${carrier.problem}`); continue; }
96
+ if (!fs.lstatSync(ownerFileOf(file), { throwIfNoEntry: false })) { keep("no owner record: its creator cannot be known"); continue; }
97
+ const o = readBounded(ownerFileOf(file), MAX_OWNER_BYTES);
98
+ if (o.problem) { corrupt(`owner record ${o.problem}`); continue; }
99
+ ownerBytes = o.bytes;
100
+ } catch (e) { corrupt(`unreadable (${e.code ?? e.message})`); continue; }
101
+ const parsed = parseOwner(ownerBytes);
102
+ if (parsed.problem) { corrupt(`the owner record is not one this gate wrote (${parsed.problem})`); continue; }
103
+ const { owner } = parsed;
104
+ const gate = gateState(owner.gate, identity.probe);
105
+ if (gate === "unverifiable") { keep("its gate's identity cannot be read, so it cannot be proven dead"); continue; }
106
+ const finished = now - owner.createdAtMs >= FINISHED_CARRIER_RETENTION_MS;
107
+ if (gate === "same" && !finished) { keep("its gate is alive"); continue; }
108
+ let doc = null; try { doc = JSON.parse(carrier.bytes.toString("utf8")); } catch { /* the snapshot comes from the broker's view below */ }
109
+ const answer = lease({ snapshot: doc?.snapshot?.path ?? snapshot, handle: owner.handle });
110
+ const members = Array.isArray(answer.members) ? answer.members : null;
111
+ const old = now - owner.createdAtMs >= retentionMs;
112
+ const settled = answer.state === "REAPED" || (answer.state === "UNKNOWN" && members !== null && members.length === 0 && old);
113
+ if (!settled) { keep(answer.state === "ACTIVE" ? "the broker holds it ACTIVE" : answer.state === "UNKNOWN" && members?.length ? "the broker knows it UNKNOWN" : `the broker's answer (${answer.state}) does not settle it${old ? "" : " and it is younger than the retention"}`); continue; }
114
+ // Compare-token removal: the carrier and the owner record are exactly what was judged, and still not links.
115
+ try {
116
+ const again = readBounded(file, MAX_CARRIER_BYTES); const ownerAgain = readBounded(ownerFileOf(file), MAX_OWNER_BYTES);
117
+ if (again.problem || ownerAgain.problem || digest(again.bytes) !== digest(carrier.bytes) || digest(ownerAgain.bytes) !== digest(ownerBytes)) { keep("changed while it was judged"); continue; }
118
+ fs.rmSync(file, { force: true });
119
+ fs.rmSync(ownerFileOf(file), { force: true });
120
+ out.retired.push(file);
121
+ } catch (e) { keep(`not removed (${e.code ?? e.message})`); }
122
+ }
123
+ return out;
124
+ }