@rightkit/release 0.6.26 → 0.6.28
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/build-helper-trace.mjs +92 -5
- package/build-helpers-command.mjs +147 -53
- package/deterministic-scans.mjs +3 -1
- package/package.json +1 -1
- package/pipeline-receipts.mjs +302 -302
- package/rightkit-versions.json +1 -1
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +0 -6
package/build-helper-trace.mjs
CHANGED
|
@@ -23,8 +23,9 @@ const CRATES_IO = new Set(["registry+https://github.com/rust-lang/crates.io-inde
|
|
|
23
23
|
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
24
24
|
|
|
25
25
|
/** The unit key: sha256 of the canonical {crate, version, checksum, features (sorted), target, toolchain}. */
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// No toolchain in the key (Adrian, 2026-11-03, rightkit#1473: "why would a rust upgrade void it"): a Rust upgrade never voids a trace list.
|
|
27
|
+
export function buildHelperKey({ crate, version, checksum, features = [], target }) {
|
|
28
|
+
return sha256(JSON.stringify({ crate, version, checksum, features: [...features].sort(), target }));
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
/** Checksums of crates.io packages from a Cargo.lock text: Map "name version" -> checksum. */
|
|
@@ -93,8 +94,16 @@ export function recordTrace(file, unit, verdict, { now = () => new Date().toISOS
|
|
|
93
94
|
*/
|
|
94
95
|
export function checkBuildHelpers(units, list) {
|
|
95
96
|
const untraced = []; const failed = [];
|
|
97
|
+
// Entries recorded under the older key (which held the toolchain) are re-keyed from their own fields; a pass wins over a fail.
|
|
98
|
+
const byKey = new Map();
|
|
99
|
+
for (const entry of Object.values(list?.units ?? {})) {
|
|
100
|
+
if (!entry || typeof entry !== "object") continue;
|
|
101
|
+
const key = buildHelperKey(entry);
|
|
102
|
+
const had = byKey.get(key);
|
|
103
|
+
if (!had || (had.verdict !== "pass" && entry.verdict === "pass") || !TRUSTED_TRACERS.includes(had.tracer)) byKey.set(key, entry);
|
|
104
|
+
}
|
|
96
105
|
for (const u of units) {
|
|
97
|
-
const entry =
|
|
106
|
+
const entry = byKey.get(u.key);
|
|
98
107
|
if (!entry || !u.checksum || !TRUSTED_TRACERS.includes(entry.tracer)) untraced.push(u);
|
|
99
108
|
else if (entry.verdict !== "pass") failed.push({ unit: u, outside: entry.outside ?? [] });
|
|
100
109
|
}
|
|
@@ -160,11 +169,15 @@ export function parseStraceWrites(text, { cwd = "/" } = {}) {
|
|
|
160
169
|
const out = new Set();
|
|
161
170
|
for (const line of String(text).split(/\r?\n/)) {
|
|
162
171
|
if (/=\s*-1\s/.test(line) || /<unfinished \.\.\.>/.test(line)) continue;
|
|
172
|
+
// With `strace -y`, a directory fd prints its real path (`AT_FDCWD</abs/cwd>`, `5</abs/dir>`): a relative path resolves against that,
|
|
173
|
+
// never against a guessed folder (rustc writes `full.rmeta` relative to its own working directory).
|
|
174
|
+
const dirOf = new Map([...line.matchAll(/(?:AT_FDCWD|\d+)<([^>]+)>,\s*"((?:[^"\\]|\\.)*)"/g)].map((m) => [m[2], m[1]]));
|
|
175
|
+
const resolveIt = (p) => (path.posix.isAbsolute(p) ? p : path.posix.resolve(dirOf.get(p) ?? cwd, p));
|
|
163
176
|
let paths = [];
|
|
164
177
|
const open = OPEN.exec(line);
|
|
165
178
|
if (open) { if (/O_WRONLY|O_RDWR|O_CREAT|O_TRUNC|O_APPEND/.test(open[3])) paths = [open[2]]; }
|
|
166
179
|
else if (MUTATING.test(line)) paths = [...line.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]);
|
|
167
|
-
for (const p of paths) if (p) out.add(
|
|
180
|
+
for (const p of paths) if (p) out.add(resolveIt(p));
|
|
168
181
|
}
|
|
169
182
|
return [...out];
|
|
170
183
|
}
|
|
@@ -201,7 +214,7 @@ export function parseEtwWrites(xml, { rootPid, devices = {} }) {
|
|
|
201
214
|
if (tree.has(parent)) tree.add(child);
|
|
202
215
|
}
|
|
203
216
|
}
|
|
204
|
-
const toDos = (p) => { if (!p) return null; for (const [dev, letter] of Object.entries(devices)) if (p.toLowerCase().startsWith(dev.toLowerCase())) return letter + p.slice(dev.length); return p; };
|
|
217
|
+
const toDos = (p) => { if (!p) return null; for (const [dev, letter] of Object.entries(devices)) if (p.toLowerCase().startsWith(`${dev.toLowerCase()}\\`)) return letter + p.slice(dev.length); return p; };
|
|
205
218
|
const names = new Map(); const out = new Set();
|
|
206
219
|
for (const ev of events) {
|
|
207
220
|
if (provider(ev) !== "Microsoft-Windows-Kernel-File" || !tree.has(pidOf(ev))) continue;
|
|
@@ -262,3 +275,77 @@ export function readStraceFiles(prefix, { fsApi = fs } = {}) {
|
|
|
262
275
|
const dir = path.dirname(prefix); const base = path.basename(prefix);
|
|
263
276
|
return fsApi.readdirSync(dir).filter((n) => n.startsWith(`${base}.`)).map((n) => fsApi.readFileSync(path.join(dir, n), "utf8")).join("\n");
|
|
264
277
|
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
/** Calls `fn(record)` for every record of `file` separated by `sep`, reading 8 MiB at a time (a whole-run trace can pass Node's string limit). */
|
|
281
|
+
export function forEachRecord(file, sep, fn, { fsApi = fs } = {}) {
|
|
282
|
+
const fd = fsApi.openSync(file, "r"); const buf = Buffer.alloc(8 << 20); let rest = "";
|
|
283
|
+
try {
|
|
284
|
+
for (let n = fsApi.readSync(fd, buf, 0, buf.length, null); n > 0; n = fsApi.readSync(fd, buf, 0, buf.length, null)) {
|
|
285
|
+
const parts = (rest + buf.toString("utf8", 0, n)).split(sep);
|
|
286
|
+
rest = parts.pop();
|
|
287
|
+
for (const part of parts) fn(part);
|
|
288
|
+
}
|
|
289
|
+
if (rest) fn(rest);
|
|
290
|
+
} finally { fsApi.closeSync(fd); }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The root (one of `roots`) a pid descends from, through `parent`, or null. */
|
|
294
|
+
function rootFinder(parent, roots) {
|
|
295
|
+
const memo = new Map();
|
|
296
|
+
return (pid) => {
|
|
297
|
+
if (!Number.isInteger(pid)) return null;
|
|
298
|
+
if (memo.has(pid)) return memo.get(pid);
|
|
299
|
+
let found = null;
|
|
300
|
+
for (let p = pid, hops = 0; Number.isInteger(p) && hops < 64; p = parent.get(p), hops += 1) if (roots.has(p)) { found = p; break; }
|
|
301
|
+
memo.set(pid, found);
|
|
302
|
+
return found;
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** eslogger JSON lines from `file`, streamed: Map(root pid -> written paths) for every pid in `roots` (a Set), in two passes. */
|
|
307
|
+
export function esloggerWritesByRoot(file, roots) {
|
|
308
|
+
const parent = new Map();
|
|
309
|
+
const parse = (line) => { if (!line.trim().startsWith("{")) return null; try { return JSON.parse(line); } catch { return null; } };
|
|
310
|
+
forEachRecord(file, "\n", (line) => {
|
|
311
|
+
const e = parse(line); if (!e) return;
|
|
312
|
+
const pid = e?.process?.audit_token?.pid; const ppid = e?.process?.ppid ?? e?.process?.original_ppid;
|
|
313
|
+
if (Number.isInteger(pid) && Number.isInteger(ppid) && !parent.has(pid)) parent.set(pid, ppid);
|
|
314
|
+
const child = e?.event?.fork?.child?.audit_token?.pid;
|
|
315
|
+
if (Number.isInteger(child) && Number.isInteger(pid)) parent.set(child, pid);
|
|
316
|
+
});
|
|
317
|
+
const rootOf = rootFinder(parent, roots); const out = new Map([...roots].map((r) => [r, new Set()]));
|
|
318
|
+
forEachRecord(file, "\n", (line) => {
|
|
319
|
+
const e = parse(line); if (!e) return;
|
|
320
|
+
const root = rootOf(e?.process?.audit_token?.pid); if (root === null) return;
|
|
321
|
+
for (const w of parseEsloggerWrites(line, { rootPid: e.process.audit_token.pid })) out.get(root).add(w);
|
|
322
|
+
});
|
|
323
|
+
return new Map([...out].map(([r, s]) => [r, [...s]]));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** tracerpt XML from `file`, streamed by `</Event>`: Map(root pid -> written paths) for every pid in `roots`, kernel paths mapped by `devices`. */
|
|
327
|
+
export function etwWritesByRoot(file, roots, devices = {}) {
|
|
328
|
+
const field = (ev, name) => new RegExp(`<Data Name="${name}">([^<]*)</Data>`).exec(ev)?.[1];
|
|
329
|
+
const id = (ev) => Number(/<EventID>(\d+)<\/EventID>/.exec(ev)?.[1]);
|
|
330
|
+
const pidOf = (ev) => Number(/ProcessID="(\d+)"/.exec(ev)?.[1]);
|
|
331
|
+
const provider = (ev) => /Provider Name="([^"]+)"/.exec(ev)?.[1] ?? "";
|
|
332
|
+
const parent = new Map();
|
|
333
|
+
forEachRecord(file, "</Event>", (ev) => {
|
|
334
|
+
if (provider(ev) === "Microsoft-Windows-Kernel-Process" && id(ev) === 1) parent.set(Number(field(ev, "ProcessID")), Number(field(ev, "ParentProcessID")));
|
|
335
|
+
});
|
|
336
|
+
const rootOf = rootFinder(parent, roots); const out = new Map([...roots].map((r) => [r, new Set()]));
|
|
337
|
+
const toDos = (p) => { if (!p) return null; for (const [dev, letter] of Object.entries(devices)) if (p.toLowerCase().startsWith(`${dev.toLowerCase()}\\`)) return letter + p.slice(dev.length); return p; };
|
|
338
|
+
const names = new Map();
|
|
339
|
+
forEachRecord(file, "</Event>", (ev) => {
|
|
340
|
+
if (provider(ev) !== "Microsoft-Windows-Kernel-File") return;
|
|
341
|
+
const n = id(ev); const fileObject = field(ev, "FileObject");
|
|
342
|
+
if (n === 12) names.set(fileObject, field(ev, "FileName"));
|
|
343
|
+
const root = rootOf(pidOf(ev)); if (root === null) return;
|
|
344
|
+
const add = (p) => { const d = toDos(p); if (d) out.get(root).add(d); };
|
|
345
|
+
if (n === 12) { const disposition = (Number(field(ev, "CreateOptions") ?? 0) >>> 24) & 0xff; if (disposition !== 1) add(field(ev, "FileName")); }
|
|
346
|
+
else if (n === 30) add(field(ev, "FileName"));
|
|
347
|
+
else if (n === 16) add(names.get(fileObject));
|
|
348
|
+
else if (n === 26 || n === 27) add(field(ev, "FilePath") ?? field(ev, "FileName"));
|
|
349
|
+
});
|
|
350
|
+
return new Map([...out].map(([r, s]) => [r, [...s]]));
|
|
351
|
+
}
|
|
@@ -13,7 +13,7 @@ import fs from "node:fs";
|
|
|
13
13
|
import os from "node:os";
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
|
-
import { buildHelperRefusal, buildHelperUnits, checkBuildHelpers, hostListPath,
|
|
16
|
+
import { buildHelperRefusal, buildHelperUnits, checkBuildHelpers, hostListPath, esloggerWritesByRoot, etwWritesByRoot, judgeTrace, mergeLists, parseStraceWrites, readStraceFiles, readHostList, recordTrace, repoListPath } from "./build-helper-trace.mjs";
|
|
17
17
|
import { resolveManagedRoots } from "./managed-roots.mjs";
|
|
18
18
|
import { directCargoPath } from "./managed-cargo.mjs";
|
|
19
19
|
|
|
@@ -27,6 +27,22 @@ function hostFacts(run = spawnSync) {
|
|
|
27
27
|
return { target: host, toolchain: `rustc ${release}${commit ? ` (${commit.slice(0, 9)})` : ""}` };
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// Runs the trace builds, `argv[3]` at a time, starting the next whenever one finishes: argv[1] = jobs JSON [{cmd, args, env}], argv[2] = results
|
|
31
|
+
// file [{pid, status, error}] in job order.
|
|
32
|
+
const BATCH_RUNNER = `const fs = require("node:fs"); const { spawn } = require("node:child_process");
|
|
33
|
+
const jobs = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const limit = Math.max(1, Number(process.argv[3]) || 4);
|
|
34
|
+
const results = new Array(jobs.length); let next = 0; let done = 0;
|
|
35
|
+
const start = (i) => new Promise((resolve) => {
|
|
36
|
+
const j = jobs[i]; let child;
|
|
37
|
+
try { child = spawn(j.cmd, j.args, { env: j.env, stdio: ["ignore", "ignore", "pipe"] }); } catch (e) { results[i] = { status: -1, error: e.message }; resolve(); return; }
|
|
38
|
+
let tail = ""; child.stderr.on("data", (d) => { tail = (tail + d).slice(-2000); });
|
|
39
|
+
const finish = (r) => { if (results[i]) return; results[i] = r; done += 1; process.stdout.write(" [" + done + "/" + jobs.length + "] " + j.label + (r.status === 0 ? " built" : " failed") + "\\n"); resolve(); };
|
|
40
|
+
child.on("error", (e) => finish({ pid: child.pid, status: -1, error: e.message }));
|
|
41
|
+
child.on("close", (status) => finish({ pid: child.pid, status, ...(status ? { error: tail.trim().split("\\n").slice(-2).join(" | ") } : {}) }));
|
|
42
|
+
});
|
|
43
|
+
const worker = async () => { while (next < jobs.length) { const i = next++; await start(i); } };
|
|
44
|
+
Promise.all(Array.from({ length: Math.min(limit, jobs.length) }, worker)).then(() => fs.writeFileSync(process.argv[2], JSON.stringify(results)));`;
|
|
45
|
+
|
|
30
46
|
export function inventory({ manifestPath, run = spawnSync, readFile = (f) => fs.readFileSync(f, "utf8") }) {
|
|
31
47
|
const args = ["metadata", "--format-version", "1", "--locked", ...(manifestPath ? ["--manifest-path", manifestPath] : [])];
|
|
32
48
|
const r = run("cargo", args, { encoding: "utf8", maxBuffer: 1 << 28 });
|
|
@@ -47,7 +63,8 @@ export function cargoWorkspaces(root, { depth = 4, fsApi = fs } = {}) {
|
|
|
47
63
|
};
|
|
48
64
|
walk(root, depth);
|
|
49
65
|
// Only repositories the gate builds: the workspace's repository root (nearest .git) carries a .rightkit directory.
|
|
50
|
-
|
|
66
|
+
// A gate snapshot of a commit (<managed root>/snapshots/<key>) has .rightkit but no .git: the first ancestor with either decides.
|
|
67
|
+
const gated = (dir) => { for (let d = dir; ; d = path.dirname(d)) { if (fsApi.existsSync(path.join(d, ".rightkit"))) return true; if (fsApi.existsSync(path.join(d, ".git"))) return false; if (path.dirname(d) === d) return false; } };
|
|
51
68
|
return found.filter(gated).sort();
|
|
52
69
|
}
|
|
53
70
|
|
|
@@ -72,12 +89,15 @@ function traceAll(argv, { env, platform, log, err }) {
|
|
|
72
89
|
try {
|
|
73
90
|
for (const ws of workspaces) {
|
|
74
91
|
log(`--- ${ws}`);
|
|
75
|
-
const
|
|
92
|
+
const jobsAt = argv.indexOf("--jobs");
|
|
93
|
+
// The host list is what Class A reads on this host; a repository's committed list is written only on request (--record-repo), so
|
|
94
|
+
// tracing never edits a checkout another owner holds.
|
|
95
|
+
const code = main(["trace", "--manifest-path", path.join(ws, "Cargo.toml"), ...(argv.includes("--record-repo") ? ["--record-repo"] : []), ...(jobsAt >= 0 ? ["--jobs", argv[jobsAt + 1]] : [])], { env, platform, log, err });
|
|
76
96
|
if (code !== 0) err(`right-release build-helpers trace-all: ${ws} ended with ${code}`);
|
|
77
97
|
worst = Math.max(worst, code);
|
|
78
98
|
}
|
|
79
99
|
} finally { try { keepAlive?.kill(); } catch { /* already gone */ } }
|
|
80
|
-
log(`right-release build-helpers trace-all: done; commit each workspace's .rightkit/build-helper-trace.json`);
|
|
100
|
+
log(`right-release build-helpers trace-all: done${argv.includes("--record-repo") ? "; commit each workspace's .rightkit/build-helper-trace.json" : ""}`);
|
|
81
101
|
return worst;
|
|
82
102
|
}
|
|
83
103
|
|
|
@@ -89,7 +109,10 @@ export function main(argv = process.argv.slice(2), { env = process.env, platform
|
|
|
89
109
|
const directPath = directCargoPath({ env: { ...env, PATH: env[pathKey] }, platform, gateWrappers: true }).path;
|
|
90
110
|
const scratchTarget = fs.mkdtempSync(path.join(os.tmpdir(), "rk-bh-target-"));
|
|
91
111
|
const run = runRaw ?? ((cmd, args, opts = {}) => spawnSync(cmd, args, { ...opts, env: { ...env, ...(opts.env ?? {}), [pathKey]: directPath, CARGO_TARGET_DIR: opts.env?.CARGO_TARGET_DIR ?? scratchTarget } }));
|
|
92
|
-
|
|
112
|
+
// The parallel trace builds are spawned by the batch runner with the job's own env: that env carries the direct PATH too, so no build
|
|
113
|
+
// ever goes through the RustRight shim (which refuses a build that redirects its output folder).
|
|
114
|
+
const directEnv = { ...env, [pathKey]: directPath };
|
|
115
|
+
try { return mainWith(argv, { env: directEnv, platform, run, log, err }); } finally { fs.rmSync(scratchTarget, { recursive: true, force: true }); }
|
|
93
116
|
}
|
|
94
117
|
|
|
95
118
|
function mainWith(argv, { env, platform, run, log, err }) {
|
|
@@ -121,62 +144,133 @@ function mainWith(argv, { env, platform, run, log, err }) {
|
|
|
121
144
|
// ETW kernel sessions need an elevated shell; kernel paths are mapped back to drive letters once (QueryDosDevice).
|
|
122
145
|
const admin = run("net", ["session"], { encoding: "utf8" });
|
|
123
146
|
if (admin.status !== 0) { err("right-release build-helpers trace: run from an elevated (Administrator) shell; ETW kernel tracing needs it"); return 2; }
|
|
124
|
-
const map = run("powershell", ["-NoProfile", "-Command", "$s='[DllImport(
|
|
147
|
+
const map = run("powershell", ["-NoProfile", "-Command", "$s='[DllImport(\"kernel32.dll\")] public static extern uint QueryDosDevice(string d, System.Text.StringBuilder b, uint m);'; $k=Add-Type -MemberDefinition $s -Name K -Namespace W -PassThru; foreach($d in [IO.DriveInfo]::GetDrives()){ $l=$d.Name.Substring(0,2); $b=New-Object Text.StringBuilder 512; [void]$k::QueryDosDevice($l,$b,512); \"$l=$b\" }"], { encoding: "utf8" });
|
|
125
148
|
for (const line of String(map.stdout ?? "").split(/\r?\n/)) { const m = /^([A-Z]:)=(\\Device\\\S+)$/.exec(line.trim()); if (m) devices[m[2]] = m[1]; }
|
|
149
|
+
// Without the map every kernel path would be judged outside the allowed folders: stop instead of recording false verdicts.
|
|
150
|
+
if (!Object.keys(devices).length) { err(`right-release build-helpers trace: could not map kernel device paths to drive letters (QueryDosDevice): ${String(map.stderr ?? "").trim().split(/\r?\n/)[0] ?? ""}`); return 2; }
|
|
126
151
|
}
|
|
127
152
|
const recordRepo = rest.includes("--record-repo");
|
|
128
153
|
const cargoHome = env.CARGO_HOME ?? path.join(os.homedir(), ".cargo");
|
|
129
|
-
|
|
154
|
+
// Parallel: `--jobs N` builds at once (default 4). Each build is its own process tree, rooted at its own cargo, in its own scratch
|
|
155
|
+
// target, and is judged only on the writes of that tree (eslogger and ETW events name their process; strace -ff writes one file per
|
|
156
|
+
// process of its own run). The cores are shared out so the builds do not fight over them.
|
|
157
|
+
const jobsAt = rest.indexOf("--jobs");
|
|
158
|
+
const jobs = Math.max(1, Number.parseInt(jobsAt >= 0 ? rest[jobsAt + 1] : "4", 10) || 4);
|
|
159
|
+
const perBuildJobs = String(Math.max(1, Math.floor(os.cpus().length / jobs)));
|
|
160
|
+
const pending = [...verdict.untraced, ...verdict.failed.map((f) => f.unit)];
|
|
161
|
+
log(`right-release build-helpers trace: ${pending.length} unit(s), ${jobs} at a time`);
|
|
162
|
+
if (!pending.length) return 0;
|
|
163
|
+
const prepared = pending.map((unit) => {
|
|
130
164
|
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "rk-bht-"));
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
165
|
+
const target = path.join(scratch, "target");
|
|
166
|
+
// The unit is built on its own: a throwaway probe package in the scratch folder depends on exactly this version with exactly its
|
|
167
|
+
// resolved features (`cargo build -p <crate>@<ver>` panics for a crate the workspace uses only as a build dependency, which most
|
|
168
|
+
// build helpers are). The workspace's Cargo.lock seeds the probe so its other crates resolve to the same versions.
|
|
169
|
+
const probe = path.join(scratch, "probe");
|
|
170
|
+
fs.mkdirSync(path.join(probe, "src"), { recursive: true });
|
|
171
|
+
const features = unit.features.filter((f) => f !== "default");
|
|
172
|
+
fs.writeFileSync(path.join(probe, "Cargo.toml"), `[package]\nname = "rk-build-helper-probe"\nversion = "0.0.0"\nedition = "2021"\npublish = false\n\n[dependencies]\n${JSON.stringify(unit.crate)} = { version = "=${unit.version}", default-features = ${unit.features.includes("default")}, features = ${JSON.stringify(features)} }\n\n[workspace]\n`);
|
|
173
|
+
fs.writeFileSync(path.join(probe, "src", "lib.rs"), "");
|
|
174
|
+
try { fs.copyFileSync(path.join(inv.metadata.workspace_root, "Cargo.lock"), path.join(probe, "Cargo.lock")); } catch { /* the probe resolves on its own */ }
|
|
175
|
+
// Release profile, like every other build (GATE-CONTRACT GC-A15): no dev or debug build anywhere, a throwaway probe included.
|
|
176
|
+
const buildArgs = ["build", "--profile", "release", "--manifest-path", path.join(probe, "Cargo.toml"), "--target-dir", target];
|
|
177
|
+
const buildEnv = { ...env, CARGO_TARGET_DIR: target, CARGO_BUILD_JOBS: perBuildJobs };
|
|
178
|
+
const straceOut = path.join(scratch, "strace");
|
|
179
|
+
const job = platform === "linux"
|
|
180
|
+
? { cmd: "strace", args: ["-ff", "-qq", "-y", "-o", straceOut, "-e", "trace=open,openat,openat2,creat,rename,renameat,renameat2,unlink,unlinkat,mkdir,mkdirat,rmdir,link,linkat,symlink,symlinkat,truncate,chmod,fchmodat,chown,fchownat,lchown,utimensat", "cargo", ...buildArgs], env: buildEnv }
|
|
181
|
+
: { cmd: "cargo", args: buildArgs, env: buildEnv };
|
|
182
|
+
job.label = `${unit.crate} ${unit.version}`;
|
|
183
|
+
return { unit, scratch, target, straceOut, job };
|
|
184
|
+
});
|
|
185
|
+
const sessionDir = fs.mkdtempSync(path.join(os.tmpdir(), "rk-bht-session-"));
|
|
186
|
+
const traceFile = path.join(sessionDir, "trace.txt");
|
|
187
|
+
// One tracer session for the whole run (macOS eslogger, Windows ETW); Linux traces per build through strace.
|
|
188
|
+
let stopTracer = () => {};
|
|
189
|
+
let etl = null;
|
|
190
|
+
if (platform === "darwin") {
|
|
191
|
+
const tracer = run("sh", ["-c", `sudo -n eslogger fork exec create rename unlink link truncate open close clone copyfile exchangedata > '${traceFile}' 2>'${traceFile}.err' & echo $!`], { encoding: "utf8" });
|
|
192
|
+
const tracerPid = String(tracer.stdout ?? "").trim();
|
|
193
|
+
run("sleep", ["2"]);
|
|
194
|
+
// Drain before stopping: eslogger writes a block-buffered file and Endpoint Security delivers late, so the last builds' events were
|
|
195
|
+
// still in flight when a fixed one-second wait ended (every "build was not observed" fail on the Mac was a build that finished last
|
|
196
|
+
// in its session). Marker files are created in the session folder, one a second, until the first appears in the recording: every
|
|
197
|
+
// event before it is then on disk. Two minutes without it leaves the verdicts to judgeTrace, which fails a build it did not see.
|
|
198
|
+
const drain = () => {
|
|
199
|
+
const tag = `rk-bht-drain-${process.pid}-`;
|
|
200
|
+
let from = 0; try { from = fs.statSync(traceFile).size; } catch { /* nothing yet */ }
|
|
201
|
+
for (let i = 0; i < 120; i += 1) {
|
|
202
|
+
fs.writeFileSync(path.join(sessionDir, `${tag}${i}`), "");
|
|
203
|
+
run("sleep", ["1"]);
|
|
204
|
+
let size = 0; try { size = fs.statSync(traceFile).size; } catch { continue; }
|
|
205
|
+
if (size <= from) continue;
|
|
206
|
+
const fd = fs.openSync(traceFile, "r");
|
|
207
|
+
try {
|
|
208
|
+
const chunk = Buffer.alloc(8 << 20);
|
|
209
|
+
for (let at = from; at < size; at += chunk.length - tag.length) {
|
|
210
|
+
const n = fs.readSync(fd, chunk, 0, chunk.length, at);
|
|
211
|
+
if (chunk.subarray(0, n).includes(tag)) return true;
|
|
212
|
+
}
|
|
213
|
+
} finally { fs.closeSync(fd); }
|
|
214
|
+
from = Math.max(from, size - tag.length);
|
|
215
|
+
}
|
|
216
|
+
err("right-release build-helpers trace: eslogger did not record the drain marker within 2 minutes; builds it did not see are judged unobserved");
|
|
217
|
+
return false;
|
|
218
|
+
};
|
|
219
|
+
stopTracer = () => { drain(); if (tracerPid) { run("sudo", ["-n", "pkill", "-INT", "-P", tracerPid]); run("kill", [tracerPid]); run("sleep", ["1"]); } };
|
|
220
|
+
} else if (platform === "win32") {
|
|
221
|
+
// One ETW session with both kernel providers, each with an explicit level (0x5): a separate process-only session without a level
|
|
222
|
+
// recorded nothing on the Dell (proc.etl 0 MB), so no write could be tied to its build. Tested on the Dell: process starts (with
|
|
223
|
+
// ParentProcessID) and file writes both recorded.
|
|
224
|
+
etl = { file: path.join(sessionDir, "trace.etl") };
|
|
225
|
+
const providers = path.join(sessionDir, "providers.txt");
|
|
226
|
+
fs.writeFileSync(providers, '"Microsoft-Windows-Kernel-Process" 0x10 0x5\r\n"Microsoft-Windows-Kernel-File" 0x1E90 0x5\r\n');
|
|
227
|
+
run("logman", ["stop", "rk-bht", "-ets"]);
|
|
228
|
+
const started = run("logman", ["start", "rk-bht", "-pf", providers, "-o", etl.file, "-ets"], { encoding: "utf8" });
|
|
229
|
+
if (started.status !== 0) { err(`right-release build-helpers trace: logman could not start the ETW session: ${String(started.stdout ?? "").trim()}`); return 2; }
|
|
230
|
+
stopTracer = () => { run("logman", ["stop", "rk-bht", "-ets"]); };
|
|
176
231
|
}
|
|
232
|
+
// The runner keeps `jobs` builds running and starts the next whenever one finishes; each build's pid is its tree's root.
|
|
233
|
+
const jobsFile = path.join(sessionDir, "jobs.json"); const resultFile = path.join(sessionDir, "results.json");
|
|
234
|
+
fs.writeFileSync(jobsFile, JSON.stringify(prepared.map((b) => b.job)));
|
|
235
|
+
let runner;
|
|
236
|
+
try { runner = run(process.execPath, ["-e", BATCH_RUNNER, jobsFile, resultFile, String(jobs)], { encoding: "utf8", env, stdio: ["ignore", "inherit", "inherit"] }); }
|
|
237
|
+
finally { stopTracer(); }
|
|
238
|
+
let results = [];
|
|
239
|
+
try { results = JSON.parse(fs.readFileSync(resultFile, "utf8")); } catch { err(`right-release build-helpers trace: the build runner wrote no results (${runner?.error?.message ?? `exit ${runner?.status}`}); nothing recorded`); return 2; }
|
|
240
|
+
// The whole-run recording is streamed, never read into one string, and attributed to every build's tree in one pass.
|
|
241
|
+
const roots = new Set(results.filter((r) => r?.status === 0 && Number.isInteger(r.pid)).map((r) => r.pid));
|
|
242
|
+
let byRoot = new Map(); let recorded = false;
|
|
243
|
+
if (platform === "darwin") { try { recorded = fs.statSync(traceFile).size > 0; if (recorded) byRoot = esloggerWritesByRoot(traceFile, roots); } catch { /* no output */ } }
|
|
244
|
+
if (platform === "win32") {
|
|
245
|
+
const xml = path.join(sessionDir, "trace.xml");
|
|
246
|
+
const exported = run("tracerpt", [etl.file, "-o", xml, "-of", "XML", "-y"], { encoding: "utf8" });
|
|
247
|
+
if (exported.status !== 0) { err("right-release build-helpers trace: tracerpt could not export the ETW trace"); return 2; }
|
|
248
|
+
try { recorded = fs.statSync(xml).size > 0; if (recorded) byRoot = etwWritesByRoot(xml, roots, devices); } catch { /* no output */ }
|
|
249
|
+
}
|
|
250
|
+
if (platform !== "linux" && !recorded && roots.size) { err(`right-release build-helpers trace: the ${platform === "darwin" ? "eslogger" : "ETW"} session recorded nothing; nothing recorded`); return 2; }
|
|
251
|
+
prepared.forEach((b, k) => {
|
|
252
|
+
const r = results[k] ?? {};
|
|
253
|
+
if (r.status !== 0 || !Number.isInteger(r.pid)) { err(`right-release build-helpers trace: ${b.unit.crate} ${b.unit.version} did not build (${r.error ?? `exit ${r.status}`}); not recorded`); return; }
|
|
254
|
+
let writes;
|
|
255
|
+
if (platform === "linux") { let text = ""; try { text = readStraceFiles(b.straceOut); } catch { /* none */ } writes = parseStraceWrites(text, { cwd: path.dirname(path.resolve(manifestPath ?? "Cargo.toml")) }); }
|
|
256
|
+
else writes = byRoot.get(r.pid) ?? [];
|
|
257
|
+
const allowed = platform === "win32" ? [b.scratch, cargoHome, os.tmpdir(), env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, "Temp") : null]
|
|
258
|
+
: [b.scratch, cargoHome, os.tmpdir(), "/private/var/folders", "/dev", "/proc/self"];
|
|
259
|
+
// Allowed folders by their real path: a linked CARGO_HOME (~/.cargo -> /Volumes/D/...) or temp (/var -> /private/var) is reported by
|
|
260
|
+
// the tracer under the real path, so comparing the link's path would fail every crate.
|
|
261
|
+
const real = (d) => { try { return fs.realpathSync.native(d); } catch { return d; } };
|
|
262
|
+
const roots = allowed.filter(Boolean).flatMap((d) => [d, real(d)]);
|
|
263
|
+
const judged = judgeTrace(writes, { allowed: roots, platform, mustSee: real(b.target) });
|
|
264
|
+
recordTrace(listFile, b.unit, judged);
|
|
265
|
+
// --record-repo also writes the verdict into the repository's committed list, for review and commit by the repository's owner.
|
|
266
|
+
if (recordRepo) recordTrace(repoFile, b.unit, judged);
|
|
267
|
+
log(`${judged.ok ? "pass" : "FAIL"} ${b.unit.crate} ${b.unit.version}${judged.ok ? "" : `: wrote ${judged.outside.slice(0, 5).join(", ")}`}`);
|
|
268
|
+
});
|
|
269
|
+
for (const b of prepared) fs.rmSync(b.scratch, { recursive: true, force: true });
|
|
270
|
+
fs.rmSync(sessionDir, { recursive: true, force: true });
|
|
177
271
|
return 0;
|
|
178
272
|
}
|
|
179
|
-
err("usage: right-release build-helpers check|trace [--manifest-path <Cargo.toml>] [--record-repo] | trace-all [--root <dir>]...");
|
|
273
|
+
err("usage: right-release build-helpers check|trace [--manifest-path <Cargo.toml>] [--record-repo] [--jobs N] | trace-all [--root <dir>]... [--jobs N]");
|
|
180
274
|
return 2;
|
|
181
275
|
}
|
|
182
276
|
|
package/deterministic-scans.mjs
CHANGED
|
@@ -1071,7 +1071,9 @@ export function planScans({
|
|
|
1071
1071
|
const wsOf = (n) => (rustMap ? rustMap.get(n).cargoDir : cargoWs);
|
|
1072
1072
|
const crateOf = (n) => (rustMap?.get(n)?.crate ?? n);
|
|
1073
1073
|
const featuresOf = (n) => crateFeatures?.[n] ?? crateFeatures?.[crateOf(n)] ?? {};
|
|
1074
|
-
|
|
1074
|
+
// With no Rust unit in play the default workspace counts only when it has a Cargo.toml: a root rust-toolchain.toml (or other Rust config)
|
|
1075
|
+
// in a crate-per-workspace repository must not turn the repository root into a phantom workspace whose cargo scans have no manifest.
|
|
1076
|
+
const workspaces = rustMap && rustMap.size ? [...new Set([...rustMap.values()].map((u) => u.cargoDir))].sort() : (exists(path.join(contentRoot, cargoWs === "." ? "Cargo.toml" : `${cargoWs}/Cargo.toml`)) ? [cargoWs] : []);
|
|
1075
1077
|
const lockOf = (ws) => (rustLocks?.[ws] instanceof Map ? rustLocks[ws] : ws === cargoWs && rustLock instanceof Map ? rustLock : null);
|
|
1076
1078
|
const rustDir = (n) => (rustMap ? rustMap.get(n).dir : `crates/${n}`);
|
|
1077
1079
|
const npmDir = (n) => (npmMap ? npmMap.get(n).dir : `packages/${n}`);
|
package/package.json
CHANGED
package/pipeline-receipts.mjs
CHANGED
|
@@ -1,302 +1,302 @@
|
|
|
1
|
-
// Pipeline stage receipts and their verifier (architecture-v3 sections 4-5, "Receipts").
|
|
2
|
-
//
|
|
3
|
-
// A receipt records a fact about one stage of one request. It never authorizes execution alone: the
|
|
4
|
-
// broker (RustRight) signs receipts and issues short-lived capabilities; this module only checks that
|
|
5
|
-
// a chain of receipts is well formed, linked, fresh and complete. Signature checking is an injected
|
|
6
|
-
// interface (`verifySignature`); this module does no cryptography beyond SHA-256 hashing and invents none.
|
|
7
|
-
//
|
|
8
|
-
// Chain: ADMISSION -> SCAN_A -> ROUTE -> SCAN_B -> BUILD -> SIGN -> QUALIFIED -> PUBLISH_INTENT -> PUBLISHED.
|
|
9
|
-
// Each stage requires exactly its predecessor; `parents` carries that predecessor's receipt hash.
|
|
10
|
-
import crypto from "node:crypto";
|
|
11
|
-
import { canonicalJson, digestScanSet, scanClassOf } from "./deterministic-scans.mjs";
|
|
12
|
-
|
|
13
|
-
export const RECEIPT_SCHEMA = "rightkit.pipeline-receipt/1";
|
|
14
|
-
export const STAGES = Object.freeze(["ADMISSION", "SCAN_A", "ROUTE", "SCAN_B", "BUILD", "SIGN", "QUALIFIED", "PUBLISH_INTENT", "PUBLISHED"]);
|
|
15
|
-
export const OPERATIONS = Object.freeze(["dev", "build", "qualify", "sign", "publish"]);
|
|
16
|
-
/** The last stage each operation may reach. */
|
|
17
|
-
export const OPERATION_LAST_STAGE = Object.freeze({ dev: "BUILD", build: "BUILD", sign: "SIGN", qualify: "QUALIFIED", publish: "PUBLISHED" });
|
|
18
|
-
/** Each stage requires only these predecessors. */
|
|
19
|
-
export const PREREQUISITES = Object.freeze({
|
|
20
|
-
ADMISSION: [], SCAN_A: ["ADMISSION"], ROUTE: ["SCAN_A"], SCAN_B: ["ROUTE"], BUILD: ["SCAN_B"],
|
|
21
|
-
SIGN: ["BUILD"], QUALIFIED: ["SIGN"], PUBLISH_INTENT: ["QUALIFIED"], PUBLISHED: ["PUBLISH_INTENT"],
|
|
22
|
-
});
|
|
23
|
-
const RELEASE_ID_STAGES = new Set(["SIGN", "QUALIFIED", "PUBLISH_INTENT", "PUBLISHED"]);
|
|
24
|
-
/** Stages only a publishable request may reach: signing and everything after it. A dev or non-publishable chain stops at BUILD. */
|
|
25
|
-
const PUBLISHABLE_ONLY_STAGES = new Set(RELEASE_ID_STAGES);
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* What ADMISSION's evidence allows for `stage`, as [{ code, message }]. ADMISSION is the sole authority:
|
|
29
|
-
* a missing or malformed ADMISSION authorizes nothing, and `publishable` must be exactly true to reach SIGN or later.
|
|
30
|
-
*/
|
|
31
|
-
function admissionProblems(admission, stage) {
|
|
32
|
-
const out = [];
|
|
33
|
-
if (!admission) return [{ code: "STAGE_NOT_AUTHORIZED", message: `no valid ADMISSION evidence authorizes ${stage}` }];
|
|
34
|
-
if (!Array.isArray(admission.authorizedStages) || !admission.authorizedStages.includes(stage)) out.push({ code: "STAGE_NOT_AUTHORIZED", message: `ADMISSION does not authorize ${stage}` });
|
|
35
|
-
if (admission.publishable !== true && PUBLISHABLE_ONLY_STAGES.has(stage)) out.push({ code: "NOT_PUBLISHABLE", message: `ADMISSION does not mark this request publishable:true; it cannot reach ${stage}` });
|
|
36
|
-
return out;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const ENVELOPE_STRINGS = ["receiptId", "jobId", "requestId", "repositoryId", "commit", "inputDigest", "configDigest", "policyDigest", "stagePlanDigest", "runtimeDigest", "executor", "issuedAt", "expiresAt", "keyId", "signature"];
|
|
40
|
-
const HEX64 = /^[0-9a-f]{64}$/;
|
|
41
|
-
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
|
42
|
-
|
|
43
|
-
/** Domain-separated bytes a signature covers: receipt type bound in, signature field excluded. */
|
|
44
|
-
export function signingBytes(receipt) {
|
|
45
|
-
const { signature, ...rest } = receipt;
|
|
46
|
-
return Buffer.from(`${RECEIPT_SCHEMA}\0${receipt.type}\0${canonicalJson(rest)}`, "utf8");
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** The hash a child receipt records in `parents`: covers the whole receipt including its signature. */
|
|
50
|
-
export function receiptHash(receipt) {
|
|
51
|
-
return sha256(Buffer.concat([Buffer.from(`${RECEIPT_SCHEMA}.hash\0`, "utf8"), Buffer.from(canonicalJson(receipt), "utf8")]));
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** In-memory request ledger for tests and single-process brokers: records which job owns a requestId. */
|
|
55
|
-
export function createMemoryLedger() {
|
|
56
|
-
const owners = new Map(); const consumed = new Set();
|
|
57
|
-
return {
|
|
58
|
-
requestOwner: (requestId) => owners.get(requestId),
|
|
59
|
-
isConsumed: (receiptId) => consumed.has(receiptId),
|
|
60
|
-
record(chain) { for (const r of chain) { if (!owners.has(r.requestId)) owners.set(r.requestId, r.jobId); consumed.add(r.receiptId); } },
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
65
|
-
const nonEmptyString = (v) => typeof v === "string" && v.trim() !== "";
|
|
66
|
-
const isHex = (v) => typeof v === "string" && HEX64.test(v);
|
|
67
|
-
const nonEmptyArray = (v) => Array.isArray(v) && v.length > 0;
|
|
68
|
-
|
|
69
|
-
/** Stage-specific evidence checks. Each returns a list of problem messages. */
|
|
70
|
-
const STAGE_EVIDENCE = {
|
|
71
|
-
ADMISSION(e) {
|
|
72
|
-
const p = [];
|
|
73
|
-
for (const f of ["inputManifestDigest", "dependencyManifestDigest", "toolchainManifestDigest"]) if (!isHex(e[f])) p.push(`${f} must be a sha256 hex digest`);
|
|
74
|
-
if (!nonEmptyArray(e.authorizedStages) || e.authorizedStages.some((s) => !STAGES.includes(s))) p.push("authorizedStages must list known stages");
|
|
75
|
-
if (typeof e.publishable !== "boolean") p.push("publishable must be a boolean");
|
|
76
|
-
return p;
|
|
77
|
-
},
|
|
78
|
-
SCAN_A: (e, ctx) => scanEvidence(e, "A", ctx),
|
|
79
|
-
ROUTE(e, ctx) {
|
|
80
|
-
const p = [];
|
|
81
|
-
const d = e.decision;
|
|
82
|
-
if (!isObj(d)) return ["decision is required"];
|
|
83
|
-
if (!["local", "ci"].includes(d.executor)) p.push("decision.executor must be local or ci");
|
|
84
|
-
if (!["public", "private"].includes(d.visibility)) p.push("decision.visibility must be public or private (unknown visibility is rejected)");
|
|
85
|
-
if (d.visibility === "private" && d.executor !== "local") p.push("a private repository never routes to CI");
|
|
86
|
-
if (typeof d.localGrant !== "boolean") p.push("decision.localGrant must be a boolean");
|
|
87
|
-
if (d.executor === "local" && !nonEmptyString(String(e.leaseFence ?? ""))) p.push("a local route needs a leaseFence");
|
|
88
|
-
if (d.executor === "ci" && !isObj(e.dispatchBinding)) p.push("a CI route needs a dispatchBinding");
|
|
89
|
-
if (!nonEmptyString(d.policyVersion)) p.push("decision.policyVersion is required");
|
|
90
|
-
return p;
|
|
91
|
-
},
|
|
92
|
-
SCAN_B: (e, ctx) => scanEvidence(e, "B", ctx),
|
|
93
|
-
BUILD(e) {
|
|
94
|
-
const p = [];
|
|
95
|
-
if (!isHex(e.artifactManifestDigest)) p.push("artifactManifestDigest must be a sha256 hex digest");
|
|
96
|
-
if (!nonEmptyArray(e.artifacts) || e.artifacts.some((a) => !nonEmptyString(a?.path) || !isHex(a?.sha256))) p.push("artifacts must list path and sha256 for every output");
|
|
97
|
-
for (const f of ["generationEvidenceDigest", "provenanceDigest"]) if (!isHex(e[f])) p.push(`${f} must be a sha256 hex digest`);
|
|
98
|
-
if (!nonEmptyArray(e.targetMatrix)) p.push("targetMatrix must list the built targets");
|
|
99
|
-
return p;
|
|
100
|
-
},
|
|
101
|
-
SIGN(e, ctx) {
|
|
102
|
-
const p = [];
|
|
103
|
-
if (!["SIGN", "SEAL"].includes(e.mode)) p.push("mode must be SIGN or SEAL");
|
|
104
|
-
for (const f of ["inputManifestDigest", "outputManifestDigest", "signingEvidenceDigest"]) if (!isHex(e[f])) p.push(`${f} must be a sha256 hex digest`);
|
|
105
|
-
if (!nonEmptyString(e.signingProfile)) p.push("signingProfile is required");
|
|
106
|
-
const build = ctx.byStage.BUILD;
|
|
107
|
-
if (build && e.inputManifestDigest !== build.evidence?.artifactManifestDigest) p.push("inputManifestDigest does not equal the BUILD artifactManifestDigest");
|
|
108
|
-
return p;
|
|
109
|
-
},
|
|
110
|
-
QUALIFIED(e, ctx) {
|
|
111
|
-
const p = [];
|
|
112
|
-
if (!isHex(e.artifactManifestDigest)) p.push("artifactManifestDigest must be a sha256 hex digest");
|
|
113
|
-
if (!nonEmptyArray(e.testMatrix) || e.testMatrix.some((t) => !nonEmptyString(t?.name) || t?.status !== "pass")) p.push("testMatrix must be non-empty and every row must pass");
|
|
114
|
-
const sign = ctx.byStage.SIGN;
|
|
115
|
-
if (sign && e.artifactManifestDigest !== sign.evidence?.outputManifestDigest) p.push("artifactManifestDigest does not equal the SIGN outputManifestDigest (qualification covers the final signed bytes)");
|
|
116
|
-
return p;
|
|
117
|
-
},
|
|
118
|
-
PUBLISH_INTENT(e, ctx) {
|
|
119
|
-
const p = [];
|
|
120
|
-
if (!isHex(e.releaseManifestDigest)) p.push("releaseManifestDigest must be a sha256 hex digest");
|
|
121
|
-
if (!nonEmptyArray(e.destinations) || e.destinations.some((d) => !nonEmptyString(d?.id) || !nonEmptyString(d?.identity) || !nonEmptyString(d?.version) || !isHex(d?.expectedDigest) || !nonEmptyString(d?.idempotencyKey))) {
|
|
122
|
-
p.push("destinations must name id, identity, version, expectedDigest and idempotencyKey");
|
|
123
|
-
}
|
|
124
|
-
const q = ctx.byStage.QUALIFIED;
|
|
125
|
-
if (q && e.releaseManifestDigest !== q.evidence?.artifactManifestDigest) p.push("releaseManifestDigest does not equal the QUALIFIED artifactManifestDigest");
|
|
126
|
-
return p;
|
|
127
|
-
},
|
|
128
|
-
PUBLISHED(e, ctx) {
|
|
129
|
-
const p = [];
|
|
130
|
-
const intent = ctx.byStage.PUBLISH_INTENT;
|
|
131
|
-
if (!nonEmptyArray(e.entries)) return ["entries must list every destination outcome"];
|
|
132
|
-
for (const d of intent?.evidence?.destinations ?? []) {
|
|
133
|
-
const entry = e.entries.find((x) => x?.destinationId === d.id);
|
|
134
|
-
if (!entry) p.push(`no entry for destination ${d.id}`);
|
|
135
|
-
else if (entry.outcome !== "verified") p.push(`destination ${d.id} is ${entry.outcome}, not verified`);
|
|
136
|
-
else if (entry.remoteDigest !== d.expectedDigest) p.push(`destination ${d.id} readback digest differs from the intent`);
|
|
137
|
-
}
|
|
138
|
-
return p;
|
|
139
|
-
},
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
function scanEvidence(e, cls, ctx) {
|
|
143
|
-
const p = [];
|
|
144
|
-
const other = cls === "A" ? "B" : "A";
|
|
145
|
-
if (e.scanClass !== cls) p.push(`scanClass must be ${cls}`);
|
|
146
|
-
if (!nonEmptyArray(e.scans)) return [...p, "scans must list every required scan result"];
|
|
147
|
-
if (cls === "A" && !isHex(e.advisorySnapshotDigest)) p.push("advisorySnapshotDigest must be a sha256 hex digest");
|
|
148
|
-
if (cls === "A" && !nonEmptyString(e.advisorySnapshotTime)) p.push("advisorySnapshotTime is required");
|
|
149
|
-
for (const s of e.scans) {
|
|
150
|
-
const derived = scanClassOf(s?.id);
|
|
151
|
-
if (s?.class === other || derived === other) p.push(`${cls === "A" ? "SCAN_A claims" : "SCAN_B carries"} class ${other} scan ${s?.id}`);
|
|
152
|
-
else if (s?.class !== cls) p.push(`scan ${s?.id} has class ${s?.class}, expected ${cls}`);
|
|
153
|
-
if (s?.status !== "pass") p.push(`scan ${s?.id} is ${s?.status}, not pass`);
|
|
154
|
-
}
|
|
155
|
-
const ids = new Set(e.scans.map((s) => s?.id));
|
|
156
|
-
const required = ctx.requiredScans?.[cls];
|
|
157
|
-
const declared = Array.isArray(e.requiredScanIds) ? e.requiredScanIds : null;
|
|
158
|
-
if (!declared) p.push("requiredScanIds is required");
|
|
159
|
-
// The required set comes only from the injected policy matrix. A receipt's own declaration is never trusted:
|
|
160
|
-
// without the policy, verification fails closed.
|
|
161
|
-
if (!Array.isArray(required) || required.some((id) => typeof id !== "string")) {
|
|
162
|
-
p.push(`trusted required scan set for class ${cls} is not supplied by policy; verification fails closed`);
|
|
163
|
-
return p;
|
|
164
|
-
}
|
|
165
|
-
for (const id of required) if (!ids.has(id)) p.push(`required class ${cls} scan ${id} is missing`);
|
|
166
|
-
if (declared && canonicalJson([...required].sort()) !== canonicalJson([...declared].sort())) p.push(`requiredScanIds differ from the trusted class ${cls} set`);
|
|
167
|
-
if (e.scanSetDigest !== digestScanSet(e.scans)) p.push("scanSetDigest does not match the listed scans");
|
|
168
|
-
if (cls === "B" && !nonEmptyArray(required)) p.push("SCAN_B must require at least one class B scan");
|
|
169
|
-
return p;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Verify a chain of receipts (ADMISSION first, ending at the stage being authorized).
|
|
174
|
-
*
|
|
175
|
-
* options:
|
|
176
|
-
* verifySignature({ receipt, keyId, signingBytes, signature }) -> boolean REQUIRED (the broker supplies keys)
|
|
177
|
-
* now ISO string or Date, default current time
|
|
178
|
-
* expected { requestId, operation, inputDigest, stage, repositoryId, commit, jobId, releaseId, targetSet } (each optional but checked when given)
|
|
179
|
-
* requiredScans { A: [ids], B: [ids] } trusted required sets from the policy matrix: REQUIRED; absence rejects (NO_SCAN_POLICY)
|
|
180
|
-
* ledger { requestOwner(requestId), isConsumed(receiptId) } REQUIRED: without it verification fails (NO_LEDGER)
|
|
181
|
-
* keyAllowed (keyId, receipt) -> boolean, for revocations
|
|
182
|
-
* Returns { ok, problems: [{ code, stage, message }], head }. Never throws on a bad receipt.
|
|
183
|
-
*/
|
|
184
|
-
export function verifyReceiptChain(chain, options = {}) {
|
|
185
|
-
const problems = [];
|
|
186
|
-
const bad = (code, stage, message) => problems.push({ code, stage: stage ?? null, message });
|
|
187
|
-
if (typeof options.verifySignature !== "function") return { ok: false, problems: [{ code: "NO_SIGNATURE_VERIFIER", stage: null, message: "a signature verifier must be supplied by the broker" }], head: null };
|
|
188
|
-
if (!Array.isArray(chain) || chain.length === 0) return { ok: false, problems: [{ code: "EMPTY_CHAIN", stage: null, message: "no receipts" }], head: null };
|
|
189
|
-
const now = new Date(options.now ?? Date.now()).getTime();
|
|
190
|
-
const expected = options.expected ?? {};
|
|
191
|
-
const byStage = {};
|
|
192
|
-
let previous = null;
|
|
193
|
-
// Replay protection is mandatory: without a ledger the chain cannot be proven fresh, so it fails closed.
|
|
194
|
-
if (typeof options.ledger?.requestOwner !== "function" || typeof options.ledger?.isConsumed !== "function") {
|
|
195
|
-
bad("NO_LEDGER", null, "a request ledger with requestOwner and isConsumed must be supplied; replay cannot be ruled out without it");
|
|
196
|
-
}
|
|
197
|
-
// One release id for the whole chain: the ADMISSION one when it has it, else the first release-stage one.
|
|
198
|
-
const releaseRef = chain.find((r) => isObj(r) && nonEmptyString(r.releaseId))?.releaseId;
|
|
199
|
-
// ADMISSION fixes which stages may run and whether the request may publish at all.
|
|
200
|
-
const admission = isObj(chain[0]) && chain[0].type === "ADMISSION" && isObj(chain[0].evidence) ? chain[0].evidence : null;
|
|
201
|
-
|
|
202
|
-
chain.forEach((receipt, index) => {
|
|
203
|
-
const stage = isObj(receipt) ? receipt.type : null;
|
|
204
|
-
if (!isObj(receipt)) { bad("MALFORMED", null, `receipt ${index} is not an object`); return; }
|
|
205
|
-
if (receipt.schema !== RECEIPT_SCHEMA) bad("SCHEMA", stage, `schema must be ${RECEIPT_SCHEMA}`);
|
|
206
|
-
if (!STAGES.includes(stage)) { bad("UNKNOWN_STAGE", stage, `unknown receipt type ${JSON.stringify(stage)}`); return; }
|
|
207
|
-
const wantStage = STAGES[index];
|
|
208
|
-
if (stage !== wantStage) bad("WRONG_STAGE", stage, `position ${index} requires ${wantStage}, got ${stage}`);
|
|
209
|
-
for (const f of ENVELOPE_STRINGS) if (!nonEmptyString(receipt[f])) bad("ENVELOPE", stage, `${f} is required`);
|
|
210
|
-
if (!Number.isInteger(receipt.attempt) || receipt.attempt < 1) bad("ENVELOPE", stage, "attempt must be an integer >= 1");
|
|
211
|
-
if (!Array.isArray(receipt.targetSet)) bad("ENVELOPE", stage, "targetSet must be an array");
|
|
212
|
-
if (!OPERATIONS.includes(receipt.operation)) bad("WRONG_OPERATION", stage, `unknown operation ${JSON.stringify(receipt.operation)}`);
|
|
213
|
-
if (RELEASE_ID_STAGES.has(stage) && !nonEmptyString(receipt.releaseId)) bad("ENVELOPE", stage, "releaseId is required");
|
|
214
|
-
if (!isObj(receipt.evidence)) bad("EVIDENCE", stage, "evidence is required");
|
|
215
|
-
|
|
216
|
-
// The operation bounds how far a request may go.
|
|
217
|
-
const last = OPERATION_LAST_STAGE[receipt.operation];
|
|
218
|
-
if (last && STAGES.indexOf(stage) > STAGES.indexOf(last)) bad("WRONG_OPERATION", stage, `operation ${receipt.operation} does not authorize ${stage}`);
|
|
219
|
-
|
|
220
|
-
// ADMISSION bounds the chain: only its authorized stages, and nothing from SIGN on unless publishable:true.
|
|
221
|
-
for (const x of admissionProblems(admission, stage)) bad(x.code, stage, x.message);
|
|
222
|
-
|
|
223
|
-
// Binding to the caller's expectations.
|
|
224
|
-
if (expected.requestId !== undefined && receipt.requestId !== expected.requestId) bad("WRONG_REQUEST_ID", stage, "requestId differs from the expected request");
|
|
225
|
-
if (expected.operation !== undefined && receipt.operation !== expected.operation) bad("WRONG_OPERATION", stage, `operation ${receipt.operation} differs from expected ${expected.operation}`);
|
|
226
|
-
if (expected.inputDigest !== undefined && receipt.inputDigest !== expected.inputDigest) bad("WRONG_INPUT_DIGEST", stage, "inputDigest differs from the expected input");
|
|
227
|
-
for (const f of ["repositoryId", "commit", "jobId", "releaseId"]) if (expected[f] !== undefined && receipt[f] !== expected[f]) bad("BINDING", stage, `${f} differs from the expected value`);
|
|
228
|
-
if (expected.targetSet !== undefined && canonicalJson(receipt.targetSet ?? null) !== canonicalJson(expected.targetSet)) bad("BINDING", stage, "targetSet differs from the expected value");
|
|
229
|
-
if (releaseRef !== undefined && receipt.releaseId !== undefined && receipt.releaseId !== releaseRef) bad("BINDING", stage, "releaseId differs from the chain's release");
|
|
230
|
-
|
|
231
|
-
// Consistency across the chain.
|
|
232
|
-
const first = chain[0];
|
|
233
|
-
if (index > 0 && isObj(first)) {
|
|
234
|
-
for (const f of ["requestId", "jobId", "repositoryId", "commit", "inputDigest", "configDigest", "policyDigest", "stagePlanDigest", "operation", "targetSet"]) {
|
|
235
|
-
if (f === "targetSet" ? canonicalJson(receipt[f] ?? null) !== canonicalJson(first[f] ?? null) : receipt[f] !== first[f]) bad(f === "requestId" ? "WRONG_REQUEST_ID" : f === "inputDigest" ? "WRONG_INPUT_DIGEST" : f === "operation" ? "WRONG_OPERATION" : "BINDING", stage, `${f} differs from the ADMISSION receipt`);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Freshness.
|
|
240
|
-
const issued = Date.parse(receipt.issuedAt); const expires = Date.parse(receipt.expiresAt);
|
|
241
|
-
if (Number.isNaN(issued) || Number.isNaN(expires)) bad("ENVELOPE", stage, "issuedAt and expiresAt must be ISO timestamps");
|
|
242
|
-
else {
|
|
243
|
-
if (expires <= issued) bad("ENVELOPE", stage, "expiresAt must be after issuedAt");
|
|
244
|
-
if (expires <= now) bad("EXPIRED", stage, `expired at ${receipt.expiresAt}`);
|
|
245
|
-
if (issued > now + 60_000) bad("ENVELOPE", stage, "issuedAt is in the future");
|
|
246
|
-
if (previous && issued < Date.parse(previous.issuedAt)) bad("ORDER", stage, "issued before its parent receipt");
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Hash chain.
|
|
250
|
-
const wantParents = previous ? [receiptHash(previous)] : [];
|
|
251
|
-
if (!Array.isArray(receipt.parents) || canonicalJson(receipt.parents) !== canonicalJson(wantParents)) bad("BROKEN_CHAIN", stage, previous ? `parents must be exactly [hash of ${previous.type}]` : "ADMISSION has no parents");
|
|
252
|
-
|
|
253
|
-
// Replay (the ledger is required; see NO_LEDGER above).
|
|
254
|
-
if (typeof options.ledger?.requestOwner === "function" && typeof options.ledger?.isConsumed === "function") {
|
|
255
|
-
const owner = options.ledger.requestOwner(receipt.requestId);
|
|
256
|
-
if (owner !== undefined && owner !== receipt.jobId) bad("REPLAYED_REQUEST_ID", stage, "requestId already belongs to another job");
|
|
257
|
-
if (options.ledger.isConsumed(receipt.receiptId)) bad("REPLAYED_RECEIPT", stage, "receipt already consumed");
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Signature (injected).
|
|
261
|
-
if (options.keyAllowed && !options.keyAllowed(receipt.keyId, receipt)) bad("KEY_REVOKED", stage, `key ${receipt.keyId} is not allowed for this receipt`);
|
|
262
|
-
let signatureOk = false;
|
|
263
|
-
try { signatureOk = options.verifySignature({ receipt, keyId: receipt.keyId, signingBytes: signingBytes(receipt), signature: receipt.signature }) === true; } catch { signatureOk = false; }
|
|
264
|
-
if (!signatureOk) bad("BAD_SIGNATURE", stage, "signature did not verify");
|
|
265
|
-
|
|
266
|
-
byStage[stage] = receipt;
|
|
267
|
-
// Stage evidence last: it can look at the earlier stages collected so far.
|
|
268
|
-
if (isObj(receipt.evidence) && STAGE_EVIDENCE[stage]) {
|
|
269
|
-
for (const message of STAGE_EVIDENCE[stage](receipt.evidence, { byStage, requiredScans: options.requiredScans })) {
|
|
270
|
-
const code = /trusted required scan set/.test(message) ? "NO_SCAN_POLICY" : /missing|claims|carries/.test(message) && (stage === "SCAN_A" || stage === "SCAN_B") ? (/claims|carries/.test(message) ? "SCAN_CLASS_MIX" : "SCAN_INCOMPLETE") : "EVIDENCE";
|
|
271
|
-
bad(code, stage, message);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
previous = receipt;
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
const head = chain[chain.length - 1];
|
|
278
|
-
if (expected.stage !== undefined) {
|
|
279
|
-
if (!STAGES.includes(expected.stage)) bad("WRONG_STAGE", null, `unknown expected stage ${expected.stage}`);
|
|
280
|
-
else if (head?.type !== expected.stage) bad("WRONG_STAGE", head?.type, `chain ends at ${head?.type}, expected ${expected.stage}`);
|
|
281
|
-
}
|
|
282
|
-
return { ok: problems.length === 0, problems, head: isObj(head) ? { type: head.type, hash: receiptHash(head) } : null };
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Verify that a new stage may start: the chain so far must verify and end at the stage `stage` requires.
|
|
287
|
-
* Returns the same result shape; `authorizes` is the stage the chain authorizes next (or null).
|
|
288
|
-
*/
|
|
289
|
-
export function authorizeNextStage(chain, stage, options = {}) {
|
|
290
|
-
const need = PREREQUISITES[stage];
|
|
291
|
-
if (!need) return { ok: false, problems: [{ code: "UNKNOWN_STAGE", stage, message: `unknown stage ${stage}` }], head: null, authorizes: null };
|
|
292
|
-
if (need.length === 0) return { ok: !chain?.length, problems: chain?.length ? [{ code: "WRONG_STAGE", stage, message: "ADMISSION starts a chain" }] : [], head: null, authorizes: chain?.length ? null : stage };
|
|
293
|
-
const result = verifyReceiptChain(chain, { ...options, expected: { ...(options.expected ?? {}), stage: need[0] } });
|
|
294
|
-
// The requested stage itself must sit inside the operation's boundary and ADMISSION's authorized stages.
|
|
295
|
-
const first = Array.isArray(chain) ? chain[0] : null;
|
|
296
|
-
const last = OPERATION_LAST_STAGE[first?.operation];
|
|
297
|
-
if (!last || STAGES.indexOf(stage) > STAGES.indexOf(last)) result.problems.push({ code: "WRONG_OPERATION", stage, message: `operation ${first?.operation} does not authorize ${stage}` });
|
|
298
|
-
const admission = first?.type === "ADMISSION" && isObj(first.evidence) ? first.evidence : null;
|
|
299
|
-
for (const x of admissionProblems(admission, stage)) result.problems.push({ code: x.code, stage, message: x.message });
|
|
300
|
-
result.ok = result.problems.length === 0;
|
|
301
|
-
return { ...result, authorizes: result.ok ? stage : null };
|
|
302
|
-
}
|
|
1
|
+
// Pipeline stage receipts and their verifier (architecture-v3 sections 4-5, "Receipts").
|
|
2
|
+
//
|
|
3
|
+
// A receipt records a fact about one stage of one request. It never authorizes execution alone: the
|
|
4
|
+
// broker (RustRight) signs receipts and issues short-lived capabilities; this module only checks that
|
|
5
|
+
// a chain of receipts is well formed, linked, fresh and complete. Signature checking is an injected
|
|
6
|
+
// interface (`verifySignature`); this module does no cryptography beyond SHA-256 hashing and invents none.
|
|
7
|
+
//
|
|
8
|
+
// Chain: ADMISSION -> SCAN_A -> ROUTE -> SCAN_B -> BUILD -> SIGN -> QUALIFIED -> PUBLISH_INTENT -> PUBLISHED.
|
|
9
|
+
// Each stage requires exactly its predecessor; `parents` carries that predecessor's receipt hash.
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
import { canonicalJson, digestScanSet, scanClassOf } from "./deterministic-scans.mjs";
|
|
12
|
+
|
|
13
|
+
export const RECEIPT_SCHEMA = "rightkit.pipeline-receipt/1";
|
|
14
|
+
export const STAGES = Object.freeze(["ADMISSION", "SCAN_A", "ROUTE", "SCAN_B", "BUILD", "SIGN", "QUALIFIED", "PUBLISH_INTENT", "PUBLISHED"]);
|
|
15
|
+
export const OPERATIONS = Object.freeze(["dev", "build", "qualify", "sign", "publish"]);
|
|
16
|
+
/** The last stage each operation may reach. */
|
|
17
|
+
export const OPERATION_LAST_STAGE = Object.freeze({ dev: "BUILD", build: "BUILD", sign: "SIGN", qualify: "QUALIFIED", publish: "PUBLISHED" });
|
|
18
|
+
/** Each stage requires only these predecessors. */
|
|
19
|
+
export const PREREQUISITES = Object.freeze({
|
|
20
|
+
ADMISSION: [], SCAN_A: ["ADMISSION"], ROUTE: ["SCAN_A"], SCAN_B: ["ROUTE"], BUILD: ["SCAN_B"],
|
|
21
|
+
SIGN: ["BUILD"], QUALIFIED: ["SIGN"], PUBLISH_INTENT: ["QUALIFIED"], PUBLISHED: ["PUBLISH_INTENT"],
|
|
22
|
+
});
|
|
23
|
+
const RELEASE_ID_STAGES = new Set(["SIGN", "QUALIFIED", "PUBLISH_INTENT", "PUBLISHED"]);
|
|
24
|
+
/** Stages only a publishable request may reach: signing and everything after it. A dev or non-publishable chain stops at BUILD. */
|
|
25
|
+
const PUBLISHABLE_ONLY_STAGES = new Set(RELEASE_ID_STAGES);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What ADMISSION's evidence allows for `stage`, as [{ code, message }]. ADMISSION is the sole authority:
|
|
29
|
+
* a missing or malformed ADMISSION authorizes nothing, and `publishable` must be exactly true to reach SIGN or later.
|
|
30
|
+
*/
|
|
31
|
+
function admissionProblems(admission, stage) {
|
|
32
|
+
const out = [];
|
|
33
|
+
if (!admission) return [{ code: "STAGE_NOT_AUTHORIZED", message: `no valid ADMISSION evidence authorizes ${stage}` }];
|
|
34
|
+
if (!Array.isArray(admission.authorizedStages) || !admission.authorizedStages.includes(stage)) out.push({ code: "STAGE_NOT_AUTHORIZED", message: `ADMISSION does not authorize ${stage}` });
|
|
35
|
+
if (admission.publishable !== true && PUBLISHABLE_ONLY_STAGES.has(stage)) out.push({ code: "NOT_PUBLISHABLE", message: `ADMISSION does not mark this request publishable:true; it cannot reach ${stage}` });
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ENVELOPE_STRINGS = ["receiptId", "jobId", "requestId", "repositoryId", "commit", "inputDigest", "configDigest", "policyDigest", "stagePlanDigest", "runtimeDigest", "executor", "issuedAt", "expiresAt", "keyId", "signature"];
|
|
40
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
41
|
+
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
|
42
|
+
|
|
43
|
+
/** Domain-separated bytes a signature covers: receipt type bound in, signature field excluded. */
|
|
44
|
+
export function signingBytes(receipt) {
|
|
45
|
+
const { signature, ...rest } = receipt;
|
|
46
|
+
return Buffer.from(`${RECEIPT_SCHEMA}\0${receipt.type}\0${canonicalJson(rest)}`, "utf8");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The hash a child receipt records in `parents`: covers the whole receipt including its signature. */
|
|
50
|
+
export function receiptHash(receipt) {
|
|
51
|
+
return sha256(Buffer.concat([Buffer.from(`${RECEIPT_SCHEMA}.hash\0`, "utf8"), Buffer.from(canonicalJson(receipt), "utf8")]));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** In-memory request ledger for tests and single-process brokers: records which job owns a requestId. */
|
|
55
|
+
export function createMemoryLedger() {
|
|
56
|
+
const owners = new Map(); const consumed = new Set();
|
|
57
|
+
return {
|
|
58
|
+
requestOwner: (requestId) => owners.get(requestId),
|
|
59
|
+
isConsumed: (receiptId) => consumed.has(receiptId),
|
|
60
|
+
record(chain) { for (const r of chain) { if (!owners.has(r.requestId)) owners.set(r.requestId, r.jobId); consumed.add(r.receiptId); } },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
65
|
+
const nonEmptyString = (v) => typeof v === "string" && v.trim() !== "";
|
|
66
|
+
const isHex = (v) => typeof v === "string" && HEX64.test(v);
|
|
67
|
+
const nonEmptyArray = (v) => Array.isArray(v) && v.length > 0;
|
|
68
|
+
|
|
69
|
+
/** Stage-specific evidence checks. Each returns a list of problem messages. */
|
|
70
|
+
const STAGE_EVIDENCE = {
|
|
71
|
+
ADMISSION(e) {
|
|
72
|
+
const p = [];
|
|
73
|
+
for (const f of ["inputManifestDigest", "dependencyManifestDigest", "toolchainManifestDigest"]) if (!isHex(e[f])) p.push(`${f} must be a sha256 hex digest`);
|
|
74
|
+
if (!nonEmptyArray(e.authorizedStages) || e.authorizedStages.some((s) => !STAGES.includes(s))) p.push("authorizedStages must list known stages");
|
|
75
|
+
if (typeof e.publishable !== "boolean") p.push("publishable must be a boolean");
|
|
76
|
+
return p;
|
|
77
|
+
},
|
|
78
|
+
SCAN_A: (e, ctx) => scanEvidence(e, "A", ctx),
|
|
79
|
+
ROUTE(e, ctx) {
|
|
80
|
+
const p = [];
|
|
81
|
+
const d = e.decision;
|
|
82
|
+
if (!isObj(d)) return ["decision is required"];
|
|
83
|
+
if (!["local", "ci"].includes(d.executor)) p.push("decision.executor must be local or ci");
|
|
84
|
+
if (!["public", "private"].includes(d.visibility)) p.push("decision.visibility must be public or private (unknown visibility is rejected)");
|
|
85
|
+
if (d.visibility === "private" && d.executor !== "local") p.push("a private repository never routes to CI");
|
|
86
|
+
if (typeof d.localGrant !== "boolean") p.push("decision.localGrant must be a boolean");
|
|
87
|
+
if (d.executor === "local" && !nonEmptyString(String(e.leaseFence ?? ""))) p.push("a local route needs a leaseFence");
|
|
88
|
+
if (d.executor === "ci" && !isObj(e.dispatchBinding)) p.push("a CI route needs a dispatchBinding");
|
|
89
|
+
if (!nonEmptyString(d.policyVersion)) p.push("decision.policyVersion is required");
|
|
90
|
+
return p;
|
|
91
|
+
},
|
|
92
|
+
SCAN_B: (e, ctx) => scanEvidence(e, "B", ctx),
|
|
93
|
+
BUILD(e) {
|
|
94
|
+
const p = [];
|
|
95
|
+
if (!isHex(e.artifactManifestDigest)) p.push("artifactManifestDigest must be a sha256 hex digest");
|
|
96
|
+
if (!nonEmptyArray(e.artifacts) || e.artifacts.some((a) => !nonEmptyString(a?.path) || !isHex(a?.sha256))) p.push("artifacts must list path and sha256 for every output");
|
|
97
|
+
for (const f of ["generationEvidenceDigest", "provenanceDigest"]) if (!isHex(e[f])) p.push(`${f} must be a sha256 hex digest`);
|
|
98
|
+
if (!nonEmptyArray(e.targetMatrix)) p.push("targetMatrix must list the built targets");
|
|
99
|
+
return p;
|
|
100
|
+
},
|
|
101
|
+
SIGN(e, ctx) {
|
|
102
|
+
const p = [];
|
|
103
|
+
if (!["SIGN", "SEAL"].includes(e.mode)) p.push("mode must be SIGN or SEAL");
|
|
104
|
+
for (const f of ["inputManifestDigest", "outputManifestDigest", "signingEvidenceDigest"]) if (!isHex(e[f])) p.push(`${f} must be a sha256 hex digest`);
|
|
105
|
+
if (!nonEmptyString(e.signingProfile)) p.push("signingProfile is required");
|
|
106
|
+
const build = ctx.byStage.BUILD;
|
|
107
|
+
if (build && e.inputManifestDigest !== build.evidence?.artifactManifestDigest) p.push("inputManifestDigest does not equal the BUILD artifactManifestDigest");
|
|
108
|
+
return p;
|
|
109
|
+
},
|
|
110
|
+
QUALIFIED(e, ctx) {
|
|
111
|
+
const p = [];
|
|
112
|
+
if (!isHex(e.artifactManifestDigest)) p.push("artifactManifestDigest must be a sha256 hex digest");
|
|
113
|
+
if (!nonEmptyArray(e.testMatrix) || e.testMatrix.some((t) => !nonEmptyString(t?.name) || t?.status !== "pass")) p.push("testMatrix must be non-empty and every row must pass");
|
|
114
|
+
const sign = ctx.byStage.SIGN;
|
|
115
|
+
if (sign && e.artifactManifestDigest !== sign.evidence?.outputManifestDigest) p.push("artifactManifestDigest does not equal the SIGN outputManifestDigest (qualification covers the final signed bytes)");
|
|
116
|
+
return p;
|
|
117
|
+
},
|
|
118
|
+
PUBLISH_INTENT(e, ctx) {
|
|
119
|
+
const p = [];
|
|
120
|
+
if (!isHex(e.releaseManifestDigest)) p.push("releaseManifestDigest must be a sha256 hex digest");
|
|
121
|
+
if (!nonEmptyArray(e.destinations) || e.destinations.some((d) => !nonEmptyString(d?.id) || !nonEmptyString(d?.identity) || !nonEmptyString(d?.version) || !isHex(d?.expectedDigest) || !nonEmptyString(d?.idempotencyKey))) {
|
|
122
|
+
p.push("destinations must name id, identity, version, expectedDigest and idempotencyKey");
|
|
123
|
+
}
|
|
124
|
+
const q = ctx.byStage.QUALIFIED;
|
|
125
|
+
if (q && e.releaseManifestDigest !== q.evidence?.artifactManifestDigest) p.push("releaseManifestDigest does not equal the QUALIFIED artifactManifestDigest");
|
|
126
|
+
return p;
|
|
127
|
+
},
|
|
128
|
+
PUBLISHED(e, ctx) {
|
|
129
|
+
const p = [];
|
|
130
|
+
const intent = ctx.byStage.PUBLISH_INTENT;
|
|
131
|
+
if (!nonEmptyArray(e.entries)) return ["entries must list every destination outcome"];
|
|
132
|
+
for (const d of intent?.evidence?.destinations ?? []) {
|
|
133
|
+
const entry = e.entries.find((x) => x?.destinationId === d.id);
|
|
134
|
+
if (!entry) p.push(`no entry for destination ${d.id}`);
|
|
135
|
+
else if (entry.outcome !== "verified") p.push(`destination ${d.id} is ${entry.outcome}, not verified`);
|
|
136
|
+
else if (entry.remoteDigest !== d.expectedDigest) p.push(`destination ${d.id} readback digest differs from the intent`);
|
|
137
|
+
}
|
|
138
|
+
return p;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
function scanEvidence(e, cls, ctx) {
|
|
143
|
+
const p = [];
|
|
144
|
+
const other = cls === "A" ? "B" : "A";
|
|
145
|
+
if (e.scanClass !== cls) p.push(`scanClass must be ${cls}`);
|
|
146
|
+
if (!nonEmptyArray(e.scans)) return [...p, "scans must list every required scan result"];
|
|
147
|
+
if (cls === "A" && !isHex(e.advisorySnapshotDigest)) p.push("advisorySnapshotDigest must be a sha256 hex digest");
|
|
148
|
+
if (cls === "A" && !nonEmptyString(e.advisorySnapshotTime)) p.push("advisorySnapshotTime is required");
|
|
149
|
+
for (const s of e.scans) {
|
|
150
|
+
const derived = scanClassOf(s?.id);
|
|
151
|
+
if (s?.class === other || derived === other) p.push(`${cls === "A" ? "SCAN_A claims" : "SCAN_B carries"} class ${other} scan ${s?.id}`);
|
|
152
|
+
else if (s?.class !== cls) p.push(`scan ${s?.id} has class ${s?.class}, expected ${cls}`);
|
|
153
|
+
if (s?.status !== "pass") p.push(`scan ${s?.id} is ${s?.status}, not pass`);
|
|
154
|
+
}
|
|
155
|
+
const ids = new Set(e.scans.map((s) => s?.id));
|
|
156
|
+
const required = ctx.requiredScans?.[cls];
|
|
157
|
+
const declared = Array.isArray(e.requiredScanIds) ? e.requiredScanIds : null;
|
|
158
|
+
if (!declared) p.push("requiredScanIds is required");
|
|
159
|
+
// The required set comes only from the injected policy matrix. A receipt's own declaration is never trusted:
|
|
160
|
+
// without the policy, verification fails closed.
|
|
161
|
+
if (!Array.isArray(required) || required.some((id) => typeof id !== "string")) {
|
|
162
|
+
p.push(`trusted required scan set for class ${cls} is not supplied by policy; verification fails closed`);
|
|
163
|
+
return p;
|
|
164
|
+
}
|
|
165
|
+
for (const id of required) if (!ids.has(id)) p.push(`required class ${cls} scan ${id} is missing`);
|
|
166
|
+
if (declared && canonicalJson([...required].sort()) !== canonicalJson([...declared].sort())) p.push(`requiredScanIds differ from the trusted class ${cls} set`);
|
|
167
|
+
if (e.scanSetDigest !== digestScanSet(e.scans)) p.push("scanSetDigest does not match the listed scans");
|
|
168
|
+
if (cls === "B" && !nonEmptyArray(required)) p.push("SCAN_B must require at least one class B scan");
|
|
169
|
+
return p;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Verify a chain of receipts (ADMISSION first, ending at the stage being authorized).
|
|
174
|
+
*
|
|
175
|
+
* options:
|
|
176
|
+
* verifySignature({ receipt, keyId, signingBytes, signature }) -> boolean REQUIRED (the broker supplies keys)
|
|
177
|
+
* now ISO string or Date, default current time
|
|
178
|
+
* expected { requestId, operation, inputDigest, stage, repositoryId, commit, jobId, releaseId, targetSet } (each optional but checked when given)
|
|
179
|
+
* requiredScans { A: [ids], B: [ids] } trusted required sets from the policy matrix: REQUIRED; absence rejects (NO_SCAN_POLICY)
|
|
180
|
+
* ledger { requestOwner(requestId), isConsumed(receiptId) } REQUIRED: without it verification fails (NO_LEDGER)
|
|
181
|
+
* keyAllowed (keyId, receipt) -> boolean, for revocations
|
|
182
|
+
* Returns { ok, problems: [{ code, stage, message }], head }. Never throws on a bad receipt.
|
|
183
|
+
*/
|
|
184
|
+
export function verifyReceiptChain(chain, options = {}) {
|
|
185
|
+
const problems = [];
|
|
186
|
+
const bad = (code, stage, message) => problems.push({ code, stage: stage ?? null, message });
|
|
187
|
+
if (typeof options.verifySignature !== "function") return { ok: false, problems: [{ code: "NO_SIGNATURE_VERIFIER", stage: null, message: "a signature verifier must be supplied by the broker" }], head: null };
|
|
188
|
+
if (!Array.isArray(chain) || chain.length === 0) return { ok: false, problems: [{ code: "EMPTY_CHAIN", stage: null, message: "no receipts" }], head: null };
|
|
189
|
+
const now = new Date(options.now ?? Date.now()).getTime();
|
|
190
|
+
const expected = options.expected ?? {};
|
|
191
|
+
const byStage = {};
|
|
192
|
+
let previous = null;
|
|
193
|
+
// Replay protection is mandatory: without a ledger the chain cannot be proven fresh, so it fails closed.
|
|
194
|
+
if (typeof options.ledger?.requestOwner !== "function" || typeof options.ledger?.isConsumed !== "function") {
|
|
195
|
+
bad("NO_LEDGER", null, "a request ledger with requestOwner and isConsumed must be supplied; replay cannot be ruled out without it");
|
|
196
|
+
}
|
|
197
|
+
// One release id for the whole chain: the ADMISSION one when it has it, else the first release-stage one.
|
|
198
|
+
const releaseRef = chain.find((r) => isObj(r) && nonEmptyString(r.releaseId))?.releaseId;
|
|
199
|
+
// ADMISSION fixes which stages may run and whether the request may publish at all.
|
|
200
|
+
const admission = isObj(chain[0]) && chain[0].type === "ADMISSION" && isObj(chain[0].evidence) ? chain[0].evidence : null;
|
|
201
|
+
|
|
202
|
+
chain.forEach((receipt, index) => {
|
|
203
|
+
const stage = isObj(receipt) ? receipt.type : null;
|
|
204
|
+
if (!isObj(receipt)) { bad("MALFORMED", null, `receipt ${index} is not an object`); return; }
|
|
205
|
+
if (receipt.schema !== RECEIPT_SCHEMA) bad("SCHEMA", stage, `schema must be ${RECEIPT_SCHEMA}`);
|
|
206
|
+
if (!STAGES.includes(stage)) { bad("UNKNOWN_STAGE", stage, `unknown receipt type ${JSON.stringify(stage)}`); return; }
|
|
207
|
+
const wantStage = STAGES[index];
|
|
208
|
+
if (stage !== wantStage) bad("WRONG_STAGE", stage, `position ${index} requires ${wantStage}, got ${stage}`);
|
|
209
|
+
for (const f of ENVELOPE_STRINGS) if (!nonEmptyString(receipt[f])) bad("ENVELOPE", stage, `${f} is required`);
|
|
210
|
+
if (!Number.isInteger(receipt.attempt) || receipt.attempt < 1) bad("ENVELOPE", stage, "attempt must be an integer >= 1");
|
|
211
|
+
if (!Array.isArray(receipt.targetSet)) bad("ENVELOPE", stage, "targetSet must be an array");
|
|
212
|
+
if (!OPERATIONS.includes(receipt.operation)) bad("WRONG_OPERATION", stage, `unknown operation ${JSON.stringify(receipt.operation)}`);
|
|
213
|
+
if (RELEASE_ID_STAGES.has(stage) && !nonEmptyString(receipt.releaseId)) bad("ENVELOPE", stage, "releaseId is required");
|
|
214
|
+
if (!isObj(receipt.evidence)) bad("EVIDENCE", stage, "evidence is required");
|
|
215
|
+
|
|
216
|
+
// The operation bounds how far a request may go.
|
|
217
|
+
const last = OPERATION_LAST_STAGE[receipt.operation];
|
|
218
|
+
if (last && STAGES.indexOf(stage) > STAGES.indexOf(last)) bad("WRONG_OPERATION", stage, `operation ${receipt.operation} does not authorize ${stage}`);
|
|
219
|
+
|
|
220
|
+
// ADMISSION bounds the chain: only its authorized stages, and nothing from SIGN on unless publishable:true.
|
|
221
|
+
for (const x of admissionProblems(admission, stage)) bad(x.code, stage, x.message);
|
|
222
|
+
|
|
223
|
+
// Binding to the caller's expectations.
|
|
224
|
+
if (expected.requestId !== undefined && receipt.requestId !== expected.requestId) bad("WRONG_REQUEST_ID", stage, "requestId differs from the expected request");
|
|
225
|
+
if (expected.operation !== undefined && receipt.operation !== expected.operation) bad("WRONG_OPERATION", stage, `operation ${receipt.operation} differs from expected ${expected.operation}`);
|
|
226
|
+
if (expected.inputDigest !== undefined && receipt.inputDigest !== expected.inputDigest) bad("WRONG_INPUT_DIGEST", stage, "inputDigest differs from the expected input");
|
|
227
|
+
for (const f of ["repositoryId", "commit", "jobId", "releaseId"]) if (expected[f] !== undefined && receipt[f] !== expected[f]) bad("BINDING", stage, `${f} differs from the expected value`);
|
|
228
|
+
if (expected.targetSet !== undefined && canonicalJson(receipt.targetSet ?? null) !== canonicalJson(expected.targetSet)) bad("BINDING", stage, "targetSet differs from the expected value");
|
|
229
|
+
if (releaseRef !== undefined && receipt.releaseId !== undefined && receipt.releaseId !== releaseRef) bad("BINDING", stage, "releaseId differs from the chain's release");
|
|
230
|
+
|
|
231
|
+
// Consistency across the chain.
|
|
232
|
+
const first = chain[0];
|
|
233
|
+
if (index > 0 && isObj(first)) {
|
|
234
|
+
for (const f of ["requestId", "jobId", "repositoryId", "commit", "inputDigest", "configDigest", "policyDigest", "stagePlanDigest", "operation", "targetSet"]) {
|
|
235
|
+
if (f === "targetSet" ? canonicalJson(receipt[f] ?? null) !== canonicalJson(first[f] ?? null) : receipt[f] !== first[f]) bad(f === "requestId" ? "WRONG_REQUEST_ID" : f === "inputDigest" ? "WRONG_INPUT_DIGEST" : f === "operation" ? "WRONG_OPERATION" : "BINDING", stage, `${f} differs from the ADMISSION receipt`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Freshness.
|
|
240
|
+
const issued = Date.parse(receipt.issuedAt); const expires = Date.parse(receipt.expiresAt);
|
|
241
|
+
if (Number.isNaN(issued) || Number.isNaN(expires)) bad("ENVELOPE", stage, "issuedAt and expiresAt must be ISO timestamps");
|
|
242
|
+
else {
|
|
243
|
+
if (expires <= issued) bad("ENVELOPE", stage, "expiresAt must be after issuedAt");
|
|
244
|
+
if (expires <= now) bad("EXPIRED", stage, `expired at ${receipt.expiresAt}`);
|
|
245
|
+
if (issued > now + 60_000) bad("ENVELOPE", stage, "issuedAt is in the future");
|
|
246
|
+
if (previous && issued < Date.parse(previous.issuedAt)) bad("ORDER", stage, "issued before its parent receipt");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Hash chain.
|
|
250
|
+
const wantParents = previous ? [receiptHash(previous)] : [];
|
|
251
|
+
if (!Array.isArray(receipt.parents) || canonicalJson(receipt.parents) !== canonicalJson(wantParents)) bad("BROKEN_CHAIN", stage, previous ? `parents must be exactly [hash of ${previous.type}]` : "ADMISSION has no parents");
|
|
252
|
+
|
|
253
|
+
// Replay (the ledger is required; see NO_LEDGER above).
|
|
254
|
+
if (typeof options.ledger?.requestOwner === "function" && typeof options.ledger?.isConsumed === "function") {
|
|
255
|
+
const owner = options.ledger.requestOwner(receipt.requestId);
|
|
256
|
+
if (owner !== undefined && owner !== receipt.jobId) bad("REPLAYED_REQUEST_ID", stage, "requestId already belongs to another job");
|
|
257
|
+
if (options.ledger.isConsumed(receipt.receiptId)) bad("REPLAYED_RECEIPT", stage, "receipt already consumed");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Signature (injected).
|
|
261
|
+
if (options.keyAllowed && !options.keyAllowed(receipt.keyId, receipt)) bad("KEY_REVOKED", stage, `key ${receipt.keyId} is not allowed for this receipt`);
|
|
262
|
+
let signatureOk = false;
|
|
263
|
+
try { signatureOk = options.verifySignature({ receipt, keyId: receipt.keyId, signingBytes: signingBytes(receipt), signature: receipt.signature }) === true; } catch { signatureOk = false; }
|
|
264
|
+
if (!signatureOk) bad("BAD_SIGNATURE", stage, "signature did not verify");
|
|
265
|
+
|
|
266
|
+
byStage[stage] = receipt;
|
|
267
|
+
// Stage evidence last: it can look at the earlier stages collected so far.
|
|
268
|
+
if (isObj(receipt.evidence) && STAGE_EVIDENCE[stage]) {
|
|
269
|
+
for (const message of STAGE_EVIDENCE[stage](receipt.evidence, { byStage, requiredScans: options.requiredScans })) {
|
|
270
|
+
const code = /trusted required scan set/.test(message) ? "NO_SCAN_POLICY" : /missing|claims|carries/.test(message) && (stage === "SCAN_A" || stage === "SCAN_B") ? (/claims|carries/.test(message) ? "SCAN_CLASS_MIX" : "SCAN_INCOMPLETE") : "EVIDENCE";
|
|
271
|
+
bad(code, stage, message);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
previous = receipt;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const head = chain[chain.length - 1];
|
|
278
|
+
if (expected.stage !== undefined) {
|
|
279
|
+
if (!STAGES.includes(expected.stage)) bad("WRONG_STAGE", null, `unknown expected stage ${expected.stage}`);
|
|
280
|
+
else if (head?.type !== expected.stage) bad("WRONG_STAGE", head?.type, `chain ends at ${head?.type}, expected ${expected.stage}`);
|
|
281
|
+
}
|
|
282
|
+
return { ok: problems.length === 0, problems, head: isObj(head) ? { type: head.type, hash: receiptHash(head) } : null };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Verify that a new stage may start: the chain so far must verify and end at the stage `stage` requires.
|
|
287
|
+
* Returns the same result shape; `authorizes` is the stage the chain authorizes next (or null).
|
|
288
|
+
*/
|
|
289
|
+
export function authorizeNextStage(chain, stage, options = {}) {
|
|
290
|
+
const need = PREREQUISITES[stage];
|
|
291
|
+
if (!need) return { ok: false, problems: [{ code: "UNKNOWN_STAGE", stage, message: `unknown stage ${stage}` }], head: null, authorizes: null };
|
|
292
|
+
if (need.length === 0) return { ok: !chain?.length, problems: chain?.length ? [{ code: "WRONG_STAGE", stage, message: "ADMISSION starts a chain" }] : [], head: null, authorizes: chain?.length ? null : stage };
|
|
293
|
+
const result = verifyReceiptChain(chain, { ...options, expected: { ...(options.expected ?? {}), stage: need[0] } });
|
|
294
|
+
// The requested stage itself must sit inside the operation's boundary and ADMISSION's authorized stages.
|
|
295
|
+
const first = Array.isArray(chain) ? chain[0] : null;
|
|
296
|
+
const last = OPERATION_LAST_STAGE[first?.operation];
|
|
297
|
+
if (!last || STAGES.indexOf(stage) > STAGES.indexOf(last)) result.problems.push({ code: "WRONG_OPERATION", stage, message: `operation ${first?.operation} does not authorize ${stage}` });
|
|
298
|
+
const admission = first?.type === "ADMISSION" && isObj(first.evidence) ? first.evidence : null;
|
|
299
|
+
for (const x of admissionProblems(admission, stage)) result.problems.push({ code: x.code, stage, message: x.message });
|
|
300
|
+
result.ok = result.problems.length === 0;
|
|
301
|
+
return { ...result, authorizes: result.ok ? stage : null };
|
|
302
|
+
}
|
package/rightkit-versions.json
CHANGED
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"@rightkit/qa": "0.6.2",
|
|
50
50
|
"@rightkit/qa-chrome": "0.1.12",
|
|
51
51
|
"@rightkit/react-shell": "0.7.2",
|
|
52
|
-
"@rightkit/release": "0.6.
|
|
52
|
+
"@rightkit/release": "0.6.28",
|
|
53
53
|
"@rightkit/settings-ui": "0.7.0",
|
|
54
54
|
"@rightkit/social-ui": "0.1.0",
|
|
55
55
|
"@rightkit/system-ui": "0.1.0",
|
package/bin/darwin/rightkit-swap
DELETED
|
Binary file
|