@rightkit/release 0.6.24 → 0.6.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -35
- package/android-dev.mjs +2 -1
- package/app-check.mjs +1 -2
- package/app-closure.mjs +3 -2
- package/assembled-app.mjs +11 -3
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/bin/rightkit-gate.mjs +83 -26
- package/build-helper-trace.mjs +264 -0
- package/build-helpers-command.mjs +184 -0
- package/build-launch.mjs +11 -75
- package/build-release.mjs +10 -11
- package/cargo-profile-contract.mjs +19 -29
- package/cargo-target-shim.mjs +31 -0
- package/cli/right-release.mjs +5 -7
- package/create-mac-updater.mjs +4 -0
- package/cross-targets.mjs +94 -0
- package/deterministic-scans.mjs +310 -20
- package/development.mjs +8 -8
- package/freshness-census.mjs +1 -1
- package/gate-stages.mjs +1 -1
- package/install-assembled-app.mjs +1 -1
- package/install-tauri-authenticode.mjs +3 -3
- package/local-publish.mjs +27 -5
- package/mac-package-recipe.mjs +55 -2
- package/managed-cargo.mjs +27 -1
- package/managed-roots.mjs +12 -4
- package/package.json +1 -5
- package/pipeline-admission.mjs +1 -1
- package/pipeline-receipts.mjs +302 -302
- package/provenance-verify.mjs +3 -1
- package/qa-build-features.mjs +11 -2
- package/release-qa-audit.mjs +9 -27
- package/release-qa-command.mjs +23 -64
- package/release-qa-fixtures.mjs +9 -5
- package/release-qa-floor.json +0 -1
- package/release-qa-policy.mjs +9 -10
- package/release-qa-receipt.mjs +6 -44
- package/release-source-identity.mjs +1 -12
- package/release-switch-registry.json +4 -1
- package/rightkit-registry-resolver.mjs +35 -3
- package/rightkit-versions.json +2 -2
- package/sign-windows.mjs +1 -1
- package/snapshot-broker.mjs +33 -8
- package/snapshot-metadata.mjs +32 -0
- package/snapshot-request.mjs +6 -3
- package/stage-sidecar.mjs +28 -11
- package/suite-signing-policy.mjs +8 -58
- package/templates/cargo-profiles.toml +6 -25
- package/test-command.mjs +40 -140
- package/updater-shipped-key.mjs +26 -0
- package/web-build.mjs +29 -4
- package/qa-journeys-prebuilt-run.mjs +0 -18
- package/qa-journeys-prebuilt.mjs +0 -148
- package/qa-layout.mjs +0 -229
- package/qa-snapshot-run.mjs +0 -53
- package/qa-snapshot.mjs +0 -164
- package/sign-qa-bundle.mjs +0 -210
- package/suite-signing.mjs +0 -259
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// rightkit#1450 (Adrian, 2026-11-02: "can't we run every new crate with a helper, see exactly what it does, where it builds, ONCE and then add it
|
|
2
|
+
// to the list?" ... "yes, record it and implment it asap"). Replaces a per-job sandbox (rustright#197).
|
|
3
|
+
//
|
|
4
|
+
// A build-helper unit is a crates.io package that runs code at build time: a build script (`custom-build` target) or a proc macro. It is keyed
|
|
5
|
+
// exactly as cargo keys the unit it compiles: crate + version + checksum (Cargo.lock) + enabled features + target triple + toolchain. Class A
|
|
6
|
+
// keeps a HOST list of traced units. A unit not on the list was never seen doing its build-time work on this host: Class A refuses it, naming
|
|
7
|
+
// it, until it is traced once (built alone with every file write recorded: macOS fs_usage, Windows ETW). A trace passes when every write lies
|
|
8
|
+
// inside the unit's OUT_DIR, the build's target directory, CARGO_HOME or the temp directory; a pass is recorded and never traced again; a
|
|
9
|
+
// fail is recorded with the paths it wrote and keeps refusing. Any key change (a new version, checksum, feature set, target or toolchain) is a
|
|
10
|
+
// new unit and traces once more. Limit (accepted in #1450): a trace proves what the helper did with those inputs, not what a deliberately
|
|
11
|
+
// malicious crate could do later.
|
|
12
|
+
//
|
|
13
|
+
// Tracing needs admin rights on the host (fs_usage / ETW); enabling that is Adrian's host step. Everything here is pure except the list I/O.
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
export const BUILD_HELPER_LIST_SCHEMA = "rightkit.build-helper-trace/1";
|
|
19
|
+
export const BUILD_HELPER_UNTRACED = "RK_BUILD_HELPER_UNTRACED";
|
|
20
|
+
export const BUILD_HELPER_WROTE_OUTSIDE = "RK_BUILD_HELPER_WROTE_OUTSIDE";
|
|
21
|
+
const CRATES_IO = new Set(["registry+https://github.com/rust-lang/crates.io-index", "sparse+https://index.crates.io/"]);
|
|
22
|
+
|
|
23
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
24
|
+
|
|
25
|
+
/** The unit key: sha256 of the canonical {crate, version, checksum, features (sorted), target, toolchain}. */
|
|
26
|
+
export function buildHelperKey({ crate, version, checksum, features = [], target, toolchain }) {
|
|
27
|
+
return sha256(JSON.stringify({ crate, version, checksum, features: [...features].sort(), target, toolchain }));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Checksums of crates.io packages from a Cargo.lock text: Map "name version" -> checksum. */
|
|
31
|
+
export function lockChecksums(lockText) {
|
|
32
|
+
const out = new Map();
|
|
33
|
+
for (const block of String(lockText).split(/\n\[\[package\]\]\n/)) {
|
|
34
|
+
const name = /\nname = "([^"]+)"/.exec(`\n${block}`)?.[1];
|
|
35
|
+
const version = /\nversion = "([^"]+)"/.exec(`\n${block}`)?.[1];
|
|
36
|
+
const checksum = /\nchecksum = "([0-9a-f]{64})"/.exec(`\n${block}`)?.[1];
|
|
37
|
+
if (name && version && checksum) out.set(`${name} ${version}`, checksum);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every build-helper unit of a workspace, from `cargo metadata --format-version 1` (with `resolve`) and its Cargo.lock: crates.io packages
|
|
44
|
+
* in the resolved graph that have a `custom-build` target or are a proc macro, with the features cargo enabled for them.
|
|
45
|
+
*/
|
|
46
|
+
export function buildHelperUnits(metadata, lockText, { target, toolchain }) {
|
|
47
|
+
if (typeof target !== "string" || !target || typeof toolchain !== "string" || !toolchain) throw new TypeError("buildHelperUnits: target and toolchain are required");
|
|
48
|
+
const checksums = lockChecksums(lockText);
|
|
49
|
+
const features = new Map((metadata?.resolve?.nodes ?? []).map((n) => [n.id, Array.isArray(n.features) ? n.features : []]));
|
|
50
|
+
const inGraph = new Set((metadata?.resolve?.nodes ?? []).map((n) => n.id));
|
|
51
|
+
const units = [];
|
|
52
|
+
for (const pkg of metadata?.packages ?? []) {
|
|
53
|
+
if (!CRATES_IO.has(pkg.source) || !inGraph.has(pkg.id)) continue;
|
|
54
|
+
const kinds = new Set((pkg.targets ?? []).flatMap((t) => t.kind ?? []));
|
|
55
|
+
const role = kinds.has("custom-build") ? "build-script" : kinds.has("proc-macro") ? "proc-macro" : null;
|
|
56
|
+
if (!role) continue;
|
|
57
|
+
const checksum = checksums.get(`${pkg.name} ${pkg.version}`) ?? null;
|
|
58
|
+
const unit = { crate: pkg.name, version: pkg.version, checksum, features: [...(features.get(pkg.id) ?? [])].sort(), target, toolchain, role };
|
|
59
|
+
units.push({ ...unit, key: buildHelperKey(unit) });
|
|
60
|
+
}
|
|
61
|
+
return units.sort((a, b) => (a.crate === b.crate ? (a.version < b.version ? -1 : 1) : a.crate < b.crate ? -1 : 1));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The host list file: <managed data root>/build-helper-trace/<platform>.json. */
|
|
65
|
+
export const hostListPath = (dataRoot, platform = process.platform) => path.join(dataRoot, "build-helper-trace", `${platform}.json`);
|
|
66
|
+
|
|
67
|
+
/** The host list ({ schema, units: { [key]: entry } }); an absent file is an empty list, a malformed one throws. */
|
|
68
|
+
export function readHostList(file, { readFile = (f) => fs.readFileSync(f, "utf8"), exists = fs.existsSync } = {}) {
|
|
69
|
+
if (!exists(file)) return { schema: BUILD_HELPER_LIST_SCHEMA, units: {} };
|
|
70
|
+
const doc = JSON.parse(readFile(file));
|
|
71
|
+
if (doc?.schema !== BUILD_HELPER_LIST_SCHEMA || !doc.units || typeof doc.units !== "object") throw new Error(`${file} is not a ${BUILD_HELPER_LIST_SCHEMA} list`);
|
|
72
|
+
return doc;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Record one trace verdict in the list (atomic write). */
|
|
76
|
+
/** Tracers whose verdicts count: each follows the build's whole process tree. fs_usage (name-filtered) verdicts recorded before do not count. */
|
|
77
|
+
export const TRUSTED_TRACERS = Object.freeze(["eslogger", "strace-ff", "etw"]);
|
|
78
|
+
export const TRACER_OF = Object.freeze({ darwin: "eslogger", linux: "strace-ff", win32: "etw" });
|
|
79
|
+
|
|
80
|
+
export function recordTrace(file, unit, verdict, { now = () => new Date().toISOString(), fsApi = fs, tracer = TRACER_OF[process.platform] } = {}) {
|
|
81
|
+
const list = readHostList(file, { readFile: (f) => fsApi.readFileSync(f, "utf8"), exists: (f) => fsApi.existsSync(f) });
|
|
82
|
+
list.units[unit.key] = { crate: unit.crate, version: unit.version, checksum: unit.checksum, features: unit.features, target: unit.target, toolchain: unit.toolchain, role: unit.role, verdict: verdict.ok ? "pass" : "fail", outside: verdict.outside ?? [], tracer, tracedAt: now() };
|
|
83
|
+
fsApi.mkdirSync(path.dirname(file), { recursive: true });
|
|
84
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
85
|
+
fsApi.writeFileSync(tmp, `${JSON.stringify(list, null, 2)}\n`);
|
|
86
|
+
fsApi.renameSync(tmp, file);
|
|
87
|
+
return list;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Class A's verdict: `{ ok, untraced: [unit], failed: [{unit, outside}] }`. A unit with no checksum (not from crates.io's lock) is untraced by
|
|
92
|
+
* definition. Nothing listed as a pass under the same key ever runs a trace again.
|
|
93
|
+
*/
|
|
94
|
+
export function checkBuildHelpers(units, list) {
|
|
95
|
+
const untraced = []; const failed = [];
|
|
96
|
+
for (const u of units) {
|
|
97
|
+
const entry = list?.units?.[u.key];
|
|
98
|
+
if (!entry || !u.checksum || !TRUSTED_TRACERS.includes(entry.tracer)) untraced.push(u);
|
|
99
|
+
else if (entry.verdict !== "pass") failed.push({ unit: u, outside: entry.outside ?? [] });
|
|
100
|
+
}
|
|
101
|
+
return { ok: untraced.length === 0 && failed.length === 0, untraced, failed };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The refusal text Class A prints for a failing verdict, naming every unit (and the paths a failed one wrote). */
|
|
105
|
+
export function buildHelperRefusal(verdict) {
|
|
106
|
+
const lines = [];
|
|
107
|
+
for (const u of verdict.untraced) lines.push(`${BUILD_HELPER_UNTRACED}: ${u.crate} ${u.version} (${u.role}; features ${u.features.join(",") || "none"}; ${u.target}; ${u.toolchain}) has never been traced on this host; trace it once (rightkit-gate trace-build-helpers)`);
|
|
108
|
+
for (const f of verdict.failed) lines.push(`${BUILD_HELPER_WROTE_OUTSIDE}: ${f.unit.crate} ${f.unit.version} wrote outside its build directories when traced: ${f.outside.slice(0, 10).join(", ")}${f.outside.length > 10 ? ` (+${f.outside.length - 10} more)` : ""}`);
|
|
109
|
+
return lines;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Judge one trace: every written path must lie inside one of the allowed roots (the unit's OUT_DIR, the build's target directory,
|
|
114
|
+
* CARGO_HOME, the temp directory). Returns `{ ok, outside }` with the offending paths, sorted and unique.
|
|
115
|
+
*/
|
|
116
|
+
export function judgeTrace(writes, { allowed, platform = process.platform, mustSee = null }) {
|
|
117
|
+
const mod = platform === "win32" ? path.win32 : path.posix;
|
|
118
|
+
const norm = (p) => { const r = mod.resolve(p); return platform === "win32" ? r.toLowerCase() : r; };
|
|
119
|
+
const roots = (allowed ?? []).filter(Boolean).map(norm);
|
|
120
|
+
const inside = (p) => roots.some((r) => { const rel = mod.relative(r, p); return rel === "" || (!rel.startsWith("..") && !mod.isAbsolute(rel)); });
|
|
121
|
+
const outside = [...new Set(writes.map(norm).filter((p) => !inside(p)))].sort();
|
|
122
|
+
// Proof the tracer saw the build: at least one write under the build's own target. A tracer that started late or lost events sees
|
|
123
|
+
// nothing, and an empty trace must never pass.
|
|
124
|
+
if (mustSee) {
|
|
125
|
+
const root = norm(mustSee);
|
|
126
|
+
const saw = writes.map(norm).some((p) => { const rel = mod.relative(root, p); return rel === "" || (!rel.startsWith("..") && !mod.isAbsolute(rel)); });
|
|
127
|
+
if (!saw) return { ok: false, outside: [...outside, `(the tracer recorded no write under ${mustSee}: the build was not observed)`] };
|
|
128
|
+
}
|
|
129
|
+
return { ok: outside.length === 0, outside };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The written paths of macOS `fs_usage -w -f filesys <pid...>` output: lines whose call creates, writes, renames, links or removes a file,
|
|
134
|
+
* the path being the last absolute path on the line. Reads are ignored.
|
|
135
|
+
*/
|
|
136
|
+
export function parseFsUsageWrites(text) {
|
|
137
|
+
// fs_usage prints an open's mode as a flag group, e.g. "(R_____)" read, "(_W____)" write, "(_WC___)" create, "(_W_T__)" truncate.
|
|
138
|
+
const OPEN = /\bopen[a-z_]*\b[^(]*\(([A-Z_]{4,})\)/;
|
|
139
|
+
const MUTATING = /\b(creat|write|pwrite|writev|rename|renameat|renamex_np|link|linkat|symlink|unlink|unlinkat|mkdir|mkdirat|rmdir|truncate|ftruncate|clonefile|clonefileat|copyfile|exchangedata|setattrlist|chmod|fchmod|chown|fchown|utimes|futimes)\b/;
|
|
140
|
+
const out = new Set();
|
|
141
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
142
|
+
const open = OPEN.exec(line);
|
|
143
|
+
const writes = open ? /[WCTA]/.test(open[1]) : MUTATING.test(line);
|
|
144
|
+
if (!writes) continue;
|
|
145
|
+
const paths = line.match(/\/[^\s]+/g);
|
|
146
|
+
if (paths?.length) out.add(paths.at(-1));
|
|
147
|
+
}
|
|
148
|
+
return [...out];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The written paths of Linux `strace -f -e trace=<file calls>` output: successful calls that create, write, rename, link or remove a file.
|
|
153
|
+
* An open counts when its flags include O_WRONLY, O_RDWR, O_CREAT, O_TRUNC or O_APPEND; every other listed call mutates by definition.
|
|
154
|
+
* A failed call (`= -1`) wrote nothing. Every quoted path of a mutating call is taken (rename names both); a relative path resolves
|
|
155
|
+
* against `cwd` (the traced cargo's directory; a build script that changes directory is resolved approximately, a recorded limit).
|
|
156
|
+
*/
|
|
157
|
+
export function parseStraceWrites(text, { cwd = "/" } = {}) {
|
|
158
|
+
const OPEN = /\b(open|openat|openat2)\((?:[^"]*?)"((?:[^"\\]|\\.)*)"[^)]*?\b(O_[A-Z_|]+)/;
|
|
159
|
+
const MUTATING = /\b(creat|rename|renameat|renameat2|unlink|unlinkat|mkdir|mkdirat|rmdir|link|linkat|symlink|symlinkat|truncate|chmod|fchmodat|chown|fchownat|lchown|utimensat|utimes)\(/;
|
|
160
|
+
const out = new Set();
|
|
161
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
162
|
+
if (/=\s*-1\s/.test(line) || /<unfinished \.\.\.>/.test(line)) continue;
|
|
163
|
+
let paths = [];
|
|
164
|
+
const open = OPEN.exec(line);
|
|
165
|
+
if (open) { if (/O_WRONLY|O_RDWR|O_CREAT|O_TRUNC|O_APPEND/.test(open[3])) paths = [open[2]]; }
|
|
166
|
+
else if (MUTATING.test(line)) paths = [...line.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]);
|
|
167
|
+
for (const p of paths) if (p) out.add(path.posix.isAbsolute(p) ? p : path.posix.resolve(cwd, p));
|
|
168
|
+
}
|
|
169
|
+
return [...out];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The repository's committed list (`.rightkit/build-helper-trace.json`, same schema): reviewed passes every host and CI reads. */
|
|
173
|
+
export const repoListPath = (workspaceRoot) => path.join(workspaceRoot, ".rightkit", "build-helper-trace.json");
|
|
174
|
+
|
|
175
|
+
/** The host list and the repository list together: a unit passes when either holds a pass for its key and neither holds a fail. */
|
|
176
|
+
export function mergeLists(...lists) {
|
|
177
|
+
const units = {};
|
|
178
|
+
for (const list of lists) for (const [key, entry] of Object.entries(list?.units ?? {})) {
|
|
179
|
+
if (!units[key] || entry.verdict !== "pass") units[key] = entry;
|
|
180
|
+
}
|
|
181
|
+
return { schema: BUILD_HELPER_LIST_SCHEMA, units };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The written paths of a Windows ETW trace exported by `tracerpt -of XML` from two kernel sessions: Microsoft-Windows-Kernel-Process (process
|
|
186
|
+
* starts, to follow the build's own tree) and Microsoft-Windows-Kernel-File (create, create-new-file, write, delete-path, rename-path). Only
|
|
187
|
+
* events from `rootPid`'s descendants count. A Create counts as a write unless its disposition is FILE_OPEN (1); a Write event is named by the
|
|
188
|
+
* FileObject its Create recorded. Kernel paths (\Device\HarddiskVolumeN\...) are mapped back to drive letters through `devices`
|
|
189
|
+
* ({ "\\Device\\HarddiskVolume3": "D:" }).
|
|
190
|
+
*/
|
|
191
|
+
export function parseEtwWrites(xml, { rootPid, devices = {} }) {
|
|
192
|
+
const events = String(xml).split(/<Event[\s>]/).slice(1);
|
|
193
|
+
const field = (ev, name) => new RegExp(`<Data Name="${name}">([^<]*)</Data>`).exec(ev)?.[1];
|
|
194
|
+
const id = (ev) => Number(/<EventID>(\d+)<\/EventID>/.exec(ev)?.[1]);
|
|
195
|
+
const pidOf = (ev) => Number(/ProcessID="(\d+)"/.exec(ev)?.[1]);
|
|
196
|
+
const provider = (ev) => /Provider Name="([^"]+)"/.exec(ev)?.[1] ?? "";
|
|
197
|
+
const tree = new Set([Number(rootPid)]);
|
|
198
|
+
for (const ev of events) {
|
|
199
|
+
if (provider(ev) === "Microsoft-Windows-Kernel-Process" && id(ev) === 1) {
|
|
200
|
+
const child = Number(field(ev, "ProcessID")); const parent = Number(field(ev, "ParentProcessID"));
|
|
201
|
+
if (tree.has(parent)) tree.add(child);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
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; };
|
|
205
|
+
const names = new Map(); const out = new Set();
|
|
206
|
+
for (const ev of events) {
|
|
207
|
+
if (provider(ev) !== "Microsoft-Windows-Kernel-File" || !tree.has(pidOf(ev))) continue;
|
|
208
|
+
const n = id(ev);
|
|
209
|
+
if (n === 12) {
|
|
210
|
+
const name = field(ev, "FileName"); names.set(field(ev, "FileObject"), name);
|
|
211
|
+
const disposition = (Number(field(ev, "CreateOptions") ?? 0) >>> 24) & 0xff;
|
|
212
|
+
if (disposition !== 1) out.add(toDos(name));
|
|
213
|
+
} else if (n === 30) out.add(toDos(field(ev, "FileName")));
|
|
214
|
+
else if (n === 16) { const name = names.get(field(ev, "FileObject")); if (name) out.add(toDos(name)); }
|
|
215
|
+
else if (n === 26 || n === 27) { const name = field(ev, "FilePath") ?? field(ev, "FileName"); if (name) out.add(toDos(name)); }
|
|
216
|
+
}
|
|
217
|
+
out.delete(null);
|
|
218
|
+
return [...out];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The written paths of a macOS `eslogger` trace (Endpoint Security, JSON lines) restricted to `rootPid`'s process tree. Each event names
|
|
223
|
+
* its process and that process's parent (`process.audit_token.pid`, `process.ppid`), so every child a build script starts (sh, cc, cmake,
|
|
224
|
+
* perl) is followed, whatever its name. Writes: create, rename (both sides), unlink, link, truncate, an open with FWRITE, and a close that
|
|
225
|
+
* modified the file.
|
|
226
|
+
*/
|
|
227
|
+
export function parseEsloggerWrites(text, { rootPid }) {
|
|
228
|
+
const events = [];
|
|
229
|
+
for (const line of String(text).split(/\r?\n/)) { if (!line.trim().startsWith("{")) continue; try { events.push(JSON.parse(line)); } catch { /* a partial last line */ } }
|
|
230
|
+
const parent = new Map();
|
|
231
|
+
for (const e of events) {
|
|
232
|
+
const pid = e?.process?.audit_token?.pid; const ppid = e?.process?.ppid ?? e?.process?.original_ppid;
|
|
233
|
+
if (Number.isInteger(pid) && Number.isInteger(ppid) && !parent.has(pid)) parent.set(pid, ppid);
|
|
234
|
+
const child = e?.event?.fork?.child?.audit_token?.pid;
|
|
235
|
+
if (Number.isInteger(child) && Number.isInteger(pid)) parent.set(child, pid);
|
|
236
|
+
}
|
|
237
|
+
const inTree = (pid) => { for (let p = pid, hops = 0; Number.isInteger(p) && hops < 64; p = parent.get(p), hops += 1) if (p === rootPid) return true; return false; };
|
|
238
|
+
const joined = (np) => (np?.dir?.path && np?.filename ? `${np.dir.path.replace(/\/$/, "")}/${np.filename}` : null);
|
|
239
|
+
const dest = (d) => d?.existing_file?.path ?? joined(d?.new_path) ?? null;
|
|
240
|
+
const out = new Set();
|
|
241
|
+
for (const e of events) {
|
|
242
|
+
if (!inTree(e?.process?.audit_token?.pid)) continue;
|
|
243
|
+
const ev = e?.event ?? {};
|
|
244
|
+
const add = (p) => { if (typeof p === "string" && p) out.add(p); };
|
|
245
|
+
if (ev.create) add(dest(ev.create.destination));
|
|
246
|
+
if (ev.rename) { add(ev.rename.source?.path); add(dest(ev.rename.destination)); }
|
|
247
|
+
if (ev.unlink) add(ev.unlink.target?.path);
|
|
248
|
+
if (ev.link) { add(ev.link.source?.path); add(ev.link.target_dir?.path && ev.link.target_filename ? `${ev.link.target_dir.path.replace(/\/$/, "")}/${ev.link.target_filename}` : null); }
|
|
249
|
+
if (ev.truncate) add(ev.truncate.target?.path);
|
|
250
|
+
// std::fs::copy on macOS clones first (fclonefileat): the destination is created without an open for writing.
|
|
251
|
+
if (ev.clone) add(ev.clone.target_dir?.path && ev.clone.target_name ? `${ev.clone.target_dir.path.replace(/\/$/, "")}/${ev.clone.target_name}` : null);
|
|
252
|
+
if (ev.copyfile) add(ev.copyfile.target_file?.path ?? (ev.copyfile.target_dir?.path && ev.copyfile.target_name ? `${ev.copyfile.target_dir.path.replace(/\/$/, "")}/${ev.copyfile.target_name}` : null));
|
|
253
|
+
if (ev.exchangedata) { add(ev.exchangedata.file1?.path); add(ev.exchangedata.file2?.path); }
|
|
254
|
+
if (ev.open && (Number(ev.open.fflag) & 0x2)) add(ev.open.file?.path);
|
|
255
|
+
if (ev.close && ev.close.modified) add(ev.close.target?.path);
|
|
256
|
+
}
|
|
257
|
+
return [...out];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The strace -ff output files (`<prefix>.<pid>`, one per process: no call is ever split across lines). */
|
|
261
|
+
export function readStraceFiles(prefix, { fsApi = fs } = {}) {
|
|
262
|
+
const dir = path.dirname(prefix); const base = path.basename(prefix);
|
|
263
|
+
return fsApi.readdirSync(dir).filter((n) => n.startsWith(`${base}.`)).map((n) => fsApi.readFileSync(path.join(dir, n), "utf8")).join("\n");
|
|
264
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `right-release build-helpers check [--manifest-path <Cargo.toml>]` (Class A, rightkit#1450) and
|
|
3
|
+
// `right-release build-helpers trace [--manifest-path <Cargo.toml>]` (the one-time trace: eslogger on macOS (sudo + Full Disk Access), strace -ff on Linux, ETW on Windows (Administrator)).
|
|
4
|
+
//
|
|
5
|
+
// check: reads the workspace's resolved graph (`cargo metadata --format-version 1 --locked`, a non-compiling call run directly: GATE-CONTRACT
|
|
6
|
+
// GC-A11), its Cargo.lock, the host triple and toolchain (`rustc -vV`), and the host list, and refuses (exit 1) when any build-helper unit is
|
|
7
|
+
// untraced or traced as writing outside its build directories, naming each.
|
|
8
|
+
// trace: for each untraced unit, builds the workspace's dependency alone (`cargo build -p <crate>@<version> --locked`) under the host's tracer, into a
|
|
9
|
+
// scratch target, judges every write against OUT_DIR / target / CARGO_HOME / temp, and records the verdict in the host list.
|
|
10
|
+
// check --trace-missing (Linux only, where strace needs no admin; CI and Linux hosts): untraced units are traced first, then judged.
|
|
11
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { buildHelperRefusal, buildHelperUnits, checkBuildHelpers, hostListPath, judgeTrace, mergeLists, parseEsloggerWrites, parseEtwWrites, parseStraceWrites, readStraceFiles, readHostList, recordTrace, repoListPath } from "./build-helper-trace.mjs";
|
|
17
|
+
import { resolveManagedRoots } from "./managed-roots.mjs";
|
|
18
|
+
import { directCargoPath } from "./managed-cargo.mjs";
|
|
19
|
+
|
|
20
|
+
function hostFacts(run = spawnSync) {
|
|
21
|
+
const r = run("rustc", ["-vV"], { encoding: "utf8" });
|
|
22
|
+
if (r.error || r.status !== 0) throw new Error(`rustc -vV failed (${r.error?.message ?? `exit ${r.status}`})`);
|
|
23
|
+
const host = /^host: (\S+)$/m.exec(r.stdout)?.[1];
|
|
24
|
+
const release = /^release: (\S+)$/m.exec(r.stdout)?.[1];
|
|
25
|
+
const commit = /^commit-hash: (\S+)$/m.exec(r.stdout)?.[1];
|
|
26
|
+
if (!host || !release) throw new Error("rustc -vV printed no host or release");
|
|
27
|
+
return { target: host, toolchain: `rustc ${release}${commit ? ` (${commit.slice(0, 9)})` : ""}` };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function inventory({ manifestPath, run = spawnSync, readFile = (f) => fs.readFileSync(f, "utf8") }) {
|
|
31
|
+
const args = ["metadata", "--format-version", "1", "--locked", ...(manifestPath ? ["--manifest-path", manifestPath] : [])];
|
|
32
|
+
const r = run("cargo", args, { encoding: "utf8", maxBuffer: 1 << 28 });
|
|
33
|
+
if (r.error || r.status !== 0) throw new Error(`cargo ${args.join(" ")} failed (${r.error?.message ?? `exit ${r.status}`}): ${String(r.stderr ?? "").trim().split(/\r?\n/).slice(-2).join(" | ")}`);
|
|
34
|
+
const metadata = JSON.parse(r.stdout);
|
|
35
|
+
const lockText = readFile(path.join(metadata.workspace_root, "Cargo.lock"));
|
|
36
|
+
return { metadata, units: buildHelperUnits(metadata, lockText, hostFacts(run)) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Every Cargo workspace (a directory holding Cargo.lock) under `root`, skipping build outputs, dependencies and vendored copies.
|
|
40
|
+
export function cargoWorkspaces(root, { depth = 4, fsApi = fs } = {}) {
|
|
41
|
+
const SKIP = new Set(["target", "node_modules", ".build", ".git", "vendor", "t", "dist"]);
|
|
42
|
+
const found = [];
|
|
43
|
+
const walk = (dir, left) => {
|
|
44
|
+
let entries; try { entries = fsApi.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
45
|
+
if (entries.some((e) => e.isFile() && e.name === "Cargo.lock") && entries.some((e) => e.isFile() && e.name === "Cargo.toml")) found.push(dir);
|
|
46
|
+
if (left > 0) for (const e of entries) if (e.isDirectory() && !SKIP.has(e.name) && !e.name.startsWith(".")) walk(path.join(dir, e.name), left - 1);
|
|
47
|
+
};
|
|
48
|
+
walk(root, depth);
|
|
49
|
+
// Only repositories the gate builds: the workspace's repository root (nearest .git) carries a .rightkit directory.
|
|
50
|
+
const gated = (dir) => { for (let d = dir; ; d = path.dirname(d)) { if (fsApi.existsSync(path.join(d, ".git"))) return fsApi.existsSync(path.join(d, ".rightkit")); if (path.dirname(d) === d) return false; } };
|
|
51
|
+
return found.filter(gated).sort();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// `right-release build-helpers trace-all [--root <dir>]...`: one admin prompt, every workspace. On macOS it asks for the password once
|
|
55
|
+
// (`sudo -v`) and keeps that credential alive while it runs; each workspace's untraced units are traced and recorded in the host list and
|
|
56
|
+
// in the workspace's own committed list (--record-repo). Default root: /Volumes/D/claude (Mac), D:\claude (Windows), the current
|
|
57
|
+
// directory elsewhere.
|
|
58
|
+
function traceAll(argv, { env, platform, log, err }) {
|
|
59
|
+
const roots = argv.flatMap((a, i) => (a === "--root" && argv[i + 1] ? [path.resolve(argv[i + 1])] : []));
|
|
60
|
+
if (!roots.length) roots.push(platform === "darwin" ? "/Volumes/D/claude" : platform === "win32" ? "D:\\claude" : process.cwd());
|
|
61
|
+
let keepAlive = null;
|
|
62
|
+
if (platform === "darwin") {
|
|
63
|
+
log("right-release build-helpers trace-all: eslogger needs administrator rights (and Full Disk Access for this terminal); enter your password once.");
|
|
64
|
+
const ok = spawnSync("sudo", ["-v"], { stdio: "inherit" });
|
|
65
|
+
if (ok.status !== 0) { err("right-release build-helpers trace-all: sudo was not granted; nothing traced"); return 2; }
|
|
66
|
+
keepAlive = spawn("sh", ["-c", `while kill -0 ${process.pid} 2>/dev/null; do sudo -n -v; sleep 50; done`], { stdio: "ignore", detached: true });
|
|
67
|
+
keepAlive.unref();
|
|
68
|
+
}
|
|
69
|
+
const workspaces = roots.flatMap((r) => cargoWorkspaces(r));
|
|
70
|
+
log(`right-release build-helpers trace-all: ${workspaces.length} Cargo workspace(s) under ${roots.join(", ")}`);
|
|
71
|
+
let worst = 0;
|
|
72
|
+
try {
|
|
73
|
+
for (const ws of workspaces) {
|
|
74
|
+
log(`--- ${ws}`);
|
|
75
|
+
const code = main(["trace", "--manifest-path", path.join(ws, "Cargo.toml"), "--record-repo"], { env, platform, log, err });
|
|
76
|
+
if (code !== 0) err(`right-release build-helpers trace-all: ${ws} ended with ${code}`);
|
|
77
|
+
worst = Math.max(worst, code);
|
|
78
|
+
}
|
|
79
|
+
} 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`);
|
|
81
|
+
return worst;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function main(argv = process.argv.slice(2), { env = process.env, platform = process.platform, run: runRaw = null, log = console.log, err = console.error } = {}) {
|
|
85
|
+
if (argv[0] === "trace-all") return traceAll(argv.slice(1), { env, platform, log, err });
|
|
86
|
+
// GATE-CONTRACT GC-A11: the check's cargo and rustc calls compile nothing and run directly with the real toolchain (RustRight shims off PATH),
|
|
87
|
+
// writing to a scratch target, never through the broker.
|
|
88
|
+
const pathKey = platform === "win32" ? (Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "Path") : "PATH";
|
|
89
|
+
const directPath = directCargoPath({ env: { ...env, PATH: env[pathKey] }, platform, gateWrappers: true }).path;
|
|
90
|
+
const scratchTarget = fs.mkdtempSync(path.join(os.tmpdir(), "rk-bh-target-"));
|
|
91
|
+
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
|
+
try { return mainWith(argv, { env, platform, run, log, err }); } finally { fs.rmSync(scratchTarget, { recursive: true, force: true }); }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function mainWith(argv, { env, platform, run, log, err }) {
|
|
96
|
+
const [sub, ...rest] = argv;
|
|
97
|
+
const at = rest.indexOf("--manifest-path");
|
|
98
|
+
const manifestPath = at >= 0 ? rest[at + 1] : undefined;
|
|
99
|
+
const listFile = hostListPath(resolveManagedRoots({ env, platform }).dataRoot, platform);
|
|
100
|
+
let inv;
|
|
101
|
+
try { inv = inventory({ manifestPath, run }); } catch (e) { err(`right-release build-helpers: ${e.message}`); return 2; }
|
|
102
|
+
// The host's own list and the repository's committed list (.rightkit/build-helper-trace.json): CI and every host read the same reviewed
|
|
103
|
+
// passes, and a host traces only what neither holds.
|
|
104
|
+
const repoFile = repoListPath(inv.metadata.workspace_root);
|
|
105
|
+
const list = mergeLists(readHostList(listFile), readHostList(repoFile));
|
|
106
|
+
const verdict = checkBuildHelpers(inv.units, list);
|
|
107
|
+
if (sub === "check" && !verdict.ok && rest.includes("--trace-missing") && platform === "linux" && verdict.untraced.length) {
|
|
108
|
+
log(`right-release build-helpers: ${verdict.untraced.length} untraced unit(s) on this Linux host; tracing them now (strace -ff)`);
|
|
109
|
+
const traced = mainWith(["trace", ...(manifestPath ? ["--manifest-path", manifestPath] : [])], { env, platform, run, log, err });
|
|
110
|
+
if (traced !== 0) return traced;
|
|
111
|
+
return mainWith(["check", ...(manifestPath ? ["--manifest-path", manifestPath] : [])], { env, platform, run, log, err });
|
|
112
|
+
}
|
|
113
|
+
if (sub === "check") {
|
|
114
|
+
if (verdict.ok) { log(`right-release build-helpers: ${inv.units.length} build-helper unit(s), every one traced on this host (${listFile})`); return 0; }
|
|
115
|
+
for (const line of buildHelperRefusal(verdict)) err(line);
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
if (sub === "trace") {
|
|
119
|
+
let devices = {};
|
|
120
|
+
if (platform === "win32") {
|
|
121
|
+
// ETW kernel sessions need an elevated shell; kernel paths are mapped back to drive letters once (QueryDosDevice).
|
|
122
|
+
const admin = run("net", ["session"], { encoding: "utf8" });
|
|
123
|
+
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(\\\"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
|
+
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]; }
|
|
126
|
+
}
|
|
127
|
+
const recordRepo = rest.includes("--record-repo");
|
|
128
|
+
const cargoHome = env.CARGO_HOME ?? path.join(os.homedir(), ".cargo");
|
|
129
|
+
for (const unit of [...verdict.untraced, ...verdict.failed.map((f) => f.unit)]) {
|
|
130
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "rk-bht-"));
|
|
131
|
+
const traceFile = path.join(scratch, "trace.txt");
|
|
132
|
+
const buildArgs = ["build", "-p", `${unit.crate}@${unit.version}`, "--locked", "--target-dir", path.join(scratch, "target"), ...(manifestPath ? ["--manifest-path", manifestPath] : [])];
|
|
133
|
+
const buildEnv = { ...env, CARGO_TARGET_DIR: path.join(scratch, "target") };
|
|
134
|
+
let build;
|
|
135
|
+
if (platform === "win32") {
|
|
136
|
+
const etlFile = path.join(scratch, "file.etl"); const etlProc = path.join(scratch, "proc.etl");
|
|
137
|
+
run("logman", ["stop", "rk-bht-file", "-ets"]); run("logman", ["stop", "rk-bht-proc", "-ets"]);
|
|
138
|
+
const startProc = run("logman", ["start", "rk-bht-proc", "-p", "Microsoft-Windows-Kernel-Process", "0x10", "-o", etlProc, "-ets"], { encoding: "utf8" });
|
|
139
|
+
const startFile = run("logman", ["start", "rk-bht-file", "-p", "Microsoft-Windows-Kernel-File", "0x1E90", "-o", etlFile, "-ets"], { encoding: "utf8" });
|
|
140
|
+
if (startProc.status !== 0 || startFile.status !== 0) { err(`right-release build-helpers trace: logman could not start the ETW sessions: ${String(startFile.stdout ?? startProc.stdout ?? "").trim()}`); return 2; }
|
|
141
|
+
try { build = run("cargo", buildArgs, { encoding: "utf8", env: buildEnv }); }
|
|
142
|
+
finally { run("logman", ["stop", "rk-bht-file", "-ets"]); run("logman", ["stop", "rk-bht-proc", "-ets"]); }
|
|
143
|
+
const xml = path.join(scratch, "trace.xml");
|
|
144
|
+
const exported = run("tracerpt", [etlProc, etlFile, "-o", xml, "-of", "XML", "-y"], { encoding: "utf8" });
|
|
145
|
+
if (exported.status !== 0) { err("right-release build-helpers trace: tracerpt could not export the ETW trace"); return 2; }
|
|
146
|
+
fs.writeFileSync(traceFile, fs.readFileSync(xml, "utf8"));
|
|
147
|
+
} else if (platform === "linux") {
|
|
148
|
+
// strace follows every child (-f) and records the file calls; it needs no root for the agent's own processes.
|
|
149
|
+
// -ff: one output file per process, so no system call is split across <unfinished>/<resumed> lines.
|
|
150
|
+
build = run("strace", ["-ff", "-qq", "-o", traceFile, "-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], { encoding: "utf8", env: buildEnv });
|
|
151
|
+
if (build.error?.code === "ENOENT") { err("right-release build-helpers trace: strace is not installed on this host"); return 2; }
|
|
152
|
+
} else {
|
|
153
|
+
// Endpoint Security (eslogger) names every event's process and its parent, so the whole tree under this process is traced: build
|
|
154
|
+
// scripts and everything they start (sh, cc, cmake, perl), whatever the name. Only the logger is root (`sudo -n`: the credential
|
|
155
|
+
// `trace-all` cached with one `sudo -v`); the build runs as the user. eslogger needs Full Disk Access for the terminal running it.
|
|
156
|
+
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" });
|
|
157
|
+
const tracerPid = String(tracer.stdout ?? "").trim();
|
|
158
|
+
run("sleep", ["2"]);
|
|
159
|
+
build = run("cargo", buildArgs, { encoding: "utf8", env: buildEnv });
|
|
160
|
+
run("sleep", ["1"]);
|
|
161
|
+
if (tracerPid) { run("sudo", ["-n", "pkill", "-INT", "-P", tracerPid]); run("kill", [tracerPid]); run("sleep", ["1"]); }
|
|
162
|
+
}
|
|
163
|
+
if (build.error || build.status !== 0) { err(`right-release build-helpers trace: ${unit.crate} ${unit.version} did not build (${build.error?.message ?? `exit ${build.status}`}); not recorded`); continue; }
|
|
164
|
+
let text = ""; try { text = platform === "linux" ? readStraceFiles(traceFile) : fs.readFileSync(traceFile, "utf8"); } catch { /* no trace output */ }
|
|
165
|
+
if (!text.trim()) { err(`right-release build-helpers trace: ${platform === "linux" ? "strace" : platform === "darwin" ? "eslogger (it needs sudo and Full Disk Access for this terminal)" : "ETW"} recorded nothing; nothing recorded`); return 2; }
|
|
166
|
+
const writes = platform === "win32" ? parseEtwWrites(text, { rootPid: process.pid, devices })
|
|
167
|
+
: platform === "linux" ? parseStraceWrites(text, { cwd: path.dirname(path.resolve(manifestPath ?? "Cargo.toml")) }) : parseEsloggerWrites(text, { rootPid: process.pid });
|
|
168
|
+
const allowed = platform === "win32" ? [path.join(scratch, "target"), cargoHome, os.tmpdir(), env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, "Temp") : null]
|
|
169
|
+
: [path.join(scratch, "target"), cargoHome, os.tmpdir(), "/private/var/folders", "/dev", "/proc/self"];
|
|
170
|
+
const judged = judgeTrace(writes, { allowed, platform, mustSee: path.join(scratch, "target") });
|
|
171
|
+
recordTrace(listFile, unit, judged);
|
|
172
|
+
// --record-repo also writes the verdict into the repository's committed list, for review and commit by the repository's owner.
|
|
173
|
+
if (recordRepo) recordTrace(repoFile, unit, judged);
|
|
174
|
+
log(`${judged.ok ? "pass" : "FAIL"} ${unit.crate} ${unit.version}${judged.ok ? "" : `: wrote ${judged.outside.slice(0, 5).join(", ")}`}`);
|
|
175
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
176
|
+
}
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
err("usage: right-release build-helpers check|trace [--manifest-path <Cargo.toml>] [--record-repo] | trace-all [--root <dir>]...");
|
|
180
|
+
return 2;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const invoked = (() => { try { return fs.realpathSync(process.argv[1] ?? "") === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
|
|
184
|
+
if (invoked) process.exit(main());
|
package/build-launch.mjs
CHANGED
|
@@ -7,13 +7,8 @@
|
|
|
7
7
|
// - cargo <args> -> <rustright> cargo <args> (by the resolved broker client, whatever path the caller named: a real
|
|
8
8
|
// cargo binary is never launched around the broker)
|
|
9
9
|
// - rustright <args> -> <rustright> <args> (the resolved broker client)
|
|
10
|
-
// - rightkit-qa <args> ->
|
|
11
|
-
//
|
|
12
|
-
// already-built artifact and compiles nothing, so it stays a direct, gate-run step with its GUI session and
|
|
13
|
-
// unscrubbed environment. A `run-tests` step with no `--from-cargo-json` is two parts (qa-snapshot-run.mjs): a
|
|
14
|
-
// carried test build whose listing lands under the carrier's managed directory, then QA directly against
|
|
15
|
-
// it. A `journeys run` step is the same two parts (qa-journeys-prebuilt-run.mjs, GB-79: the gate carries the build, signs the products, hands QA the verified input) when rightkit-qa advertises `journeys-prebuilt-input`, else refused naming it. Any other QA step whose argv would build (`--build*`) is refused (`qaBuildReason`), as
|
|
16
|
-
// is a QA directory outside the snapshot or an input that resolves into the primary checkout (`qaPathReason`)
|
|
10
|
+
// - rightkit-qa <args> -> refused on every route (GATE-CONTRACT GC-A07): a build stage runs no QA step; QA journeys run after the build
|
|
11
|
+
// against the installed dev copy (`right-release qa --stage installed`, which starts the runner itself, never through this door)
|
|
17
12
|
// - this package's right-release -> unchanged (same NODE_OPTIONS as this process): it orchestrates, and every build it starts comes back
|
|
18
13
|
// through this door
|
|
19
14
|
// A SNAPSHOT STAGE (the gate's carrier map is in the environment, RIGHTKIT_CARRIED_CARGO_MAP) carries every one of those steps itself: a
|
|
@@ -25,7 +20,7 @@
|
|
|
25
20
|
// environment: the door sets RUSTRIGHT_ADAPTER_ENV to exactly those names (adapter-env.mjs), the broker accepts the names the adapter's
|
|
26
21
|
// registration lists in `env`, and the values are bound into the signed request. A reserved name is refused here; a step with no env gets no
|
|
27
22
|
// RUSTRIGHT_ADAPTER_ENV.
|
|
28
|
-
// - rustright run <adapter> -- <args> on a snapshot stage -> the same step as the adapter's own program (pnpm, node,
|
|
23
|
+
// - rustright run <adapter> -- <args> on a snapshot stage -> the same step as the adapter's own program (pnpm, node, swift), so
|
|
29
24
|
// the door's rules apply to it; any other `rustright <command>` there is refused (RK_BUILD_LAUNCH_REFUSED)
|
|
30
25
|
// - pnpm exec <target> -> only a target in EXEC_TARGETS (tauri, right-release, vite) on a broker host: the broker's pnpm entry
|
|
31
26
|
// has no target allowlist, so `pnpm exec node -e ...` would otherwise pass the node flag ban
|
|
@@ -51,15 +46,13 @@ import { cargoSubcommandOf, canonicalCargoCommand } from "./cargo-shapes.mjs";
|
|
|
51
46
|
import { swiftScratchRefusal } from "./swift-scratch.mjs";
|
|
52
47
|
import { NATIVE_ADAPTERS, NATIVE_SIGNING_FILE_ENV, nativeCallFsProblem, nativeManagedOutputs, nativeOutputsFromReceipt, nativeSigningFileRefusal, nativeStateDir, nativeToolLaunch, nativeToolOf } from "./native-adapters.mjs";
|
|
53
48
|
import { PNPM_ADAPTER_GRAMMAR, adapterArgvRefusal, liveAdapterGrammar, scanAdapterArgv } from "./pnpm-adapter-grammar.mjs";
|
|
54
|
-
import { isJourneysRun, journeysCapabilityRefusal, journeysRunRefusal, qaPrebuiltCapability } from "./qa-journeys-prebuilt.mjs";
|
|
55
|
-
import { QA_VALUE_OPTIONS, hasListing, isBuildFlag, ownWords, qaPathReason, qaSnapshotPlan } from "./qa-snapshot.mjs";
|
|
56
49
|
|
|
57
50
|
/** The environment variable the door sets for a stage that runs from a commit snapshot (gate-stages.mjs CHECKOUT_ENV). */
|
|
58
51
|
const SNAPSHOT_STAGE_ENV = "RK_GATE_CHECKOUT";
|
|
59
52
|
|
|
60
53
|
export const BUILD_LAUNCH_REFUSED = "RK_BUILD_LAUNCH_REFUSED";
|
|
61
|
-
/**
|
|
62
|
-
|
|
54
|
+
/** Why a `rightkit-qa` step never launches through this door (GATE-CONTRACT GC-A07). */
|
|
55
|
+
const QA_STEP_REMOVED = "a build or test step runs no QA (GATE-CONTRACT GC-A07): QA journeys run after the build against the installed dev copy, started by `right-release qa --stage installed`";
|
|
63
56
|
const OWN_RIGHT_RELEASE = fileURLToPath(new URL("./cli/right-release.mjs", import.meta.url));
|
|
64
57
|
// GB-06: the helpers the package recipes generate (package-recipe-targets.mjs) are this package's own release operations, run by this very Node binary at their exact installed
|
|
65
58
|
// paths, like right-release itself: they orchestrate in the gate's own session, and every build-capable child they start comes back through this door as a carried step.
|
|
@@ -69,8 +62,6 @@ const CREATE_MAC_UPDATER_HELPER = fileURLToPath(new URL("./create-mac-updater.mj
|
|
|
69
62
|
export const INTERNAL_RELEASE_HELPERS = Object.freeze([OWN_RIGHT_RELEASE, RECIPE_BUILD_HELPER, CREATE_MAC_UPDATER_HELPER]);
|
|
70
63
|
const CARRIED_RUN = fileURLToPath(new URL("./carried-cargo-run.mjs", import.meta.url));
|
|
71
64
|
const CARRIED_ADAPTER_RUN = fileURLToPath(new URL("./carried-adapter-run.mjs", import.meta.url));
|
|
72
|
-
const QA_SNAPSHOT_RUN = fileURLToPath(new URL("./qa-snapshot-run.mjs", import.meta.url));
|
|
73
|
-
const QA_JOURNEYS_RUN = fileURLToPath(new URL("./qa-journeys-prebuilt-run.mjs", import.meta.url));
|
|
74
65
|
// pnpm options that take the next argument as their value (so that value is not the command).
|
|
75
66
|
const PNPM_VALUE_OPTIONS = new Set(["--dir", "-C", "--filter", "-F", "--prefix", "--config", "--reporter", "--workspace-concurrency"]);
|
|
76
67
|
// Node flags that change no code path; everything else before the script (-e, --eval, -p, --import, -r, --require, --loader, …) refuses.
|
|
@@ -78,31 +69,7 @@ const NODE_FLAGS = /^--(?:disable-warning=[A-Za-z0-9_-]+|no-experimental-webstor
|
|
|
78
69
|
/** The pnpm `exec` targets the door admits (every app config's `pnpm exec <target>`: tauri and right-release; vite for a web build). */
|
|
79
70
|
export const EXEC_TARGETS = Object.freeze(["tauri", "right-release", "vite"]);
|
|
80
71
|
/** The broker adapter a `rustright run <adapter> -- ...` names, and the program whose door rules judge the step. */
|
|
81
|
-
const ADAPTER_PROGRAMS = Object.freeze({ "rightkit-pnpm": "pnpm", "rightkit-node": "node", "rightkit-
|
|
82
|
-
/** The tools a QA step must not start: running one is a build, and a QA step compiles nothing. */
|
|
83
|
-
const QA_BUILD_PROGRAMS = new Set(["cargo", "rustc", "rustdoc", "rustright", "rightkit", "pnpm", "npm", "npx", "yarn", "swift", "swiftc", "xcodebuild", "gradle", "gradlew", "make", "cmake", "node-gyp"]);
|
|
84
|
-
export { QA_VALUE_OPTIONS };
|
|
85
|
-
/**
|
|
86
|
-
* Why a `rightkit-qa` argv would build, or null. A QA step on a snapshot stage runs an already-built artifact: `run-tests` compiles its
|
|
87
|
-
* test binaries unless it is handed an existing listing (`--from-cargo-json`, read in flag position only), `journeys run` builds through the
|
|
88
|
-
* broker, `run --build`, `--build-ui` and `--build-with` build, and any named toolchain command is a build. `carried` says the gate carries a
|
|
89
|
-
* `run-tests` step's build itself (qa-snapshot.mjs), so that one is not reported; `journeys run` has no prebuilt form and always is.
|
|
90
|
-
*/
|
|
91
|
-
export function qaBuildReason(args, { carried = false } = {}) {
|
|
92
|
-
const words = args.map(String);
|
|
93
|
-
// run-tests has its own parser (words after `--` are test arguments); every other command's Args reads the whole argv (rightkit-qa.rs
|
|
94
|
-
// Args::has / value ignore `--`), so for those a build flag or tool anywhere is the build.
|
|
95
|
-
const own = words[0] === "run-tests" ? ownWords(words) : words;
|
|
96
|
-
if (words[0] === "run-tests" && !hasListing(words) && !carried) return "`rightkit-qa run-tests` without --from-cargo-json compiles its test binaries (`cargo test --no-run`)";
|
|
97
|
-
// GB-79: `journeys run` spawns its own `rustright cargo ... --no-run`. On a gate-carried stage the gate carries that build itself and hands QA the verified, signed input
|
|
98
|
-
// (qa-journeys-prebuilt.mjs, capability journeys-prebuilt-input), so there it is not a build; anywhere else it is, and a listing or attestation the step names is unverified input.
|
|
99
|
-
if (words[0] === "journeys" && words[1] === "run" && !carried) return "`rightkit-qa journeys run` builds through the broker before it launches (it spawns its own `rustright cargo ... --no-run`); only a gate-carried stage runs it, on the gate's own verified prebuilt input (capability journeys-prebuilt-input)";
|
|
100
|
-
if (words[0] === "journeys" && words[1] === "run") { const own = journeysRunRefusal(words); if (own) return own; }
|
|
101
|
-
if (own.some(isBuildFlag)) return "a --build, --build-ui or --build-with flag makes the QA run build";
|
|
102
|
-
const tool = own.find((w) => QA_BUILD_PROGRAMS.has(name(w)));
|
|
103
|
-
if (tool !== undefined) return `the argument ${JSON.stringify(tool)} names a build tool (${name(tool)}); a QA step compiles nothing`;
|
|
104
|
-
return null;
|
|
105
|
-
}
|
|
72
|
+
const ADAPTER_PROGRAMS = Object.freeze({ "rightkit-pnpm": "pnpm", "rightkit-node": "node", "rightkit-swift": "swift", "rightkit-xcodegen": "xcodegen", "rightkit-xcodebuild": "xcodebuild", "rightkit-gradle": "gradle", "rightkit-cmake": "cmake" });
|
|
106
73
|
const name = (program) => path.basename(String(program ?? "").replaceAll("\\", "/")).replace(/\.(exe|cmd|bat)$/i, "").toLowerCase();
|
|
107
74
|
|
|
108
75
|
function refuse(message, reason) {
|
|
@@ -169,8 +136,7 @@ export function snapshotInstallSkip(command, args, env) {
|
|
|
169
136
|
* (resolveExecutor's inputs); `which` defaults to a PATH lookup over `env`.
|
|
170
137
|
*/
|
|
171
138
|
export function buildLaunch(command, args = [], host = {}) {
|
|
172
|
-
|
|
173
|
-
if (command === SIGN_STEP_COMMAND || host.signStep) refuse("a sign step runs in-process in the gate's own session (signing agent, Keychain and Azure signer are the session's, not a broker job's); it never reaches the carrier (rightkit#1303)");
|
|
139
|
+
if (name(command) === "rightkit-qa") refuse(`rightkit-qa ${args.join(" ")}: ${QA_STEP_REMOVED}`);
|
|
174
140
|
const env = host.env ?? process.env;
|
|
175
141
|
const platform = host.platform ?? process.platform;
|
|
176
142
|
const exists = host.exists;
|
|
@@ -223,7 +189,7 @@ export function buildLaunch(command, args = [], host = {}) {
|
|
|
223
189
|
if (carriedArgs && carried?.map) return { command: process.execPath, args: [CARRIED_RUN, ...carriedArgs.map(String)], ...cargoHandoffEnv(carriedArgs) };
|
|
224
190
|
const client = () => lookup("rustright")[0] ?? refuse(`rustright is not on PATH: on a broker-managed host ${program} runs only as a broker job`);
|
|
225
191
|
// A snapshot stage's pnpm, node and swift steps are carried adapter jobs (carried-adapter-run.mjs); anywhere else they are the client's `run`.
|
|
226
|
-
// A QA step is not one of them:
|
|
192
|
+
// A QA step is not one of them: the door refuses it (see the rightkit-qa branch below).
|
|
227
193
|
const adapterMap = ["pnpm", "node", "swift"].includes(program) || nativeToolOf(command) !== null ? carriedCargoMapOf(env) : null;
|
|
228
194
|
if (adapterMap?.error) refuse(`${name(command)} ${args.join(" ")}: ${adapterMap.error}; a snapshot stage's step is never sent to the client around its carrier`);
|
|
229
195
|
// The step's own environment rides the adapter request (adapter-env.mjs): RUSTRIGHT_ADAPTER_ENV names exactly what the step declares, plus the
|
|
@@ -253,47 +219,17 @@ export function buildLaunch(command, args = [], host = {}) {
|
|
|
253
219
|
if (program === "rustright") {
|
|
254
220
|
const snapshotMap = carriedCargoMapOf(env);
|
|
255
221
|
if (!snapshotMap) {
|
|
256
|
-
//
|
|
257
|
-
if (args[0] === "run" && String(args[1]) === "rightkit-qa") refuse(`rustright ${args.join(" ")}:
|
|
222
|
+
// The client spelling of the QA adapter is refused as well (GATE-CONTRACT GC-A07).
|
|
223
|
+
if (args[0] === "run" && String(args[1]) === "rightkit-qa") refuse(`rustright ${args.join(" ")}: ${QA_STEP_REMOVED}`);
|
|
258
224
|
return { command: client(), args: args.map(String) };
|
|
259
225
|
}
|
|
260
226
|
if (snapshotMap.error) refuse(`rustright ${args.join(" ")}: ${snapshotMap.error}; a snapshot stage's step is never sent to the client around its carrier`);
|
|
261
227
|
// A snapshot stage: `rustright run <adapter> -- <args>` is that adapter's step (judged by the door's rules for its program, then carried);
|
|
262
228
|
// any other `rustright <command>` is an uncarried client call that no registered adapter takes, so it is refused, naming the step.
|
|
263
229
|
const adapterProgram = args[0] === "run" && args[2] === "--" ? ADAPTER_PROGRAMS[String(args[1])] : undefined;
|
|
264
|
-
if (!adapterProgram) refuse(`rustright ${args.join(" ")}: a snapshot stage carries every compile, and this client call maps to no registered adapter (rustright run rightkit-pnpm|rightkit-node|rightkit-
|
|
230
|
+
if (!adapterProgram) refuse(`rustright ${args.join(" ")}: a snapshot stage carries every compile, and this client call maps to no registered adapter (rustright run rightkit-pnpm|rightkit-node|rightkit-swift -- <args>); it is never sent to the client uncarried`);
|
|
265
231
|
return buildLaunch(adapterProgram, args.slice(3), host);
|
|
266
232
|
}
|
|
267
|
-
if (program === "rightkit-qa") {
|
|
268
|
-
// On a snapshot stage QA is a direct, gate-run step against the already-built artifact: no broker job (a QA run needs the GUI session and
|
|
269
|
-
// the unscrubbed environment the broker's job lacks), and nothing in it may compile (the broker refuses an uncarried compile from the snapshot).
|
|
270
|
-
const snapshotMap = carriedCargoMapOf(env);
|
|
271
|
-
if (snapshotMap?.error) refuse(`rightkit-qa ${args.join(" ")}: ${snapshotMap.error}; a snapshot stage's step is never run around its carrier`);
|
|
272
|
-
// GB-27 (GATE one entry): the broker registers no `rightkit-qa` adapter, so a QA step with no snapshot carrier has no route: it is
|
|
273
|
-
// refused here, naming the step, never sent as `rustright run rightkit-qa` (an unregistered capability the broker would refuse after the
|
|
274
|
-
// gate had marked the call submitted). QA runs only as the direct, noncompiling step of a snapshot stage under the door.
|
|
275
|
-
if (!snapshotMap) refuse(`rightkit-qa ${args.join(" ")}: a QA run is a direct, noncompiling step of a snapshot stage under the door; the broker registers no rightkit-qa adapter, so outside a snapshot stage there is no uncarried route (and any build the run needs is its own carried build step)`);
|
|
276
|
-
// The step runs from the snapshot and reads only the snapshot and managed output: its directory is inside the snapshot, and nothing it
|
|
277
|
-
// names (the listing, --root, --cwd, --manifest-path) resolves into the primary checkout.
|
|
278
|
-
const section = snapshotMap.map.broker ?? {};
|
|
279
|
-
const checkouts = [section.checkout, env[SNAPSHOT_STAGE_ENV]].filter((c) => typeof c === "string" && c);
|
|
280
|
-
const placed = qaPathReason(args, { cwd: host.cwd ?? process.cwd(), snapshot: section.snapshot?.path, checkouts, env: { ...env, ...host.stepEnv } });
|
|
281
|
-
if (placed) refuse(`rightkit-qa ${args.join(" ")}: ${placed}`);
|
|
282
|
-
const reason = qaBuildReason(args, { carried: true });
|
|
283
|
-
if (reason) refuse(`rightkit-qa ${args.join(" ")}: ${reason}; a QA step of a snapshot stage runs the already-built artifact and compiles nothing, so a build it needs is its own carried build step (a test build with --no-run --message-format=json, then run-tests --from-cargo-json)`);
|
|
284
|
-
const qa = lookup("rightkit-qa")[0] ?? "rightkit-qa";
|
|
285
|
-
// `run-tests` with no listing: the gate carries its test build, then runs QA directly against the listing (qa-snapshot-run.mjs).
|
|
286
|
-
// GB-79: a `journeys run` step is the gate's build-once route (carry the build, sign the products, hand QA the verified input) when QA implements the prebuilt input.
|
|
287
|
-
if (isJourneysRun(args)) {
|
|
288
|
-
const capable = (host.qaPrebuiltCapability ?? ((binary) => qaPrebuiltCapability(binary, { env: { ...env, ...host.stepEnv } })))(qa);
|
|
289
|
-
if (!capable) refuse(journeysCapabilityRefusal(`rightkit-qa ${args.join(" ")}`));
|
|
290
|
-
return { command: process.execPath, args: [QA_JOURNEYS_RUN, qa, "--", ...args.map(String)] };
|
|
291
|
-
}
|
|
292
|
-
const plan = qaSnapshotPlan(args);
|
|
293
|
-
if (plan?.refusal) refuse(`rightkit-qa ${args.join(" ")}: ${plan.refusal}`);
|
|
294
|
-
if (plan) return { command: process.execPath, args: [QA_SNAPSHOT_RUN, qa, "--", ...args.map(String)] };
|
|
295
|
-
return { command: qa, args: args.map(String) };
|
|
296
|
-
}
|
|
297
233
|
// GB-51 / RA-07..RA-09: a declared native build tool (xcodegen, xcodebuild, Gradle and its wrapper, CMake) is a snapshot-bound adapter job of its own constrained adapter (native-adapters.mjs): its argv is
|
|
298
234
|
// judged by the registration and by the broker's own shape and file rules BEFORE the send (an output override, an unregistered flag or a shape the broker has no managed form for is refused here, naming
|
|
299
235
|
// the capability), paths are made relative to the step's directory, and the step carries no environment of its own: the signing-properties file is a typed handoff, never an environment, and refuses
|