@rightkit/release 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +59 -7
  2. package/adhoc-entitlements.mjs +8 -0
  3. package/adoption-evidence.mjs +650 -0
  4. package/advisory-exceptions.mjs +371 -0
  5. package/apple-profile-create.mjs +113 -10
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/rightkit-gate.mjs +1079 -0
  8. package/binary-license-notices.mjs +161 -0
  9. package/cargo-license-notices.mjs +111 -0
  10. package/cli/right-release.mjs +39 -0
  11. package/deterministic-scans.mjs +966 -0
  12. package/development-signing.mjs +2 -1
  13. package/host-upload-refusal.mjs +90 -0
  14. package/local-publish.mjs +142 -19
  15. package/local-qualify.mjs +728 -0
  16. package/local-runtime.mjs +319 -0
  17. package/lock-graph.mjs +51 -0
  18. package/managed-cargo.mjs +4 -1
  19. package/ownership-approval.mjs +23 -6
  20. package/ownership-dependency-allowlist.json +2 -0
  21. package/ownership-mandatory-set.json +158 -68
  22. package/ownership-mandatory-set.mjs +82 -6
  23. package/package.json +6 -2
  24. package/pipeline-admission.mjs +375 -0
  25. package/pipeline-config.mjs +100 -0
  26. package/pipeline-generators.json +4 -0
  27. package/pipeline-generators.mjs +137 -0
  28. package/pipeline-receipts.mjs +302 -0
  29. package/pipeline-scan.mjs +285 -0
  30. package/pipeline-tools.mjs +171 -0
  31. package/production-mode.mjs +42 -0
  32. package/publication-gate.mjs +94 -32
  33. package/publish-cargo.mjs +24 -7
  34. package/publish-swift.mjs +12 -3
  35. package/qa-control-scan.mjs +4 -0
  36. package/release-config.mjs +330 -0
  37. package/release-switch-scan.mjs +44 -5
  38. package/release.mjs +14 -4
  39. package/rightkit-ownership.json +57 -0
  40. package/rightkit-sync.mjs +8 -0
  41. package/rightkit-versions.json +191 -131
  42. package/runtime-budget-classes.json +8 -0
  43. package/runtime-budget-crates.mjs +14 -0
  44. package/runtime-budgets.mjs +128 -31
  45. package/scan-digest.mjs +107 -0
  46. package/sign-qa-bundle.mjs +210 -0
  47. package/stage-sidecar.mjs +99 -15
  48. package/swap-helper-bundle.mjs +7 -3
  49. package/workflow-yaml.mjs +177 -0
  50. package/bin/darwin/rightkit-swap.sign.json +0 -6
@@ -0,0 +1,319 @@
1
+ // Local runtime-budget proof of ONE runtime owner on THIS host (Adrian 2026-10-22: runtime proofs no longer need GitHub CI).
2
+ // Called by local-qualify.mjs after the host's units are qualified, so the machine is not busy with them. For every trusted
3
+ // workload of the crate it runs the pinned Cargo command through the RustRight broker, repeatedly, each time in a quiet window
4
+ // whose evidence it records, and returns the entry that goes into this host's `local-verify` receipt as `runtime.crates[crate]`.
5
+ //
6
+ // Trust and measurement, mirroring the Actions path (scripts/ci/runtime-qualify.mjs):
7
+ // - WHICH workloads run comes from the trusted table in runtime-qualify.mjs (`workloadsFor`), never from the candidate. The
8
+ // candidate's crates/<crate>/benches/workloads.json must equal that table exactly (`readSourcePrescription`) or the run
9
+ // refuses. The harness code is loaded from `root`, a clean checkout whose HEAD is on origin/main (local-qualify.mjs
10
+ // checkoutState) and equals the candidate commit; admission later requires every helper of the harness to equal the
11
+ // promotion checkout's (`harness.helpers` below), exactly as CI requires the producer's blobs to.
12
+ // - WHAT is measured is the candidate's source at `commit`: the checkout is verified clean and at `commit` before and
13
+ // after every run, so the bench cannot have built anything else. Every file the measurement depends on is hashed from
14
+ // the git blobs of `commit` into `bindings`, as CI does (bindSource).
15
+ // - HOW it is measured: the same `RESULT budget` line the bench prints, parsed and recorded by the owner's own
16
+ // `recordMeasurement`, checked by the owner's own `measurementProblems` against the frozen budget (profile, ceilings,
17
+ // required scale). The submitted record is the WORST of `repeats` runs, every run must pass, and each run holds a window
18
+ // that local-runtime-proof.mjs `windowProblems` calls quiet. A disturbed window refuses; it is never retried silently.
19
+ // The broker has no client-held exclusive lease (docs/CI.md), so quiet is DETECTED: health snapshots before, during and after,
20
+ // the broker's job listing for overlapping root jobs, and host CPU idle before and after.
21
+ import crypto from "node:crypto";
22
+ import fs from "node:fs";
23
+ import os from "node:os";
24
+ import path from "node:path";
25
+ import { pathToFileURL } from "node:url";
26
+ import { spawnSync } from "./background-process.mjs";
27
+ import { spawnCollect } from "./managed-cargo.mjs";
28
+ import { discoverBrokerSocket, managedControlCandidates } from "./managed-roots.mjs";
29
+
30
+ const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
31
+ const sleepFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
32
+ const round3 = (value) => Math.round(value * 1000) / 1000;
33
+ const tail = (text, lines = 30) => String(text ?? "").split(/\r?\n/).slice(-lines).join("\n");
34
+ const loadFromRoot = (root, ...segments) => import(pathToFileURL(path.join(root, ...segments)).href);
35
+ const CREDENTIAL_LIKE = /TOKEN|PASSWORD|SECRET|CREDENTIAL|^ACTIONS_ID_TOKEN/i;
36
+ const BROKER_ENV = ["RIGHTKIT_BUILD_BROKER_SOCKET", "RIGHTKIT_CONTROL_ROOT", "RIGHTKIT_DATA_ROOT", "RIGHTKIT_MANAGED_ROOT"];
37
+ const BENCH_TIMEOUT_MS = 40 * 60 * 1000;
38
+
39
+ // ---- host evidence -------------------------------------------------------------------------------
40
+
41
+ /** Candidate locations of the broker's health.json on this host, most specific first. */
42
+ export function brokerHealthFiles({ env = process.env, platform = process.platform, home = os.homedir() } = {}) {
43
+ const join = platform === "win32" ? path.win32.join : path.posix.join;
44
+ const files = [env.RK_BROKER_HEALTH_FILE, ...managedControlCandidates({ env, platform }).map((root) => join(root, "health.json"))];
45
+ if (platform === "darwin") files.push(path.posix.join(home, "Library", "Application Support", "RustRight", "health.json"));
46
+ return [...new Set(files.filter(Boolean))];
47
+ }
48
+
49
+ /** The newest readable broker snapshot, stamped with the time it was read; null when none is readable. */
50
+ export function readBrokerHealth({ files, readFile = fs.readFileSync, now = Date.now } = {}) {
51
+ let best = null;
52
+ for (const file of files) {
53
+ let doc;
54
+ try { doc = JSON.parse(readFile(file, "utf8")); } catch { continue; }
55
+ if (doc?.schemaVersion !== 1 || !Number.isInteger(doc.writtenAtMs)) continue;
56
+ if (!best || doc.writtenAtMs > best.writtenAtMs) best = { writtenAtMs: doc.writtenAtMs, sampledAtMs: now(), state: doc.state, admissionPaused: doc.admissionPaused, inFlight: doc.inFlight, queued: doc.queued, admitting: doc.admitting, pid: doc.pid ?? null };
57
+ }
58
+ return best;
59
+ }
60
+
61
+ const cpuTotals = (cpus) => cpus.reduce((sum, cpu) => { const t = cpu.times; sum.idle += t.idle; sum.total += t.user + t.nice + t.sys + t.idle + t.irq; return sum; }, { idle: 0, total: 0 });
62
+ /** Host-wide CPU idle share over `ms`, from the OS tick counters (every process, not only ours). */
63
+ export async function sampleCpu(ms, { cpus = os.cpus, loadavg = os.loadavg, sleep = sleepFor, now = Date.now, platform = process.platform } = {}) {
64
+ const startedAtMs = now();
65
+ const first = cpuTotals(cpus());
66
+ await sleep(ms);
67
+ const second = cpuTotals(cpus());
68
+ const total = second.total - first.total;
69
+ return { startedAtMs, durationMs: now() - startedAtMs, idlePercent: total > 0 ? round3((100 * (second.idle - first.idle)) / total) : null, cores: cpus().length, loadavg1: platform === "win32" ? null : round3(loadavg()[0]) };
70
+ }
71
+
72
+ /** Root jobs of the broker's listing (`rustright list`): live ones from `inFlight`, finished ones from `recent`. Tolerant of wrapping. */
73
+ export function parseBrokerJobs(text) {
74
+ const jobs = []; let found = false;
75
+ const take = (rows, live) => {
76
+ if (!Array.isArray(rows)) return;
77
+ found = true;
78
+ for (const row of rows) {
79
+ const submittedAtMs = Date.parse(row?.submittedAt);
80
+ if (!row || typeof row !== "object" || !Number.isFinite(submittedAtMs)) continue;
81
+ const startedAtMs = row.startedAt ? Date.parse(row.startedAt) : null;
82
+ jobs.push({ id: String(row.id), workspace: row.workspace ?? null, live, submittedAtMs, startedAtMs: Number.isFinite(startedAtMs) ? startedAtMs : null, endedAtMs: live ? null : (Number.isFinite(row.elapsedMs) ? submittedAtMs + row.elapsedMs : null) });
83
+ }
84
+ };
85
+ for (const line of String(text ?? "").split(/\r?\n/)) {
86
+ let doc; try { doc = JSON.parse(line); } catch { continue; }
87
+ for (const holder of [doc, doc?.event, doc?.result]) if (holder && typeof holder === "object") { take(holder.inFlight, true); take(holder.recent, false); }
88
+ }
89
+ if (!found) throw new Error("the broker job listing has no inFlight or recent list");
90
+ return jobs;
91
+ }
92
+
93
+ const samePath = (a, b) => String(a ?? "").replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase() === String(b ?? "").replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase();
94
+ /**
95
+ * Which root jobs touched the span [fromMs, toMs]. `ours` counts the root jobs in the measured checkout that were submitted
96
+ * at the run start; anything else that overlaps the span, in any workspace, is foreign. A finished job whose end is unknown
97
+ * is treated as still running (it overlaps).
98
+ */
99
+ export function analyzeJobs({ jobs, root, fromMs, toMs, runStartMs, checkedAtMs, slackMs = 1000 }) {
100
+ const mine = jobs.filter((job) => samePath(job.workspace, root) && job.submittedAtMs >= runStartMs - 5000 && job.submittedAtMs <= runStartMs + 60_000);
101
+ const overlaps = (job) => (job.startedAtMs ?? job.submittedAtMs) <= toMs + slackMs && (job.live || job.endedAtMs === null || job.endedAtMs >= fromMs - slackMs);
102
+ const foreign = jobs.filter((job) => overlaps(job) && !(mine.length === 1 && job.id === mine[0].id));
103
+ return { checked: true, fromMs, toMs, checkedAtMs, ours: mine.length, foreign: foreign.map(({ id, workspace, live, startedAtMs, endedAtMs }) => ({ id, workspace, live, startedAtMs, endedAtMs })) };
104
+ }
105
+
106
+ /** The broker listing at one moment, reduced to what the window proof needs: when it was read, which root jobs it names, which are live. */
107
+ export const listingRecord = (jobs, checkedAtMs) => ({ checked: true, checkedAtMs, ids: jobs.map((job) => job.id), live: jobs.filter((job) => job.live).map((job) => job.id) });
108
+
109
+ async function listBrokerJobs({ client, env, run }) {
110
+ const result = await run(client, ["list"], { env, timeout: 30_000, windowsHide: true });
111
+ if (result.error || result.status !== 0) throw new Error(`rustright list failed: ${result.error?.message ?? `exit ${result.status}`}`);
112
+ return parseBrokerJobs(result.stdout);
113
+ }
114
+
115
+ // ---- the proof -----------------------------------------------------------------------------------
116
+
117
+ async function defaultHarness(root) {
118
+ const [owner, proof, budgets, supplement, workloadEnv] = await Promise.all([
119
+ loadFromRoot(root, "scripts", "ci", "runtime-qualify.mjs"), loadFromRoot(root, "scripts", "ci", "local-runtime-proof.mjs"), loadFromRoot(root, "packages", "release", "runtime-budgets.mjs"),
120
+ loadFromRoot(root, "scripts", "ci", "runtime-supplement.mjs"), loadFromRoot(root, "scripts", "ci", "runtime-workload-env.mjs"),
121
+ ]);
122
+ return { owner, proof, budgets, workloadEnv, helperPaths: supplement.trustedRuntimeHelperPaths };
123
+ }
124
+
125
+ /** `args` of a pinned bench command with `--no-run` before the bench's own `--` arguments: a warm build, never a measurement. */
126
+ export const warmArgs = (args) => { const at = args.indexOf("--"); return at < 0 ? [...args, "--no-run"] : [...args.slice(0, at), "--no-run", ...args.slice(at)]; };
127
+
128
+ /**
129
+ * Measures `crate` on this host and returns its runtime entry (see scripts/ci/runtime-supplement.mjs verifyLocalRuntimeProof
130
+ * for what admission checks). Throws with every reason on the first refusal; nothing partial is returned.
131
+ * options: root, commit, crate, version, archiveSha256, candidateId, broker, cargoCommand, repository, scratch,
132
+ * repeats (policy range), waitIdleMs (how long to wait for a quiet host before refusing).
133
+ */
134
+ export async function measureRuntimeCrate(options, deps = {}) {
135
+ const { root, commit, crate, version, archiveSha256, candidateId, broker, cargoCommand, repository, scratch, repeats, waitIdleMs = 0 } = options;
136
+ const log = deps.log ?? (() => {});
137
+ const platform = deps.platform ?? process.platform;
138
+ const arch = deps.arch ?? process.arch;
139
+ const now = deps.now ?? Date.now;
140
+ const sleep = deps.sleep ?? sleepFor;
141
+ const H = await (deps.loadHarness ?? defaultHarness)(root);
142
+ const { owner, proof } = H;
143
+ const policy = proof.WINDOW_POLICY;
144
+ if (!Number.isInteger(repeats) || repeats < policy.minRepeats || repeats > policy.maxRepeats) throw new Error(`repeats must be an integer from ${policy.minRepeats} to ${policy.maxRepeats}`);
145
+ const gitText = deps.git ?? ((args) => { const r = spawnSync("git", args, { cwd: root, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024 }); return r.error || r.status !== 0 ? null : (r.stdout ?? "").trim(); });
146
+ const blob = deps.gitBlob ?? ((full) => { const r = spawnSync("git", ["cat-file", "blob", `${commit}:${full}`], { cwd: root, encoding: "buffer", windowsHide: true, maxBuffer: 64 * 1024 * 1024 }); if (r.error || r.status !== 0) throw new Error(`candidate blob missing: ${full}`); return Buffer.from(r.stdout); });
147
+ const lsTree = (paths) => { const text = gitText(["ls-tree", "-r", "--name-only", commit, ...paths]); if (text === null) throw new Error("cannot list the candidate tree"); return text.split(/\r?\n/).filter(Boolean); };
148
+ const assertCheckout = (when) => {
149
+ const head = gitText(["rev-parse", "HEAD"]); const dirty = gitText(["status", "--porcelain"]);
150
+ if (head !== commit) throw new Error(`${when}: HEAD is ${String(head).slice(0, 12)}, not the candidate commit ${commit.slice(0, 12)}; the measured source changed`);
151
+ if (dirty !== "") throw new Error(`${when}: the checkout is not clean, so the measured source is not the candidate:\n${dirty}`);
152
+ };
153
+ assertCheckout("start");
154
+
155
+ // Trusted coverage first: a crate without it has nothing to run, and the candidate cannot supply its own list.
156
+ const trusted = owner.workloadsFor(crate);
157
+ const { prescription, workloads } = owner.readSourcePrescription(crate, blob);
158
+ if (JSON.stringify(workloads.map((w) => w.id)) !== JSON.stringify(trusted.map((w) => w.id))) throw new Error("candidate workloads differ from the trusted table");
159
+ const crateDir = path.join(root, "crates", crate);
160
+ let budget;
161
+ try { budget = JSON.parse(blob(`crates/${crate}/runtime-budget.json`)); } catch { throw new Error("candidate runtime-budget.json is missing or not JSON"); }
162
+
163
+ const bindings = {};
164
+ for (const full of [...lsTree([`crates/${crate}/src`]), `crates/${crate}/Cargo.toml`]) bindings[full] = sha256(blob(full));
165
+ Object.assign(bindings, owner.bindHarnessMetadata(crate, prescription, blob));
166
+ for (const full of lsTree(["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo", `crates/${crate}/benches`, "crates/rightkit-child-process/src", "crates/rightkit-child-process/Cargo.toml"])) bindings[full] = sha256(blob(full));
167
+ const { specs, problems: scopeProblems = [] } = (deps.measuredPathspecs ?? H.budgets.measuredPathspecs)(crateDir);
168
+ if (scopeProblems.length) throw new Error(`cannot bind the measured dependency scope: ${scopeProblems.join("; ")}`);
169
+ for (const full of lsTree(specs)) bindings[full] = sha256(blob(full));
170
+ const helpers = Object.fromEntries(H.helperPaths().map((name) => [name, sha256(blob(name))]));
171
+
172
+ const baseEnv = Object.fromEntries(Object.entries(deps.env ?? process.env).filter(([name]) => !CREDENTIAL_LIKE.test(name)));
173
+ const brokerEnv = Object.fromEntries(BROKER_ENV.filter((name) => baseEnv[name]).map((name) => [name, baseEnv[name]]));
174
+ const socket = discoverBrokerSocket(deps.env ?? process.env, { platform });
175
+ if (socket) brokerEnv.RIGHTKIT_BUILD_BROKER_SOCKET = socket;
176
+ const run = deps.run ?? ((command, args, settings) => spawnCollect(command, args, { maxBuffer: 64 * 1024 * 1024, ...settings }));
177
+ const invoke = (args, settings) => run(cargoCommand[0], [...cargoCommand.slice(1), ...args], { cwd: root, windowsHide: true, ...settings });
178
+ const toolchainOf = async () => {
179
+ const rustc = await run(cargoCommand[0] === "rightkit" ? "rightkit" : "rustc", cargoCommand[0] === "rightkit" ? ["rustc", "-Vv"] : ["-Vv"], { cwd: root, env: { ...baseEnv, ...brokerEnv }, windowsHide: true, timeout: 60_000 });
180
+ const cargo = await invoke(["-V"], { env: { ...baseEnv, ...brokerEnv }, timeout: 60_000 });
181
+ if (rustc.error || rustc.status !== 0 || cargo.error || cargo.status !== 0) throw new Error("cannot read the toolchain through the broker");
182
+ return { rustc: String(rustc.stdout).trim(), cargo: String(cargo.stdout).trim() };
183
+ };
184
+ const toolchain = await toolchainOf();
185
+
186
+ const healthFiles = deps.healthFiles ?? brokerHealthFiles({ env: deps.env ?? process.env, platform });
187
+ const readHealth = deps.readHealth ?? (() => readBrokerHealth({ files: healthFiles, now }));
188
+ const cpuSample = deps.sampleCpu ?? ((ms) => sampleCpu(ms, { sleep, now, platform }));
189
+ const listJobs = deps.listJobs ?? (() => listBrokerJobs({ client: broker.client, env: { ...baseEnv, ...brokerEnv }, run }));
190
+
191
+ async function awaitQuiet(label) {
192
+ const deadline = now() + waitIdleMs;
193
+ for (;;) {
194
+ let problems;
195
+ const first = readHealth();
196
+ problems = first ? proof.brokerIdleProblems(first, "before", policy) : ["before: no broker health snapshot is readable"];
197
+ if (!problems.length) {
198
+ const cpu = await cpuSample(policy.before.minCpuSampleMs + 500);
199
+ const confirm = readHealth();
200
+ problems = [...proof.cpuIdleProblems(cpu, "before", policy.before), ...(confirm ? proof.brokerIdleProblems(confirm, "before", policy) : ["before: no broker health snapshot is readable"])];
201
+ if (!problems.length) return { health: confirm, cpu };
202
+ }
203
+ if (now() >= deadline) throw new Error(`${label}: the host is not quiet${waitIdleMs ? ` after waiting ${Math.round(waitIdleMs / 60_000)} min` : ""}; no measurement was taken: ${problems.join("; ")}`);
204
+ await sleep(5000);
205
+ }
206
+ }
207
+
208
+ // The owner recorder rewrites runtime-budget.json and writes an evidence alias inside the checkout, as in CI; it is restored
209
+ // in `finally` and the checkout is verified clean again.
210
+ function recordRun({ workload, raw, index, command }) {
211
+ const budgetFile = path.join(crateDir, "runtime-budget.json");
212
+ const before = fs.readFileSync(budgetFile);
213
+ const alias = path.join(crateDir, "runtime-budget.evidence", `${platform}-${arch}-${version}-synthetic.txt`);
214
+ const prior = fs.existsSync(alias) ? fs.readFileSync(alias) : null;
215
+ const resultFile = path.join(scratch, `${workload.id}-run-${index}.txt`);
216
+ fs.mkdirSync(scratch, { recursive: true });
217
+ fs.writeFileSync(resultFile, raw);
218
+ try {
219
+ const record = (deps.recordMeasurement ?? H.budgets.recordMeasurement)({ crateDir, resultFile, scope: "synthetic", command, description: workload.description });
220
+ return owner.preserveWorkloadRecord(structuredClone(record), workload.id, crate);
221
+ } finally {
222
+ fs.writeFileSync(budgetFile, before);
223
+ if (prior !== null) fs.writeFileSync(alias, prior); else if (fs.existsSync(alias)) fs.unlinkSync(alias);
224
+ fs.rmSync(resultFile, { force: true });
225
+ }
226
+ }
227
+
228
+ const entryWorkloads = [];
229
+ for (const workload of trusted) {
230
+ const gate = owner.budgetForWorkload(budget, workload);
231
+ const command = `cargo ${workload.args.join(" ")}`;
232
+ // Warm build first: compilation is never part of a quiet window and never overlaps a measurement.
233
+ log(`[local-runtime] ${crate}/${workload.id}: warm build`);
234
+ const warm = await invoke(warmArgs(workload.args), { env: { ...baseEnv, ...brokerEnv }, timeout: BENCH_TIMEOUT_MS });
235
+ if (warm.error || warm.status !== 0) throw new Error(`${workload.id}: the warm build failed (${warm.error?.message ?? `exit ${warm.status}`})\n${tail(`${warm.stdout}\n${warm.stderr}`)}`);
236
+ const runs = [];
237
+ for (let index = 0; index < repeats; index += 1) {
238
+ assertCheckout(`${workload.id}#${index} before`);
239
+ const label = `${workload.id} run ${index + 1}/${repeats}`;
240
+ log(`[local-runtime] ${crate}/${label}: waiting for a quiet host`);
241
+ const quiet = await awaitQuiet(label);
242
+ // The broker's job listing at grant: read once the host is quiet and before the run starts. Nothing may be live.
243
+ let grantJobs;
244
+ try { grantJobs = listingRecord(await listJobs(), now()); } catch (error) { grantJobs = { checked: false, error: error.message }; }
245
+ let provisioned; let cleanup = null; let result; const during = [];
246
+ const startedAtMs = now();
247
+ try {
248
+ provisioned = H.workloadEnv.provisionWorkloadEnv(workload.env);
249
+ const env = { ...H.workloadEnv.workloadEnvironment(baseEnv, workload.env, provisioned.values), ...brokerEnv, CARGO_INCREMENTAL: "1" };
250
+ const running = invoke(workload.args, { env, timeout: BENCH_TIMEOUT_MS });
251
+ let finished = false;
252
+ running.then(() => { finished = true; }, () => { finished = true; });
253
+ while (!finished) {
254
+ // Only a snapshot the broker WROTE during the run is evidence of it, and each distinct write is kept once.
255
+ const sample = readHealth(); if (sample && sample.writtenAtMs >= startedAtMs && (!during.length || sample.writtenAtMs > during[during.length - 1].writtenAtMs)) during.push(sample);
256
+ await Promise.race([running.catch(() => {}), sleep(1000)]);
257
+ }
258
+ result = await running;
259
+ } catch (error) { result = { error, status: null, signal: null, stdout: "", stderr: `workload environment failed: ${error.message}` }; }
260
+ finally { try { provisioned?.dispose(); } catch (error) { cleanup = error.message; } }
261
+ const endedAtMs = now();
262
+ // Broker idle again, from a snapshot written after the run ended (the broker rewrites health every 5 s).
263
+ let afterHealth = null; const settleBy = now() + 20_000;
264
+ for (;;) {
265
+ const sample = readHealth(); if (sample) afterHealth = sample;
266
+ if (afterHealth && afterHealth.writtenAtMs >= endedAtMs && !proof.brokerIdleProblems(afterHealth, "after", policy).length) break;
267
+ if (now() >= settleBy) break;
268
+ await sleep(1000);
269
+ }
270
+ const afterCpu = await cpuSample(policy.after.minCpuSampleMs + 500);
271
+ const checkedAtMs = now();
272
+ let jobs;
273
+ try {
274
+ const listing = await listJobs();
275
+ jobs = { ...analyzeJobs({ jobs: listing, root, fromMs: quiet.cpu.startedAtMs, toMs: afterCpu.startedAtMs + afterCpu.durationMs, runStartMs: startedAtMs, checkedAtMs }), atGrant: grantJobs, atRelease: listingRecord(listing, checkedAtMs) };
276
+ } catch (error) { jobs = { checked: false, error: error.message }; }
277
+ const window = { policy: policy.id, startedAtMs, endedAtMs, before: { health: quiet.health, cpu: quiet.cpu }, during: { health: during }, after: { health: afterHealth, cpu: afterCpu }, jobs };
278
+ const windowIssues = proof.windowProblems(window, policy, { minWindowMs: proof.minWindowMsFor(crate, workload.id, policy), runStartedAtMs: startedAtMs, runEndedAtMs: endedAtMs });
279
+ window.quiet = windowIssues.length === 0;
280
+ assertCheckout(`${label} after`);
281
+ if (!window.quiet) throw new Error(`${label}: the window was not quiet (disturbed by something on this host; rerun when idle): ${windowIssues.join("; ")}`);
282
+ const raw = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
283
+ if (cleanup) throw new Error(`${label}: ${cleanup}`);
284
+ if (result.error || result.status !== 0) throw new Error(`${label}: the bench failed (${result.error?.message ?? `exit ${result.status}`})\n${tail(raw)}`);
285
+ let record;
286
+ try { record = recordRun({ workload, raw, index, command }); } catch (error) { throw new Error(`${label}: the owner recorder rejected the run: ${error.message}`); }
287
+ assertCheckout(`${label} recorded`);
288
+ const issues = owner.measurementProblems(record, gate, commit, platform);
289
+ if (issues.length) throw new Error(`${label}: over budget or unproven: ${issues.join("; ")}`);
290
+ runs.push({ index, startedAt: new Date(startedAtMs).toISOString(), endedAt: new Date(endedAtMs).toISOString(), exitCode: result.status, signal: result.signal ?? null, rawSha256: sha256(Buffer.from(raw)), raw, record, window });
291
+ log(`[local-runtime] ${crate}/${label}: ok (${JSON.stringify(record.observed)})`);
292
+ }
293
+ const selected = proof.worstIndex(runs.map((run) => run.record), gate);
294
+ const picked = runs[selected];
295
+ entryWorkloads.push({
296
+ schema: 2, source: "local", workload: workload.id, intervalMs: workload.intervalMs ?? null, crate, version, repository, runId: candidateId, runAttempt: "local", sourceSha: commit,
297
+ artifactId: "local", artifactDigest: null, harnessSha: commit, qualificationRunId: candidateId, qualificationRunAttempt: "local", manifestSha256: null, bindings, toolchain,
298
+ host: { os: platform, arch, release: (deps.release ?? os.release)(), runner: "local" }, command, invocation: [...cargoCommand, ...workload.args].join(" "), envNames: [...workload.env],
299
+ rawFile: `${workload.id}-bench.txt`, rawSha256: picked.rawSha256, exitCode: picked.exitCode, signal: null, problems: [], record: picked.record, selected, repeats: runs,
300
+ });
301
+ }
302
+ assertCheckout("end");
303
+ const cpus = (deps.cpus ?? os.cpus)();
304
+ return {
305
+ schema: 1, kind: "local-runtime-crate", source: "local", policy: policy.id, crate, version, sourceSha: commit, candidateId, manifestSha256: null, archiveSha256,
306
+ host: { os: platform, arch, release: (deps.release ?? os.release)(), model: cpus[0]?.model ?? null, cores: cpus.length }, broker: structuredClone(broker),
307
+ harness: { sourceSha: commit, helpers }, workloads: entryWorkloads,
308
+ };
309
+ }
310
+
311
+ /** Fills in what is only known once the packed manifest is sealed (the host measured before the driver sealed it). */
312
+ export function bindRuntimeManifest(runtime, manifestSha256) {
313
+ const bound = structuredClone(runtime);
314
+ for (const entry of Object.values(bound.crates)) {
315
+ entry.manifestSha256 = manifestSha256;
316
+ for (const item of entry.workloads) { item.manifestSha256 = manifestSha256; item.artifactDigest = `local:${manifestSha256}`; }
317
+ }
318
+ return bound;
319
+ }
package/lock-graph.mjs ADDED
@@ -0,0 +1,51 @@
1
+ // Pure Cargo.lock graph functions shipped with @rightkit/release for rightkit-gate (the local gate).
2
+ // KEEP IDENTICAL to parseLock, changedLockNames and reach in scripts/ci/select.mjs (RightKit CI, not shipped);
3
+ // rightkit-gate.test.mjs compares the two sources and fails on any difference. No process, no filesystem.
4
+
5
+ /**
6
+ * Parse Cargo.lock into name -> { signatures: string[], deps: Set<name> }.
7
+ * A package's signature covers version, source, checksum and its dependency list, so any change
8
+ * to what gets compiled changes it. Dependency edges are by name: over-approximate, never under.
9
+ */
10
+ export function parseLock(text) {
11
+ const packages = new Map();
12
+ for (const block of String(text ?? "").split(/^\[\[package\]\]\s*$/m).slice(1)) {
13
+ const name = /^name = "([^"]+)"/m.exec(block)?.[1];
14
+ if (!name) continue;
15
+ const field = (key) => new RegExp(`^${key} = "([^"]*)"`, "m").exec(block)?.[1] ?? "";
16
+ const depsRaw = /^dependencies = \[([^\]]*)\]/m.exec(block)?.[1] ?? "";
17
+ const deps = [...depsRaw.matchAll(/"([^"\s]+)[^"]*"/g)].map((m) => m[1]);
18
+ const entry = packages.get(name) ?? { signatures: [], deps: new Set() };
19
+ entry.signatures.push([field("version"), field("source"), field("checksum"), deps.join(",")].join("|"));
20
+ for (const dep of deps) entry.deps.add(dep);
21
+ packages.set(name, entry);
22
+ }
23
+ for (const entry of packages.values()) entry.signatures.sort();
24
+ return packages;
25
+ }
26
+
27
+ /** Names whose lock entry is new or different in `next`. */
28
+ export function changedLockNames(previous, next) {
29
+ const changed = new Set();
30
+ for (const [name, entry] of next) {
31
+ const before = previous.get(name);
32
+ if (!before || before.signatures.join("\n") !== entry.signatures.join("\n")) changed.add(name);
33
+ }
34
+ return changed;
35
+ }
36
+
37
+ /** Every package name reachable from `name` through the lock graph, `name` excluded. */
38
+ export function reach(lock, name, memo = new Map()) {
39
+ if (memo.has(name)) return memo.get(name);
40
+ const seen = new Set();
41
+ const stack = [...(lock.get(name)?.deps ?? [])];
42
+ while (stack.length) {
43
+ const next = stack.pop();
44
+ if (seen.has(next)) continue;
45
+ seen.add(next);
46
+ for (const dep of lock.get(next)?.deps ?? []) stack.push(dep);
47
+ }
48
+ seen.delete(name);
49
+ memo.set(name, seen);
50
+ return seen;
51
+ }
package/managed-cargo.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { discoverBrokerSocket, managedControlCandidates, resolveManagedRoots } from './managed-roots.mjs';
2
2
  import { randomUUID } from "node:crypto";
3
- import { homedir } from "node:os";
3
+ import { homedir, userInfo } from "node:os";
4
4
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
5
5
  import path from "node:path";
6
6
  import { spawn as spawnProcess, spawnSync } from "./background-process.mjs";
@@ -17,6 +17,9 @@ export function managedRuntimeClient(env = process.env, platform = process.platf
17
17
  if (platform === 'darwin') {
18
18
  const client = path.posix.join(env.HOME || homedir(), 'Library', 'Application Support', 'RustRight', 'runtime', 'rustright');
19
19
  if (exists(client)) return client;
20
+ // Broker-managed HOME may point at a build home; the installed native client belongs to the OS account.
21
+ const accountClient = path.posix.join(userInfo().homedir, 'Library', 'Application Support', 'RustRight', 'runtime', 'rustright');
22
+ if (exists(accountClient)) return accountClient;
20
23
  }
21
24
  // RustRight's own install on Windows (2026-10-18 cutover): the runtime moved out of the control root to
22
25
  // <drive>\.rustright\runtime, beside the control root's drive, and runtime-current was retired.
@@ -302,22 +302,39 @@ export function validateOwnershipFloor(trust, { scannerVersion, catalogVersion,
302
302
  if (trust.policyBundle?.digest && policyBundleDigest !== undefined && trust.policyBundle.digest !== policyBundleDigest) warnings.push(POLICY_BUNDLE_DRIFT_WARNING);
303
303
  return { ok: problems.length === 0, problems, warnings };
304
304
  }
305
- function protectedLocation(file, platform, { runner = spawnSync, inspect = lstatSync, stat = statSync, requireNonElevated = true } = {}) {
305
+ // Windows ACL answers, per runner, for a short window: every trust check walks the same ancestor directories (C:\, C:\Users, ...)
306
+ // and each answer costs a Windows PowerShell start (~2 s), so a scan of many files repeated the same lookups hundreds of times
307
+ // (the fingerprint-samples test: 187 scanner runs, ~8 ancestors each). An ACL that changes within the window is caught on the
308
+ // next check after it; a failed lookup is never cached.
309
+ const WINDOWS_ACL_MEMO = new WeakMap();
310
+ export const WINDOWS_ACL_MEMO_TTL_MS = 30_000;
311
+ function windowsAclLookup(runner, target, script, now) {
312
+ let memo = WINDOWS_ACL_MEMO.get(runner);
313
+ if (!memo) { memo = new Map(); WINDOWS_ACL_MEMO.set(runner, memo); }
314
+ const hit = memo.get(target);
315
+ if (hit && now - hit.at < WINDOWS_ACL_MEMO_TTL_MS) return hit.r;
316
+ const r = runner("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, env: windowsPowerShellEnvironment({ RK_TRUST_CHECK_TARGET: target }) });
317
+ if (r && r.status === 0) memo.set(target, { at: now, r }); else memo.delete(target);
318
+ return r;
319
+ }
320
+
321
+ function protectedLocation(file, platform, { runner = spawnSync, inspect = lstatSync, stat = statSync, requireNonElevated = true, now = Date.now } = {}) {
306
322
  const problems = [];
307
323
  if (platform === "win32") {
308
324
  const dirs = [];
309
- let current = path.dirname(file);
325
+ // Audit the requested platform's ancestors even when fixtures run on another host.
326
+ let current = path.win32.dirname(file);
310
327
  while (true) {
311
328
  dirs.push(current);
312
- if (path.dirname(current) === current) break;
313
- current = path.dirname(current);
329
+ if (path.win32.dirname(current) === current) break;
330
+ current = path.win32.dirname(current);
314
331
  }
315
332
  dirs.push(file);
316
333
  for (const target of dirs) {
317
334
  try { if (inspect(target).isSymbolicLink()) problems.push(`trust path contains a reparse/symlink: ${target}`); } catch { problems.push(`cannot inspect trust path: ${target}`); continue; }
318
335
  const script = "$ErrorActionPreference='Stop'; $a=Get-Acl -LiteralPath $env:RK_TRUST_CHECK_TARGET; $item=Get-Item -Force -LiteralPath $env:RK_TRUST_CHECK_TARGET; $id=[Security.Principal.WindowsIdentity]::GetCurrent(); $p=New-Object Security.Principal.WindowsPrincipal($id); $ownerSid=$a.GetOwner([Security.Principal.SecurityIdentifier]).Value; $ownerName=$a.Owner; $out=[ordered]@{ownerSID=$ownerSid;ownerName=$ownerName;attributes=$item.Attributes.ToString();directory=[bool]$item.PSIsContainer;nonempty=(!$item.PSIsContainer -or [System.IO.Directory]::EnumerateFileSystemEntries($item.FullName).GetEnumerator().MoveNext());elevated=$p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator);access=@($a.Access | ForEach-Object { [ordered]@{identitySID=$_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value;type=$_.AccessControlType.ToString();mask=[int64]$_.FileSystemRights.value__;propagationFlags=[int]$_.PropagationFlags} })}; $out | ConvertTo-Json -Compress -Depth 5";
319
- const r = runner("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, env: windowsPowerShellEnvironment({ RK_TRUST_CHECK_TARGET: target }) });
320
- if (r.status !== 0) { problems.push(`cannot inspect trust ACL: ${target}`); continue; }
336
+ const r = windowsAclLookup(runner, target, script, now());
337
+ if (!r || r.status !== 0) { problems.push(`cannot inspect trust ACL: ${target}`); continue; }
321
338
  try {
322
339
  const acl = JSON.parse(r.stdout.trim());
323
340
  const result = evaluateWindowsAcl(acl, { target, requireNonElevated });
@@ -795,12 +795,14 @@
795
795
  "cargo:serde_json",
796
796
  "cargo:tauri",
797
797
  "cargo:tauri-build",
798
+ "cargo:tauri-plugin-http",
798
799
  "cargo:tauri-plugin-os",
799
800
  "cargo:tauri-plugin-process",
800
801
  "cargo:tauri-plugin-updater",
801
802
  "npm:@tailwindcss/vite",
802
803
  "npm:@tauri-apps/api",
803
804
  "npm:@tauri-apps/cli",
805
+ "npm:@tauri-apps/plugin-http",
804
806
  "npm:@tauri-apps/plugin-os",
805
807
  "npm:@tauri-apps/plugin-process",
806
808
  "npm:@tauri-apps/plugin-updater",