@rightkit/release 0.6.10 → 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 +7 -5
- package/admission-files.mjs +3 -0
- package/admission.mjs +2 -2
- package/advisory-exceptions.mjs +41 -5
- package/android-play-upload.mjs +16 -15
- package/app-check.mjs +13 -9
- package/bin/rightkit-gate.mjs +762 -345
- package/broker-pins.mjs +152 -0
- package/build-release.mjs +80 -27
- package/cargo-ledger.mjs +62 -0
- package/cargo-target-shim.mjs +98 -26
- package/cli/right-release.mjs +28 -4
- package/clippy-equivalence.mjs +202 -0
- package/clippy-route.mjs +543 -0
- package/committed-source.mjs +39 -5
- package/crate-workspaces.mjs +87 -0
- package/create-mac-updater.mjs +14 -2
- package/deterministic-scans.mjs +196 -21
- package/development.mjs +13 -4
- package/direct-receipt.mjs +135 -0
- package/door-policy.mjs +10 -0
- package/door.mjs +6 -6
- package/freshness-census.mjs +257 -0
- package/gate-stages.mjs +76 -10
- package/hook-retirement.mjs +65 -0
- package/init-command.mjs +1 -1
- package/inventory-check.mjs +11 -8
- package/ios-upload.mjs +20 -15
- package/local-publish.mjs +77 -41
- package/local-qualify.mjs +5 -3
- package/local-runtime.mjs +5 -4
- package/mac-package-recipe.mjs +4 -1
- package/mirror-root-artifact.mjs +18 -5
- 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 +35 -6
- package/package.json +1 -1
- package/pipeline-admission.mjs +4 -4
- package/preflight.mjs +27 -4
- package/publish-cargo.mjs +4 -3
- package/recipe-build.mjs +38 -14
- package/release-artifact-path.mjs +341 -0
- 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 +44 -17
- package/rightkit-versions.json +62 -42
- 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 +12 -6
- package/snapshot-request.mjs +207 -5
- package/snapshot.mjs +123 -10
- package/source-split.mjs +73 -0
- package/stage-commands.mjs +2 -2
- package/stage-ledger.mjs +204 -0
- package/stage-sidecar-command.mjs +5 -1
- package/stage-sidecar.mjs +100 -20
- package/swap-helper-bundle.mjs +5 -2
- package/unit-gate.mjs +45 -3
- package/web-build.mjs +6 -4
- package/win-package-recipe.mjs +20 -7
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// The gate's production seam for a broker snapshot compile (item 7 rows 7.20 to 7.24; GATE-CONTRACT "Broker interface").
|
|
2
|
+
//
|
|
3
|
+
// createSnapshotBroker builds `deps.snapshotBroker` for runScans on a broker host (Mac, Dell): the commit, the snapshot, the
|
|
4
|
+
// build-ownership handle, the declared inputs READ FROM THE SNAPSHOT, the host's enrolled pins (broker-pins.mjs,
|
|
5
|
+
// never the receipt, an environment variable or a flag), the receipt reader and the managed output roots.
|
|
6
|
+
// runBrokerCargo one cargo call reached by a test on the Mac, through the runtime wrapper's broker mode: its own fresh
|
|
7
|
+
// nonce and carrier, `primaryCwd` from the gate's snapshot-to-checkout map, `callingGate` inherited and never
|
|
8
|
+
// forged, the carrier removed after the call, the signed receipt verified, and a ledger record either way.
|
|
9
|
+
// verifyBrokerLedger the gate's own re-verification of every ledgered call from the broker's signed receipt, with the gate's
|
|
10
|
+
// own commit, snapshot, ownership handle and pins (never the wrapper's say-so), and one use per carrier.
|
|
11
|
+
import crypto from "node:crypto";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { spawnSync } from "./background-process.mjs";
|
|
16
|
+
import { loadBrokerPins } from "./broker-pins.mjs";
|
|
17
|
+
import { resolveManagedRoots } from "./managed-roots.mjs";
|
|
18
|
+
import {
|
|
19
|
+
SnapshotRequestError, argvSha256, observedEnvOf, readDeclaredInputs, readRequestRef, verifySnapshotReceipt, writeSnapshotRequest,
|
|
20
|
+
} from "./snapshot-request.mjs";
|
|
21
|
+
|
|
22
|
+
/** The status a refused or unverified call returns to its caller: cargo's own error status. */
|
|
23
|
+
export const BROKER_REFUSAL_STATUS = 101;
|
|
24
|
+
export const CALLING_GATE_ENV = "RIGHTKIT_CALLING_GATE";
|
|
25
|
+
|
|
26
|
+
/** The managed `ws` roots a broker pins its generation targets under (BROKER-CONTRACT output pinning). */
|
|
27
|
+
export const brokerOutputRoots = ({ dataRoot }) => [path.join(dataRoot, "rightkit-build-control", "ws")];
|
|
28
|
+
|
|
29
|
+
/** The broker's receipt of one request, read through the RustRight client (`rustright status <id>`), synchronously. */
|
|
30
|
+
export function receiptSync({ client, requestId, env, spawn = spawnSync }) {
|
|
31
|
+
const r = spawn(client, ["status", requestId], { encoding: "utf8", env, timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
|
|
32
|
+
if (r.error || r.status !== 0) throw new SnapshotRequestError("receipt", `rustright status ${requestId} failed (${r.error?.message ?? `exit ${r.status}`})`);
|
|
33
|
+
let doc;
|
|
34
|
+
try { doc = JSON.parse(String(r.stdout)); } catch { throw new SnapshotRequestError("receipt", `rustright status ${requestId} printed no JSON`); }
|
|
35
|
+
// The status envelope is `{type:"STATUS", id, state, ..., receipt}`: `receipt` is null while the job is live and full once it is
|
|
36
|
+
// terminal. Polling is not allowed, so a null receipt after the cargo process exited is a clear error, never a retry.
|
|
37
|
+
if (!doc || typeof doc !== "object" || !Object.hasOwn(doc, "receipt")) throw new SnapshotRequestError("receipt", `rustright status ${requestId} returned no receipt field (not a STATUS envelope)`);
|
|
38
|
+
if (doc.receipt === null) throw new SnapshotRequestError("receipt-not-ready", `rustright status ${requestId} reports the job ${doc.state ?? "state unknown"} with no receipt yet, although its cargo process exited; the gate does not poll, so this call is unverified`);
|
|
39
|
+
if (typeof doc.receipt !== "object" || Array.isArray(doc.receipt)) throw new SnapshotRequestError("receipt", `rustright status ${requestId} returned a receipt that is not an object`);
|
|
40
|
+
return doc.receipt;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `deps.snapshotBroker` for a production run on a broker host. Throws when anything the compile owes cannot be established:
|
|
45
|
+
* no build ownership, no RustRight client, no pins (an app repository without an enrollment), a malformed declared-inputs manifest.
|
|
46
|
+
* There is no way to hand it pins: tests build their own object and inject it as `deps.snapshotBroker`.
|
|
47
|
+
*/
|
|
48
|
+
export function createSnapshotBroker({ env = process.env, platform = process.platform, checkout, commit, snapshot, ownership, client, gateFile, dataRoot, spawn = spawnSync, loadPins = loadBrokerPins }) {
|
|
49
|
+
const need = (ok, why) => { if (!ok) throw new SnapshotRequestError("broker", why); };
|
|
50
|
+
need(checkout && path.isAbsolute(checkout), "the primary checkout must be an absolute path");
|
|
51
|
+
need(commit?.sha && commit?.tree, "the commit and its tree are required");
|
|
52
|
+
need(snapshot?.path && path.isAbsolute(snapshot.path) && snapshot.expectedTree === commit.tree, "the snapshot needs an absolute path and the commit's tree as expectedTree");
|
|
53
|
+
need(ownership && typeof ownership.handle === "string" && ownership.handle && typeof ownership.markBrokerSubmit === "function", "a broker snapshot compile needs the gate's build ownership (handle and submit hook)");
|
|
54
|
+
need(typeof client === "string" && path.isAbsolute(client), "a broker snapshot compile needs the RustRight client (`rustright`) by absolute path");
|
|
55
|
+
const roots = resolveManagedRoots({ env, platform });
|
|
56
|
+
const managedData = dataRoot ?? roots.dataRoot;
|
|
57
|
+
const pins = loadPins({ dataRoot: managedData, platform, gateFile, checkout, spawn });
|
|
58
|
+
return {
|
|
59
|
+
controlRoot: roots.controlRoot, commit: { sha: commit.sha, tree: commit.tree }, snapshot: { path: snapshot.path, expectedTree: snapshot.expectedTree },
|
|
60
|
+
declaredInputs: readDeclaredInputs(snapshot.path), handle: ownership.handle, markBrokerSubmit: () => ownership.markBrokerSubmit(),
|
|
61
|
+
pins: { host: pins.host, keyId: pins.keyId, publicKey: pins.publicKey, brokerDigest: pins.brokerDigest }, pinsSource: pins.source,
|
|
62
|
+
checkout: fs.realpathSync.native(checkout), outputRoots: brokerOutputRoots({ dataRoot: managedData }), platform, client,
|
|
63
|
+
receiptOf: async (requestId) => receiptSync({ client, requestId, env, spawn }),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What the production gate hands the door as `o.snapshotBroker`: the statement that this gate carries the RustRight snapshot carrier.
|
|
69
|
+
* The door only asks whether a broker host may take the snapshot route; the broker itself (pins, declared inputs, ownership, receipt
|
|
70
|
+
* reader) is built by `createSnapshotBroker` where the snapshot compile runs.
|
|
71
|
+
*/
|
|
72
|
+
export const PRODUCTION_SNAPSHOT_CARRIER = Object.freeze({ kind: "production", create: createSnapshotBroker });
|
|
73
|
+
|
|
74
|
+
/** The part of a snapshot broker the wrapper process needs, as the map file carries it (public values only). */
|
|
75
|
+
export const brokerSection = (sb, { sentinel = null } = {}) => ({
|
|
76
|
+
client: sb.client, controlRoot: sb.controlRoot, commit: sb.commit, snapshot: sb.snapshot, handle: sb.handle, checkout: sb.checkout,
|
|
77
|
+
pins: sb.pins, outputRoots: sb.outputRoots, platform: sb.platform, sentinel,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const escapes = (rel) => rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
|
|
81
|
+
const inside = (child, parent) => { const rel = path.relative(parent, child); return rel === "" || !escapes(rel); };
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One cargo call of a test, carried through the broker. `workspace` is the real snapshot path of the cargo workspace the call
|
|
85
|
+
* resolved; `checkoutWorkspace` its counterpart in the primary checkout, from the gate's own map. Returns cargo's exit status.
|
|
86
|
+
* Every outcome is ledgered through `record` first or after, never skipped.
|
|
87
|
+
*/
|
|
88
|
+
export function runBrokerCargo({ words, cwd, env, section, workspace, checkoutWorkspace, record, spawn = spawnSync, err, tmp = os.tmpdir() }) {
|
|
89
|
+
const id = crypto.randomUUID();
|
|
90
|
+
const base = { id, mode: "broker", cwd, argv: [...words] };
|
|
91
|
+
const refuse = (code, message) => { record({ ...base, status: "refused", code, message }); err(`rightkit-gate: ${code}: ${message}`); return BROKER_REFUSAL_STATUS; };
|
|
92
|
+
const gate = env[CALLING_GATE_ENV];
|
|
93
|
+
if (typeof gate !== "string" || !gate.trim()) return refuse("RK_CARGO_BROKER_NO_GATE", `${CALLING_GATE_ENV} is not set: the call names no gate, and the wrapper never forges one`);
|
|
94
|
+
let realCwd; let checkoutCwd;
|
|
95
|
+
try {
|
|
96
|
+
realCwd = fs.realpathSync.native(cwd);
|
|
97
|
+
const rel = path.relative(workspace, realCwd);
|
|
98
|
+
if (escapes(rel)) throw new Error(`${realCwd} is not inside the workspace ${workspace}`);
|
|
99
|
+
checkoutCwd = fs.realpathSync.native(path.join(checkoutWorkspace, rel));
|
|
100
|
+
if (!fs.statSync(checkoutCwd).isDirectory()) throw new Error(`${checkoutCwd} is not a directory`);
|
|
101
|
+
if (!inside(checkoutCwd, section.checkout)) throw new Error(`${checkoutCwd} is outside the primary checkout ${section.checkout}`);
|
|
102
|
+
} catch (e) { return refuse("RK_CARGO_BROKER_CWD", `the call's working directory has no counterpart in the primary checkout (${e.message}); the broker runs it where the checkout has it`); }
|
|
103
|
+
let inputs;
|
|
104
|
+
try { inputs = readDeclaredInputs(section.snapshot.path); } catch (e) { return refuse("RK_CARGO_BROKER_DECLARED_INPUTS", e.message); }
|
|
105
|
+
// The environment the client sees: the caller's, without the gate's unbound-sentinel output directory (the broker pins the output).
|
|
106
|
+
const clientEnv = { ...env };
|
|
107
|
+
for (const key of Object.keys(clientEnv)) {
|
|
108
|
+
if (/^(CARGO_TARGET_DIR|CARGO_BUILD_TARGET_DIR)$/i.test(key) && section.sentinel && String(clientEnv[key]).startsWith(section.sentinel)) delete clientEnv[key];
|
|
109
|
+
}
|
|
110
|
+
const refDir = fs.mkdtempSync(path.join(tmp, "rk-snapreq-"));
|
|
111
|
+
let made = null;
|
|
112
|
+
try {
|
|
113
|
+
try {
|
|
114
|
+
made = writeSnapshotRequest({ controlRoot: section.controlRoot, commit: section.commit, snapshot: section.snapshot, declaredInputs: inputs, handle: section.handle, primaryCwd: checkoutCwd, argv: words, requestIdFile: path.join(refDir, "request.json") });
|
|
115
|
+
} catch (e) { return refuse("RK_CARGO_BROKER_CARRIER", e.message); }
|
|
116
|
+
try { record({ ...base, status: "pending", nonce: made.carrier.nonce, checkoutCwd }); } catch (e) { fs.rmSync(made.file, { force: true }); return refuse("RK_CARGO_LEDGER_ERROR", `the call cannot be recorded (${e.message}); it does not run`); }
|
|
117
|
+
const childEnv = { ...clientEnv, ...made.env };
|
|
118
|
+
const r = spawn(section.client, ["cargo", ...words], { cwd: checkoutCwd, env: childEnv, stdio: "inherit" });
|
|
119
|
+
fs.rmSync(made.file, { force: true });
|
|
120
|
+
let outcome;
|
|
121
|
+
let ref = null;
|
|
122
|
+
try {
|
|
123
|
+
if (r.error) throw new SnapshotRequestError("spawn", `the RustRight client did not start (${r.error.message})`);
|
|
124
|
+
ref = readRequestRef(path.join(refDir, "request.json"));
|
|
125
|
+
const receipt = receiptSync({ client: section.client, requestId: ref.requestId, env: clientEnv, spawn });
|
|
126
|
+
const observedEnv = observedEnvOf(clientEnv, section.platform);
|
|
127
|
+
const { evidence, attribution, toolchain } = verifySnapshotReceipt(receipt, { pins: section.pins, jobId: ref.requestId, carrier: made.carrier, argv: words, workspace: ref.workspace, observedEnv, checkout: section.checkout, outputRoots: section.outputRoots, platform: section.platform });
|
|
128
|
+
// attribution and toolchain are recorded as the broker signed them (a consumer that needs per-package attribution treats available:false as FAILED).
|
|
129
|
+
outcome = { status: "verified", requestId: ref.requestId, workspace: ref.workspace, observedEnv, outputDir: evidence.outputDir, treeAtStart: evidence.snapshot.treeAtStart, treeAtExit: evidence.snapshot.treeAtExit, attribution, toolchain };
|
|
130
|
+
} catch (e) {
|
|
131
|
+
outcome = { status: "unverified", code: e.code ?? "error", message: e.message, ...(ref ? { requestId: ref.requestId, workspace: ref.workspace } : {}) };
|
|
132
|
+
}
|
|
133
|
+
record({ ...base, ...outcome, nonce: made.carrier.nonce, checkoutCwd, clientExit: r.status ?? null });
|
|
134
|
+
if (outcome.status !== "verified") {
|
|
135
|
+
err(`rightkit-gate: RK_CARGO_BROKER_UNVERIFIED: \`cargo ${words.join(" ")}\` has no verified signed receipt (${outcome.code}: ${outcome.message}); the scan fails`);
|
|
136
|
+
return r.status ? r.status : BROKER_REFUSAL_STATUS;
|
|
137
|
+
}
|
|
138
|
+
return r.status ?? (r.signal ? 128 : BROKER_REFUSAL_STATUS);
|
|
139
|
+
} finally {
|
|
140
|
+
if (made) fs.rmSync(made.file, { force: true });
|
|
141
|
+
fs.rmSync(refDir, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The gate's own check of every ledgered broker call of one scan. A call counts only when the broker's signed receipt, fetched
|
|
147
|
+
* again now, verifies against THIS gate's commit, snapshot, ownership handle and pins, and its carrier was used once: a nonce or a
|
|
148
|
+
* request id that appears twice in the run (`seen`) is a replay. Returns the problems; none means every call verified.
|
|
149
|
+
*/
|
|
150
|
+
export async function verifyBrokerLedger(entries, sb, { seen = { nonces: new Set(), ids: new Set() } } = {}) {
|
|
151
|
+
const problems = [];
|
|
152
|
+
for (const e of entries) {
|
|
153
|
+
if (e.mode !== "broker" || e.status !== "verified") continue;
|
|
154
|
+
const call = `cargo ${(e.argv ?? []).join(" ")}`.trim();
|
|
155
|
+
try {
|
|
156
|
+
if (typeof e.nonce !== "string" || typeof e.requestId !== "string" || !Array.isArray(e.argv) || !e.argv.every((a) => typeof a === "string") || typeof e.workspace !== "string" || typeof e.checkoutCwd !== "string") throw new SnapshotRequestError("ledger", "the record is incomplete");
|
|
157
|
+
if (seen.nonces.has(e.nonce) || seen.ids.has(e.requestId)) throw new SnapshotRequestError("replay", "a carrier or request was used twice; each call carries its own fresh nonce and is used once");
|
|
158
|
+
seen.nonces.add(e.nonce); seen.ids.add(e.requestId);
|
|
159
|
+
const workspace = fs.realpathSync.native(e.workspace);
|
|
160
|
+
if (!inside(workspace, sb.checkout)) throw new SnapshotRequestError("ledger", `the request ran in ${workspace}, outside the primary checkout`);
|
|
161
|
+
if (fs.realpathSync.native(e.checkoutCwd) !== workspace) throw new SnapshotRequestError("ledger", "the request's working directory is not the one the call recorded");
|
|
162
|
+
// The gate's own truth: its commit, snapshot, handle and declared inputs; only the call's own argv, nonce and cwd come from the ledger.
|
|
163
|
+
const carrier = {
|
|
164
|
+
schema: "rightkit.snapshot-request/1", commit: sb.commit, snapshot: sb.snapshot, declaredInputs: { inputs: readDeclaredInputs(sb.snapshot.path) },
|
|
165
|
+
ownership: { handle: sb.handle }, primaryCwd: workspace, argvSha256: argvSha256(e.argv), nonce: e.nonce,
|
|
166
|
+
};
|
|
167
|
+
const receipt = await sb.receiptOf(e.requestId);
|
|
168
|
+
verifySnapshotReceipt(receipt, { pins: sb.pins, jobId: e.requestId, carrier, argv: e.argv, workspace: e.workspace, observedEnv: e.observedEnv ?? {}, checkout: sb.checkout, outputRoots: sb.outputRoots, platform: sb.platform });
|
|
169
|
+
} catch (error) { problems.push(`${call} (from ${e.cwd}): the gate's own verification of the broker's receipt failed (${error.code ?? "error"}: ${error.message})`); }
|
|
170
|
+
}
|
|
171
|
+
return problems;
|
|
172
|
+
}
|
package/snapshot-check.mjs
CHANGED
|
@@ -78,18 +78,24 @@ export function commitOf(checkout, rev, spawn = defaultSpawn) { // eslint-disabl
|
|
|
78
78
|
/**
|
|
79
79
|
* The snapshot holds exactly the commit (E6, GATE-CONTRACT §1): no path under it the commit's tree lacks (ignored ones included; the
|
|
80
80
|
* only allowances are snapshot.mjs's named pnpm dependency directories and the gate's config-only .git/config), no link resolving out
|
|
81
|
-
* of it or into the checkout, and the seeded tree id (the broker's method) equal to the commit's tree. Otherwise CheckError.
|
|
81
|
+
* of it or into the checkout, and the seeded tree id (the broker's method) equal to the commit's tree. Otherwise CheckError. Returns the tree id.
|
|
82
|
+
*
|
|
83
|
+
* Stage-output ledger (row 5.09d), the same walk as snapshot.mjs verifySnapshot (one implementation, snapshotProblem): given `ledger`
|
|
84
|
+
* (the entries an earlier verify returned) and/or `admitNew` (the END of a stage only), the stage's own ignored, untracked outputs
|
|
85
|
+
* are admitted and the ledger must still match exactly. Then the result is `{ tree, ledger: [{ path, type, size, sha256, execBit }] }`.
|
|
82
86
|
*/
|
|
83
|
-
export function verifySnapshotDir({ checkout, dir, commit, tree, when }) {
|
|
84
|
-
|
|
85
|
-
|
|
87
|
+
export function verifySnapshotDir({ checkout, dir, commit, tree, when, ledger, admitNew }) {
|
|
88
|
+
const ledgerMode = ledger !== undefined || admitNew === true;
|
|
89
|
+
let inspected;
|
|
90
|
+
try { inspected = snapshotProblem({ dir, entries: treeEntries(checkout, commit), checkout, ledger, admitNew }); }
|
|
86
91
|
catch (e) { throw new CheckError(`cannot list snapshot ${dir} ${when}: ${e.message}`); }
|
|
87
|
-
|
|
92
|
+
const extra = ledgerMode ? inspected.problem : inspected;
|
|
93
|
+
if (extra) throw new CheckError(`snapshot ${dir} is not exactly commit ${commit}${ledgerMode ? " plus its stage-output ledger" : ""} ${when}: ${extra}`);
|
|
88
94
|
let actual;
|
|
89
95
|
try { actual = snapshotTree({ checkout, dir, baseTree: tree }); }
|
|
90
96
|
catch (e) { throw new CheckError(`cannot compute the tree of snapshot ${dir} ${when}: ${e.message}`); }
|
|
91
97
|
if (actual !== tree) throw new CheckError(`snapshot ${dir} is tree ${actual} ${when}, not tree ${tree} of commit ${commit}`);
|
|
92
|
-
return actual;
|
|
98
|
+
return ledgerMode ? { tree: actual, ledger: inspected.admitted } : actual;
|
|
93
99
|
}
|
|
94
100
|
|
|
95
101
|
/** Every file of the commit with its blob id, from the checkout's object database: what the snapshot holds. */
|
package/snapshot-request.mjs
CHANGED
|
@@ -35,6 +35,36 @@ export const MANAGED_RUST_ENVIRONMENT_KEYS = Object.freeze([
|
|
|
35
35
|
]);
|
|
36
36
|
/** The fields the broker copies onto the receipt and signs in payload.snapshotEvidence (attestation.rs). */
|
|
37
37
|
export const SNAPSHOT_EVIDENCE_FIELDS = Object.freeze(["requestId", "brokerVersion", "executor", "commit", "snapshot", "declaredInputDigests", "ownership", "outputDir"]);
|
|
38
|
+
/** The stage evidence the broker signs in payload.stageEvidence and copies onto the receipt (attestation.rs STAGE_EVIDENCE_FIELDS). */
|
|
39
|
+
export const STAGE_EVIDENCE_FIELDS = Object.freeze(["freshness", "attribution", "toolchain"]);
|
|
40
|
+
const SIGNED_TOP_LEVEL_FIELDS = Object.freeze(["verdict", "refreshed", "exit", "signal", "timingsMs", "diagnosticsPath"]);
|
|
41
|
+
const TIMING_KEYS =Object.freeze(["admission", "queue", "bind", "cargo", "afterExitBind", "diagnostics"]);
|
|
42
|
+
/** `<toolchain>|cargo:<sha256>|rustc:<sha256>` (rustright toolchain_identity). */
|
|
43
|
+
const TOOLCHAIN_FORMAT = /^[^|\s][^|]*\|cargo:[0-9a-f]{64}\|rustc:[0-9a-f]{64}$/;
|
|
44
|
+
|
|
45
|
+
/** The compile-class cargo commands (and their aliases): the ones whose receipt carries attribution and toolchain. */
|
|
46
|
+
export const COMPILE_CLASS_COMMANDS = Object.freeze(["check", "clippy", "build", "doc", "rustc", "test", "b", "c", "t", "d"]);
|
|
47
|
+
const GLOBAL_VALUE_OPTIONS = new Set(["--color", "--explain"]);
|
|
48
|
+
|
|
49
|
+
/** The cargo subcommand of an argv (without `cargo`): after an optional `+toolchain` and the global options, before `--`; null when there is none. */
|
|
50
|
+
export function cargoSubcommandOf(argv) {
|
|
51
|
+
const words = [...argv];
|
|
52
|
+
for (let i = words[0]?.startsWith("+") ? 1 : 0; i < words.length; i += 1) {
|
|
53
|
+
const w = words[i];
|
|
54
|
+
if (w === "--") return null;
|
|
55
|
+
if (GLOBAL_VALUE_OPTIONS.has(w)) { i += 1; continue; }
|
|
56
|
+
if (w.startsWith("-")) continue;
|
|
57
|
+
return w;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
export const isCompileClass = (argv) => COMPILE_CLASS_COMMANDS.includes(cargoSubcommandOf(argv));
|
|
62
|
+
|
|
63
|
+
/** Where the gate's declared compile inputs are listed, as read from the snapshot (GATE-CONTRACT "Request"; the broker's COMPILE_INPUTS). */
|
|
64
|
+
export const COMPILE_INPUTS = ".rightkit/compile-inputs.json";
|
|
65
|
+
export const MAX_DECLARATION_BYTES = 1024 * 1024;
|
|
66
|
+
const MAX_INPUT_FILES = 200_000;
|
|
67
|
+
const MAX_INPUT_BYTES = 4 * 1024 * 1024 * 1024;
|
|
38
68
|
|
|
39
69
|
export class SnapshotRequestError extends Error {
|
|
40
70
|
constructor(code, message) { super(message); this.name = "SnapshotRequestError"; this.code = code; }
|
|
@@ -125,11 +155,130 @@ export function readRequestRef(file) {
|
|
|
125
155
|
|
|
126
156
|
const same = (a, b) => canonicalJson(a ?? null) === canonicalJson(b ?? null);
|
|
127
157
|
|
|
158
|
+
// ---- declared compile inputs, read and digested the way the broker does (check_admission.rs declared_inputs / snapshot_inputs) ----
|
|
159
|
+
|
|
160
|
+
const lstatOrNull = (p) => { try { return fs.lstatSync(p); } catch (e) { if (e.code === "ENOENT") return null; throw e; } };
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The declared inputs of a snapshot: `inputs` of `.rightkit/compile-inputs.json` read FROM THE SNAPSHOT, never the checkout. No
|
|
164
|
+
* manifest means an empty set; a link on its path, a file that is not regular, an oversized or malformed manifest, or an input that
|
|
165
|
+
* is not a plain relative path (absolute, `.`/`..`, inside `.git`, the manifest itself) is refused before any request is made.
|
|
166
|
+
*/
|
|
167
|
+
export function readDeclaredInputs(snapshotRoot) {
|
|
168
|
+
const parts = COMPILE_INPUTS.split("/");
|
|
169
|
+
let at = snapshotRoot; let meta = null;
|
|
170
|
+
for (const part of parts) {
|
|
171
|
+
at = path.join(at, part);
|
|
172
|
+
meta = lstatOrNull(at);
|
|
173
|
+
if (meta === null) return [];
|
|
174
|
+
if (meta.isSymbolicLink()) fail("declared-inputs", `${COMPILE_INPUTS} runs through a link at ${at}`);
|
|
175
|
+
}
|
|
176
|
+
if (!meta.isFile()) fail("declared-inputs", `${COMPILE_INPUTS} is not a regular file`);
|
|
177
|
+
if (meta.size > MAX_DECLARATION_BYTES) fail("declared-inputs", `${COMPILE_INPUTS} is larger than ${MAX_DECLARATION_BYTES} bytes`);
|
|
178
|
+
let value;
|
|
179
|
+
try { value = JSON.parse(fs.readFileSync(at, "utf8")); } catch (e) { fail("declared-inputs", `${COMPILE_INPUTS} is not JSON (${e.message})`); }
|
|
180
|
+
if (!value || typeof value !== "object" || !Array.isArray(value.inputs)) fail("declared-inputs", `${COMPILE_INPUTS}: \`inputs\` must be a list of paths`);
|
|
181
|
+
for (const input of value.inputs) {
|
|
182
|
+
if (typeof input !== "string" || !input) fail("declared-inputs", `${COMPILE_INPUTS}: every input is a non-empty relative path`);
|
|
183
|
+
const components = input.split(/[\\/]/);
|
|
184
|
+
if (path.isAbsolute(input) || path.posix.isAbsolute(input) || path.win32.isAbsolute(input) || components.some((c) => c === "" || c === "." || c === "..")) fail("declared-inputs", `${COMPILE_INPUTS}: ${input} is not a plain relative path inside the snapshot`);
|
|
185
|
+
if (components.some((c) => c.toLowerCase() === ".git")) fail("declared-inputs", `${COMPILE_INPUTS}: ${input} is inside .git; repository metadata is never a compile input`);
|
|
186
|
+
if (components.length <= parts.length && components.every((c, i) => c === parts[i])) fail("declared-inputs", `${COMPILE_INPUTS}: ${input} overlaps the declaration file itself`);
|
|
187
|
+
}
|
|
188
|
+
return [...value.inputs];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The `declaredInputDigests` the broker records for these inputs, computed here from the snapshot: per input, sha256 over, for each
|
|
193
|
+
* regular file in directory order (names sorted by their bytes), the file's relative name, a NUL, its bytes and a NUL. A link
|
|
194
|
+
* anywhere, a special file, or a limit breach is a refusal. Child names are joined with the platform's separator, as the broker's
|
|
195
|
+
* `Path::join` does.
|
|
196
|
+
*/
|
|
197
|
+
export function declaredInputDigestsOf(snapshotRoot, inputs, { platform = process.platform } = {}) {
|
|
198
|
+
const sep = platform === "win32" ? "\\" : "/";
|
|
199
|
+
let files = 0; let bytes = 0;
|
|
200
|
+
const walk = (full, name, hasher) => {
|
|
201
|
+
const meta = fs.lstatSync(full);
|
|
202
|
+
if (meta.isSymbolicLink()) fail("declared-inputs", `declared compile input holds a link at ${name}`);
|
|
203
|
+
if (meta.isDirectory()) {
|
|
204
|
+
const children = fs.readdirSync(full).sort((a, b) => Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")));
|
|
205
|
+
for (const child of children) walk(path.join(full, child), name.endsWith("/") || name.endsWith("\\") ? `${name}${child}` : `${name}${sep}${child}`, hasher);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (!meta.isFile()) fail("declared-inputs", `declared compile input ${name} is not a regular file or directory`);
|
|
209
|
+
files += 1;
|
|
210
|
+
if (files > MAX_INPUT_FILES) fail("declared-inputs", `the declared compile inputs exceed ${MAX_INPUT_FILES} files`);
|
|
211
|
+
hasher.update(name); hasher.update(Buffer.from([0]));
|
|
212
|
+
const fd = fs.openSync(full, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
213
|
+
try {
|
|
214
|
+
const after = fs.fstatSync(fd);
|
|
215
|
+
if (!after.isFile() || (process.platform !== "win32" && (after.dev !== meta.dev || after.ino !== meta.ino))) fail("declared-inputs", `${name} changed while it was read`);
|
|
216
|
+
const chunk = Buffer.allocUnsafe(64 * 1024);
|
|
217
|
+
for (;;) {
|
|
218
|
+
const n = fs.readSync(fd, chunk, 0, chunk.length, null);
|
|
219
|
+
if (n === 0) break;
|
|
220
|
+
bytes += n;
|
|
221
|
+
if (bytes > MAX_INPUT_BYTES) fail("declared-inputs", `the declared compile inputs exceed ${MAX_INPUT_BYTES} bytes`);
|
|
222
|
+
hasher.update(chunk.subarray(0, n));
|
|
223
|
+
}
|
|
224
|
+
} finally { fs.closeSync(fd); }
|
|
225
|
+
hasher.update(Buffer.from([0]));
|
|
226
|
+
};
|
|
227
|
+
return inputs.map((input) => {
|
|
228
|
+
// Every component of the declared path is walked without following a link.
|
|
229
|
+
let at = snapshotRoot;
|
|
230
|
+
for (const part of input.split(/[\\/]/)) {
|
|
231
|
+
at = path.join(at, part);
|
|
232
|
+
let meta;
|
|
233
|
+
try { meta = fs.lstatSync(at); } catch (e) { fail("declared-inputs", `${input}: ${e.message}`); }
|
|
234
|
+
if (meta.isSymbolicLink()) fail("declared-inputs", `${input} runs through a link at ${at}`);
|
|
235
|
+
}
|
|
236
|
+
const hasher = crypto.createHash("sha256");
|
|
237
|
+
walk(at, input, hasher);
|
|
238
|
+
return { path: input, sha256: hasher.digest("hex") };
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---- the receipt ----
|
|
243
|
+
|
|
244
|
+
const inside = (child, parent, platform) => {
|
|
245
|
+
const norm = (p) => (platform === "win32" ? path.win32.resolve(p).toLowerCase() : path.resolve(p));
|
|
246
|
+
const rel = (platform === "win32" ? path.win32 : path).relative(norm(parent), norm(child));
|
|
247
|
+
return rel === "" || !(rel === ".." || /^\.\.[\\/]/.test(rel) || path.isAbsolute(rel) || path.win32.isAbsolute(rel));
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The generation target the broker pins an app's outputs to: `<root>/<12 hex>/g/<NNN>/target` under one of the managed `ws` roots
|
|
252
|
+
* (`<external data root>/rightkit-build-control/ws`; BROKER-CONTRACT "Source identity", output pinning). The 12-hex key is
|
|
253
|
+
* rustright-state's workspace_key, which the gate never computes (the snapshot's own key is `snapshots/<sha256(repo path)[:16]>`, a
|
|
254
|
+
* different thing); it reads the receipt's outputDir and checks only this shape. The generation number (3+ digits) is the broker's.
|
|
255
|
+
*/
|
|
256
|
+
export function isGenerationTarget(outputDir, outputRoots, platform = process.platform) {
|
|
257
|
+
const mod = platform === "win32" ? path.win32 : path;
|
|
258
|
+
for (const root of outputRoots) {
|
|
259
|
+
const rel = mod.relative(platform === "win32" ? mod.resolve(root).toLowerCase() : mod.resolve(root), platform === "win32" ? mod.resolve(outputDir).toLowerCase() : mod.resolve(outputDir));
|
|
260
|
+
const segments = rel.split(/[\\/]/);
|
|
261
|
+
if (segments.length === 4 && segments[0] !== ".." && !mod.isAbsolute(rel) && /^[0-9a-f]{12}$/.test(segments[0]) && segments[1] === "g" && /^[0-9]{3,}$/.test(segments[2]) && segments[3] === "target") return true;
|
|
262
|
+
}
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
267
|
+
|
|
128
268
|
/**
|
|
129
269
|
* Verifies the receipt of one snapshot cargo request. `pins` come from this host's enrollment, never from the receipt.
|
|
130
|
-
* Returns `{ payload, evidence }`; throws ProvenanceError (signature, pins, job, nonce, request) or SnapshotRequestError (evidence).
|
|
270
|
+
* Returns `{ payload, evidence }`; throws ProvenanceError (legacy, signature, pins, job, nonce, request) or SnapshotRequestError (evidence).
|
|
271
|
+
*
|
|
272
|
+
* Beyond the signature, the job, the nonce and the request digest, the receipt must carry (item 7 row 7.22):
|
|
273
|
+
* - signed SUCCEEDED with exit 0, and signed snapshotEvidence equal to the receipt's copies (commit, ownership handle, request id,
|
|
274
|
+
* snapshot path, `treeAtStart` = `treeAtExit` = the expected tree);
|
|
275
|
+
* - `declaredInputDigests` equal to the gate's own digest of the snapshot's declared inputs, read from the snapshot now;
|
|
276
|
+
* - `outputDir` absolute, outside the snapshot and the checkout, and a managed generation target under one of `outputRoots`;
|
|
277
|
+
* - signed stageEvidence (freshness, attribution, toolchain) equal to the receipt's;
|
|
278
|
+
* - verdict, timingsMs, exit, diagnosticsPath, brokerVersion, executor and refreshed present with their types.
|
|
279
|
+
* `checkout` and `outputRoots` are required: without them nothing can be said about where the outputs went, so it refuses.
|
|
131
280
|
*/
|
|
132
|
-
export function verifySnapshotReceipt(receipt, { pins, jobId, carrier, argv, workspace, observedEnv = {}, canonicalPath = (p) => fs.realpathSync.native(p) }) {
|
|
281
|
+
export function verifySnapshotReceipt(receipt, { pins, jobId, carrier, argv, workspace, observedEnv = {}, checkout, outputRoots, platform = process.platform, canonicalPath = (p) => fs.realpathSync.native(p), digestInputs = declaredInputDigestsOf, readInputs = readDeclaredInputs }) {
|
|
133
282
|
const requestDigest = requestDigestOf(snapshotRequestCore({ argv, workspace, observedEnv, carrier }));
|
|
134
283
|
const payload = verifyProvenance(receipt, pins, { jobId, nonce: carrier.nonce, requestDigest });
|
|
135
284
|
// The signed outcome, not the client's exit code: a passing row needs the broker to have signed SUCCEEDED with exit 0 and no
|
|
@@ -152,10 +301,63 @@ export function verifySnapshotReceipt(receipt, { pins, jobId, carrier, argv, wor
|
|
|
152
301
|
try { samePath = canonicalPath(snap.path) === canonicalPath(carrier.snapshot.path); } catch { samePath = false; }
|
|
153
302
|
if (!samePath) fail("evidence", `the signed snapshot ${snap.path} is not ${carrier.snapshot.path}`);
|
|
154
303
|
if (snap.treeAtStart !== carrier.snapshot.expectedTree || snap.treeAtExit !== carrier.snapshot.expectedTree) {
|
|
155
|
-
fail("evidence", `the snapshot was tree ${snap.treeAtStart} at START and ${snap.treeAtExit} at exit, not ${carrier.snapshot.expectedTree} throughout`);
|
|
304
|
+
fail("evidence", `the snapshot was tree ${snap.treeAtStart} at START and ${snap.treeAtExit} at exit, not ${carrier.snapshot.expectedTree} throughout: the build changed a tracked file (or added or removed one) in the commit's snapshot, so the result is discarded; that is the app owner's defect to fix (write generated output to an ignored path or an output directory, or generate before commit)`);
|
|
305
|
+
}
|
|
306
|
+
// Declared inputs: what the broker digested in place must be what the gate digests in the snapshot, from the same manifest.
|
|
307
|
+
let gateInputs; let gateDigests;
|
|
308
|
+
try {
|
|
309
|
+
gateInputs = readInputs(carrier.snapshot.path);
|
|
310
|
+
gateDigests = digestInputs(carrier.snapshot.path, gateInputs, { platform });
|
|
311
|
+
} catch (e) { fail("evidence", `the gate cannot digest the snapshot's declared inputs (${e.message})`); }
|
|
312
|
+
if (!same(gateInputs, carrier.declaredInputs?.inputs)) fail("evidence", "the snapshot's declared inputs are not the ones this request carried");
|
|
313
|
+
if (!same(evidence.declaredInputDigests, gateDigests)) fail("evidence", "the signed declaredInputDigests are not the gate's own digest of the snapshot's declared inputs");
|
|
314
|
+
// Output directory: where the outputs went must be the broker's managed generation target, never under the snapshot or the checkout.
|
|
315
|
+
const out = evidence.outputDir;
|
|
316
|
+
if (typeof out !== "string" || !path.isAbsolute(out) && !path.win32.isAbsolute(out)) fail("evidence", "the signed evidence names no absolute output directory");
|
|
317
|
+
if (!checkout || !path.isAbsolute(checkout) || !Array.isArray(outputRoots) || outputRoots.length === 0) fail("evidence", "the gate has no checkout or managed output root to judge the output directory against");
|
|
318
|
+
const canon = (p) => { try { return canonicalPath(p); } catch { return p; } };
|
|
319
|
+
if (inside(out, carrier.snapshot.path, platform) || inside(out, canon(carrier.snapshot.path), platform)) fail("evidence", `the output directory ${out} is inside the snapshot`);
|
|
320
|
+
if (inside(out, checkout, platform) || inside(out, canon(checkout), platform)) fail("evidence", `the output directory ${out} is inside the checkout`);
|
|
321
|
+
if (!isGenerationTarget(out, outputRoots, platform)) fail("evidence", `the output directory ${out} is not a managed generation target (<ws root>/<repo-key>/g/<generation>/target) under ${outputRoots.join(", ")}`);
|
|
322
|
+
// Stage evidence is signed with the rest and must equal the receipt's copy. `freshness` is required for every carried command;
|
|
323
|
+
// `attribution` and `toolchain` only for a compile-class command (check, clippy, build, doc, rustc, test: the broker emits them
|
|
324
|
+
// for compiles only), so metadata, fetch --locked, fmt --check or tree --locked pass without them. When either side carries one
|
|
325
|
+
// anyway, both must carry the same value.
|
|
326
|
+
const stage = payload.stageEvidence;
|
|
327
|
+
if (!isObject(stage)) fail("evidence", "the signed payload carries no stageEvidence");
|
|
328
|
+
const compile = isCompileClass(argv);
|
|
329
|
+
for (const name of STAGE_EVIDENCE_FIELDS) {
|
|
330
|
+
const signed = stage[name] ?? null; const copy = receipt[name] ?? null;
|
|
331
|
+
const required = name === "freshness" || compile;
|
|
332
|
+
if (required && (signed === null || copy === null)) fail("evidence", `${name} is missing from the signed stage evidence or the receipt${name === "freshness" ? "" : ` (a compile-class command, cargo ${cargoSubcommandOf(argv)}, carries it)`}`);
|
|
333
|
+
if (!same(copy, signed)) fail("evidence", `receipt ${name} differs from the signed stage evidence`);
|
|
334
|
+
}
|
|
335
|
+
// Shapes RustRight emits (item 4 row 4.18): attribution is `{available, ...}`, and an unavailable one names its reason
|
|
336
|
+
// (`cargo-json-message-format-not-requested` without a JSON message stream; a consumer that needs per-package attribution treats
|
|
337
|
+
// `available:false` as FAILED, not partial); toolchain is `<toolchain>|cargo:<sha256>|rustc:<sha256>`.
|
|
338
|
+
const attribution = receipt.attribution ?? null; const toolchain = receipt.toolchain ?? null;
|
|
339
|
+
if (attribution !== null) {
|
|
340
|
+
if (!isObject(attribution) || typeof attribution.available !== "boolean") fail("evidence", "receipt attribution is not {available, ...}");
|
|
341
|
+
if (attribution.available === false && !(typeof attribution.reason === "string" && attribution.reason !== "")) fail("evidence", "receipt attribution is unavailable and names no reason");
|
|
342
|
+
}
|
|
343
|
+
if (toolchain !== null && !(typeof toolchain === "string" && TOOLCHAIN_FORMAT.test(toolchain))) fail("evidence", "receipt toolchain is not <toolchain>|cargo:<sha256>|rustc:<sha256>");
|
|
344
|
+
// Fields the signature does not cover must still be present with their types (the consumer reads them). Every carried command:
|
|
345
|
+
// verdict, refreshed, exit (an integer, or null with a signal) and signal, timingsMs{admission, queue, bind, cargo, afterExitBind,
|
|
346
|
+
// diagnostics} (each a number or null) and diagnosticsPath (a path, or null). Success needs exit 0 and no signal, as signed.
|
|
347
|
+
const timingsOk = isObject(receipt.timingsMs) && TIMING_KEYS.every((k) => Object.hasOwn(receipt.timingsMs, k) && (receipt.timingsMs[k] === null || Number.isFinite(receipt.timingsMs[k])));
|
|
348
|
+
const typed = [
|
|
349
|
+
["verdict", isObject(receipt.verdict)], ["timingsMs", timingsOk], ["exit", receipt.exit === null || Number.isSafeInteger(receipt.exit)],
|
|
350
|
+
["signal", receipt.signal === null || typeof receipt.signal === "string" || Number.isSafeInteger(receipt.signal)],
|
|
351
|
+
["diagnosticsPath", receipt.diagnosticsPath === null || (typeof receipt.diagnosticsPath === "string" && receipt.diagnosticsPath !== "")], ["brokerVersion", typeof receipt.brokerVersion === "string" && receipt.brokerVersion !== ""],
|
|
352
|
+
["executor", typeof receipt.executor === "string" && receipt.executor !== ""], ["refreshed", Array.isArray(receipt.refreshed)],
|
|
353
|
+
];
|
|
354
|
+
for (const [name, ok] of typed) if (!ok) fail("evidence", `receipt ${name} is missing or has the wrong type`);
|
|
355
|
+
if (receipt.exit !== 0 || receipt.signal !== null) fail("evidence", `receipt exit ${receipt.exit} and signal ${receipt.signal} are not a success (exit 0, no signal)`);
|
|
356
|
+
// RustRight signs these top-level fields in the payload (item 4 row 4.18): when the signed payload carries one, the receipt's copy must equal it.
|
|
357
|
+
for (const name of SIGNED_TOP_LEVEL_FIELDS) {
|
|
358
|
+
if (payload[name] !== undefined && !same(receipt[name], payload[name])) fail("evidence", `receipt ${name} differs from the signed payload`);
|
|
156
359
|
}
|
|
157
|
-
|
|
158
|
-
return { payload, evidence };
|
|
360
|
+
return { payload, evidence, attribution, toolchain };
|
|
159
361
|
}
|
|
160
362
|
|
|
161
363
|
export { ProvenanceError };
|