@rightkit/release 0.6.12 → 0.6.14
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 +49 -1
- package/adapter-env.mjs +165 -0
- package/android-ndk.mjs +5 -1
- package/android-release.mjs +73 -8
- package/app-check.mjs +22 -8
- package/app-closure.mjs +214 -0
- package/assemble-app-command.mjs +24 -7
- package/atomic-write.mjs +57 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +1190 -335
- package/broker-client.mjs +106 -0
- package/broker-exception.mjs +211 -0
- package/broker-handshake.mjs +160 -0
- package/broker-pins.mjs +27 -8
- package/broker-wire.mjs +262 -0
- package/build-launch.mjs +364 -12
- package/build-ownership.mjs +117 -49
- package/build-release.mjs +3 -2
- package/cache-command.mjs +7 -2
- package/candidate-build-receipt.mjs +96 -0
- package/candidate-receipt-command.mjs +29 -0
- package/cargo-contract.mjs +4 -3
- package/cargo-ledger.mjs +1 -1
- package/cargo-license-notices.mjs +2 -2
- package/cargo-shapes.mjs +332 -0
- package/cargo-target-shim.mjs +44 -5
- package/cargo-target.mjs +5 -0
- package/carried-adapter-run.mjs +31 -0
- package/carried-cargo-run.mjs +9 -0
- package/carried-cargo.mjs +237 -0
- package/carrier-census.mjs +124 -0
- package/class-a-launch-guard.mjs +266 -33
- package/cli/right-release.mjs +2 -0
- package/clippy-route.mjs +23 -11
- package/compile-inputs.mjs +9 -12
- package/control-plane.mjs +18 -0
- package/deps-cargo-lock.mjs +208 -0
- package/deps.mjs +5 -0
- package/deterministic-scans.mjs +273 -44
- package/development-signing.mjs +14 -1
- package/development.mjs +109 -24
- package/direct-origin.mjs +107 -0
- package/disk-command.mjs +1 -0
- package/disk-policy.mjs +18 -3
- package/freshness-census.mjs +206 -58
- package/gate-executor.mjs +30 -12
- package/gate-stages.mjs +93 -16
- package/github-release.mjs +2 -1
- package/github-upload-gate.mjs +2 -1
- package/install-tauri-authenticode.mjs +346 -0
- package/ios-dev.mjs +77 -23
- package/ios-package-recipe.mjs +100 -41
- package/linux-artifacts.mjs +17 -2
- package/linux-qualifier.mjs +83 -0
- package/local-publish.mjs +36 -14
- package/local-runtime.mjs +2 -2
- package/lock-identity.mjs +138 -0
- package/lsclean.sh +0 -0
- package/managed-cargo.mjs +151 -44
- package/managed-roots.mjs +130 -12
- package/media-sidecar-catalog.mjs +1 -1
- package/native-adapters.mjs +411 -0
- package/native-helpers.mjs +26 -18
- package/native-tool-planning.mjs +94 -0
- package/ownership-mark.mjs +182 -0
- package/pack-digest.mjs +122 -0
- package/package.json +12 -12
- package/platform-dir.mjs +14 -0
- package/pnpm-adapter-grammar.mjs +226 -0
- package/progress-control.mjs +7 -2
- package/provenance-real-receipt.pins.json +7 -0
- package/provenance-verify.mjs +262 -1
- package/publication-outcomes.mjs +21 -0
- package/publish-update.mjs +2 -1
- package/qa-journeys-prebuilt-run.mjs +18 -0
- package/qa-journeys-prebuilt.mjs +148 -0
- package/qa-snapshot-run.mjs +53 -0
- package/qa-snapshot.mjs +164 -0
- package/qualification-verify.mjs +144 -0
- package/qualify-reuse.mjs +38 -0
- package/recipe-build.mjs +16 -3
- package/release-qa-audit.mjs +2 -2
- package/release-qa-command.mjs +2 -1
- package/release-qa-receipt.mjs +3 -2
- package/release-state.mjs +3 -2
- package/release.mjs +14 -3
- package/rightkit-drift.mjs +27 -5
- package/rightkit-versions.json +15 -10
- package/rustright-names.mjs +32 -0
- package/scan-tool-enrolment.mjs +167 -0
- package/sign-windows.mjs +212 -112
- package/signing-files.mjs +35 -0
- package/signing-identity.mjs +7 -0
- package/snapshot-broker.mjs +528 -57
- package/snapshot-check.mjs +95 -11
- package/snapshot-metadata.mjs +123 -0
- package/snapshot-request.mjs +389 -96
- package/stage-binary.mjs +22 -4
- package/stage-commands.mjs +56 -13
- package/stage-sidecar.mjs +93 -21
- package/suite-signing-policy.mjs +657 -0
- package/suite-signing.mjs +223 -0
- package/swift-scratch.mjs +25 -0
- package/tauri-locked.mjs +25 -0
- package/test-command.mjs +164 -29
- package/unit-gate.mjs +201 -31
- package/upload-large.mjs +0 -0
- package/web-build.mjs +2 -2
- package/win-package-recipe.mjs +26 -2
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// The one rule for starting the RustRight client (`rustright`), used by every gate and child spawn of it.
|
|
2
|
+
//
|
|
3
|
+
// On Windows the client is found on PATH as the managed agent shim, `rustright.cmd`. Node refuses to start a .cmd or .bat without
|
|
4
|
+
// `shell: true` (the CVE-2024-27980 hardening: EINVAL), and a shell would re-quote the JSON arguments, which breaks the signed
|
|
5
|
+
// argvSha256 or lets an argument inject. So on win32 the shim is never spawned: its target executable is read from the shim itself,
|
|
6
|
+
// which must be EXACTLY the installed shape below, and that .exe is spawned directly with the argv array, `shell: false`, and the
|
|
7
|
+
// environment the shim would have made (RUSTRIGHT_MANAGED_AGENT_SHIM and RIGHTKIT_MANAGED_AGENT_SHIM removed). A shim of any other
|
|
8
|
+
// shape is refused with RK_BROKER_CLIENT_UNRESOLVED; there is no shell fallback. POSIX is unchanged: the client is spawned as given.
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { managedRuntimeClient } from "./managed-roots.mjs";
|
|
12
|
+
import { currentInterfaceEnv, legacyInterfaceNamesIn } from "./rustright-names.mjs";
|
|
13
|
+
|
|
14
|
+
export const BROKER_CLIENT_UNRESOLVED = "RK_BROKER_CLIENT_UNRESOLVED";
|
|
15
|
+
export const AGENT_SHIM_MARKER = "rustright.build-control.agent-shims.install.v1";
|
|
16
|
+
/** The variables the shim clears before it starts the runtime. */
|
|
17
|
+
export const AGENT_SHIM_ENV = Object.freeze(["RUSTRIGHT_MANAGED_AGENT_SHIM", "RIGHTKIT_MANAGED_AGENT_SHIM"]);
|
|
18
|
+
|
|
19
|
+
export class BrokerClientError extends Error {
|
|
20
|
+
constructor(message) { super(message); this.name = "BrokerClientError"; this.code = BROKER_CLIENT_UNRESOLVED; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const SHIM_EXE = /^[A-Za-z]:\\[^"%\r\n<>|&^!*?]+\.exe$/i;
|
|
24
|
+
const SHIM_LINES = [
|
|
25
|
+
/^@echo off$/i,
|
|
26
|
+
new RegExp(`^rem ${AGENT_SHIM_MARKER.replaceAll(".", "\\.")}$`),
|
|
27
|
+
/^setlocal$/i,
|
|
28
|
+
/^set "RUSTRIGHT_MANAGED_AGENT_SHIM="$/i,
|
|
29
|
+
/^set "RIGHTKIT_MANAGED_AGENT_SHIM="$/i,
|
|
30
|
+
/^"([^"\r\n]+)" %\*$/,
|
|
31
|
+
/^exit \/b %ERRORLEVEL%$/i,
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/** The shim's target executable from the shim's text, or a BrokerClientError. The shim must match the installed format exactly. */
|
|
35
|
+
export function parseAgentShim(text, shimPath = "the shim") {
|
|
36
|
+
const lines = String(text).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
37
|
+
if (!lines.some((l) => l === `rem ${AGENT_SHIM_MARKER}`)) throw new BrokerClientError(`${shimPath} is not a RustRight agent shim (no ${AGENT_SHIM_MARKER} marker); it is not started through a shell`);
|
|
38
|
+
if (lines.length !== SHIM_LINES.length || !SHIM_LINES.every((re, i) => re.test(lines[i]))) throw new BrokerClientError(`${shimPath} has a shape other than the installed RustRight agent shim; it is not started through a shell`);
|
|
39
|
+
const exe = SHIM_LINES[5].exec(lines[5])[1];
|
|
40
|
+
if (!SHIM_EXE.test(exe)) throw new BrokerClientError(`${shimPath} names ${JSON.stringify(exe)}, which is not an absolute .exe path without shell metacharacters`);
|
|
41
|
+
return exe;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The client as it is spawned: `{ file, shim }`. POSIX, and any win32 client that is not a .cmd/.bat, is returned as given (`shim: false`).
|
|
46
|
+
* A win32 .cmd/.bat is resolved to the executable its shim names (`shim: true`), or refused.
|
|
47
|
+
*/
|
|
48
|
+
export function resolveBrokerClient(client, { platform = process.platform, readFile = (f) => fs.readFileSync(f, "utf8") } = {}) {
|
|
49
|
+
if (platform !== "win32" || !/\.(cmd|bat)$/i.test(String(client))) return { file: client, shim: false };
|
|
50
|
+
let text;
|
|
51
|
+
try { text = readFile(client); } catch (e) { throw new BrokerClientError(`${client} cannot be read to find the RustRight client (${e.message})`); }
|
|
52
|
+
return { file: parseAgentShim(text, client), shim: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `env` without the shim's own variables (case-insensitive: Windows environments are). */
|
|
56
|
+
export function withoutAgentShimEnv(env) {
|
|
57
|
+
const out = { ...env };
|
|
58
|
+
for (const key of Object.keys(out)) if (AGENT_SHIM_ENV.includes(key.toUpperCase())) delete out[key];
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Spawn the client: `spawn(file, args, options)` with the resolved file. A shim target is started with `shell: false` and the shim's
|
|
64
|
+
* environment; anything else keeps the caller's options untouched. A refused client returns a spawn-shaped `{ error }` (code
|
|
65
|
+
* RK_BROKER_CLIENT_UNRESOLVED) and nothing is started.
|
|
66
|
+
*/
|
|
67
|
+
export function spawnBrokerClient(spawn, client, args, options = {}, { platform = process.platform, readFile } = {}) {
|
|
68
|
+
let resolved;
|
|
69
|
+
try { resolved = resolveBrokerClient(client, { platform, readFile }); } catch (error) { return { error, status: null, signal: null, stdout: "", stderr: "" }; }
|
|
70
|
+
// GB-09: every outbound client call carries the current RUSTRIGHT_* spelling of the interface names only (a legacy RIGHTKIT_* one is translated, never sent).
|
|
71
|
+
const baseEnv = options.env ?? process.env;
|
|
72
|
+
const env = legacyInterfaceNamesIn(baseEnv).length ? currentInterfaceEnv(baseEnv) : options.env;
|
|
73
|
+
if (!resolved.shim) return spawn(resolved.file, args, env === options.env ? options : { ...options, env });
|
|
74
|
+
return spawn(resolved.file, args, { ...options, shell: false, env: withoutAgentShimEnv(env ?? process.env) });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The client as PATH names it. POSIX: the bare name `rustright` (the shim is an executable file). win32: the first absolute path `where`
|
|
79
|
+
* lists for it (the managed `rustright.cmd` shim or an .exe), so resolveBrokerClient can read the shim; none found leaves the bare name,
|
|
80
|
+
* which the caller's spawn then fails on, as it did. `probe` is a synchronous spawn `(file, args, options)`; it is called on win32 only.
|
|
81
|
+
*/
|
|
82
|
+
export function locateBrokerClient({ platform = process.platform, env = process.env, probe, installed = managedRuntimeClient } = {}) {
|
|
83
|
+
if (platform !== "win32") return "rustright";
|
|
84
|
+
// GB-08 (BR11/B31): the installed runtime's own .exe is the client wherever it exists, so a PATH that exposes the `rustright.cmd` shim first
|
|
85
|
+
// (or a stale one) never decides what starts; PATH is consulted only when no installed runtime is found.
|
|
86
|
+
let runtime;
|
|
87
|
+
try { runtime = typeof installed === "function" ? installed(env, platform) : undefined; } catch { runtime = undefined; }
|
|
88
|
+
if (typeof runtime === "string" && /\.exe$/i.test(runtime) && path.win32.isAbsolute(runtime)) return runtime;
|
|
89
|
+
if (typeof probe !== "function") throw new BrokerClientError("locating the RustRight client on win32 needs a synchronous spawn (`probe`)");
|
|
90
|
+
const r = probe("where", ["rustright"], { encoding: "utf8", env, windowsHide: true, shell: false, timeout: 20_000 });
|
|
91
|
+
const found = String(r?.stdout ?? "").split(/\r?\n/).map((l) => l.trim()).filter((l) => path.win32.isAbsolute(l));
|
|
92
|
+
return found.find((l) => /\.(cmd|bat)$/i.test(l)) ?? found.find((l) => /\.exe$/i.test(l)) ?? "rustright";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* `{ command, args, options }` to hand to ANY spawn (synchronous or not) for `command`: anything but the bare `rustright` is returned
|
|
97
|
+
* as given; `rustright` is located (locateBrokerClient) and resolved (resolveBrokerClient), so a win32 `.cmd` shim becomes its .exe
|
|
98
|
+
* target with `shell: false` and the shim's environment. Throws BrokerClientError for a shim that is not the installed shape.
|
|
99
|
+
*/
|
|
100
|
+
export function brokerClientInvocation(command, args, options = {}, { platform = process.platform, probe, readFile, installed } = {}) {
|
|
101
|
+
if (command !== "rustright") return { command, args, options };
|
|
102
|
+
const client = locateBrokerClient({ platform, env: options.env, probe, ...(installed ? { installed } : {}) });
|
|
103
|
+
const resolved = resolveBrokerClient(client, { platform, readFile });
|
|
104
|
+
if (!resolved.shim) return { command: resolved.file, args, options };
|
|
105
|
+
return { command: resolved.file, args, options: { ...options, shell: false, env: withoutAgentShimEnv(options.env ?? process.env) } };
|
|
106
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// The records Adrian's decisions live in, read by the gate from the COMMIT being built (never the working tree), and the repository rules
|
|
2
|
+
// they serve. Three records, one identity test:
|
|
3
|
+
//
|
|
4
|
+
// 1. .rightkit/broker-exceptions.json (Adrian, 2026-10-31): the one documented exception to "the broker is mandatory on Mac and Dell": a
|
|
5
|
+
// build through the gate WITHOUT the broker, only when Adrian expressly asked or approved it, recorded in the repository being built.
|
|
6
|
+
// The gate refuses unless the id exists, is unexpired when it carries a date, names this host and, in RightKit, covers every selected unit.
|
|
7
|
+
//
|
|
8
|
+
// { schema: 1, exceptions: [ { id, approvedBy: "Adrian", quote, issue, units?, hosts, expires? } ] }
|
|
9
|
+
// units: "rust:<crate>" | "npm:<package>" | "*" (all units; only when his words say all). hosts: "mac" | "dell".
|
|
10
|
+
// RightKit only: `units` names what the exception covers; a run with no unit selector (a whole door run) selects every unit, so only a
|
|
11
|
+
// record whose units are ["*"] covers it. Every other repository is built whole (one fixed build definition, below): a broker exception
|
|
12
|
+
// there needs no `units` and covers the repository's run (#1325, Adrian: "the only exception is RightKit, where the unit has to be mentioned").
|
|
13
|
+
// expires (optional): ISO date. Absent = no date: the exception lasts for what he asked for (Adrian, 2026-10-31: "exception lasts for what i
|
|
14
|
+
// asked for, till the time it's published"), which `hosts`, `units` (RightKit) and his quote bound. Present = validated and enforced as ever.
|
|
15
|
+
//
|
|
16
|
+
// 2. ci/all-units-exceptions.json (RightKit only, #1325): "all units" in RightKit is Adrian's exception, never a default. RightKit's door names
|
|
17
|
+
// its unit on every run: one `--unit`, a unit list written for that run (`--units-file`), or every unit with `--all-units-exception <id>`.
|
|
18
|
+
//
|
|
19
|
+
// { exceptions: [ { id, approvedBy: "Adrian", quote, issue, expires? } ] } (the file CI's dispatch reads, minus its mandatory expiry)
|
|
20
|
+
//
|
|
21
|
+
// 3. ci/gate.config.json `cargoWorkspaces` (every repository but RightKit, #1325): the fixed, committed build definition. It lists every Cargo
|
|
22
|
+
// workspace of the repository; a gate run builds all of them and nothing is chosen per run. The gate refuses a commit with no list (and prints
|
|
23
|
+
// the exact list it discovered, to commit), and a commit whose discovered workspaces (discoverRustWorkspaces) differ from the list, missing or
|
|
24
|
+
// extra. No grace period and no generated list.
|
|
25
|
+
//
|
|
26
|
+
// ci/gate.config.json = { ..., "cargoWorkspaces": [ "<workspace directory, '.' for the root>", ... ] } (the lock of each is <directory>/Cargo.lock)
|
|
27
|
+
//
|
|
28
|
+
// RightKit is told apart by the repository identity `check` already uses for RightKit's own rules: the commit holds
|
|
29
|
+
// packages/release/package.json named @rightkit/release (isRightKitIdentity). Never a path guess.
|
|
30
|
+
export const BROKER_EXCEPTIONS_FILE = ".rightkit/broker-exceptions.json";
|
|
31
|
+
export const BROKER_EXCEPTION_SCHEMA = 1;
|
|
32
|
+
export const OUTSIDE_BROKER_NOTE = "outside the broker by exception";
|
|
33
|
+
export const ALL_UNITS_EXCEPTIONS_FILE = "ci/all-units-exceptions.json";
|
|
34
|
+
export const CARGO_WORKSPACE_LIST_FILE = "ci/gate.config.json";
|
|
35
|
+
export const CARGO_WORKSPACE_LIST_KEY = "cargoWorkspaces";
|
|
36
|
+
export const RIGHTKIT_IDENTITY_FILE = "packages/release/package.json";
|
|
37
|
+
export const RIGHTKIT_IDENTITY_NAME = "@rightkit/release";
|
|
38
|
+
|
|
39
|
+
export const BROKER_EXCEPTION_ERRORS = Object.freeze({
|
|
40
|
+
usage: "RK_BROKER_EXCEPTION_USAGE",
|
|
41
|
+
missing: "RK_BROKER_EXCEPTION_MISSING",
|
|
42
|
+
invalid: "RK_BROKER_EXCEPTION_INVALID",
|
|
43
|
+
expired: "RK_BROKER_EXCEPTION_EXPIRED",
|
|
44
|
+
host: "RK_BROKER_EXCEPTION_HOST",
|
|
45
|
+
unit: "RK_BROKER_EXCEPTION_UNIT",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const ALL_UNITS_ERRORS = Object.freeze({
|
|
49
|
+
usage: "RK_ALL_UNITS_USAGE",
|
|
50
|
+
missing: "RK_ALL_UNITS_MISSING",
|
|
51
|
+
invalid: "RK_ALL_UNITS_INVALID",
|
|
52
|
+
expired: "RK_ALL_UNITS_EXPIRED",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export const CARGO_WORKSPACE_LIST_ERRORS = Object.freeze({
|
|
56
|
+
missing: "RK_CARGO_WORKSPACE_LIST_MISSING",
|
|
57
|
+
invalid: "RK_CARGO_WORKSPACE_LIST_INVALID",
|
|
58
|
+
differs: "RK_CARGO_WORKSPACE_LIST_DIFFERS",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const HOSTS = Object.freeze(["mac", "dell"]);
|
|
62
|
+
const UNIT = /^(?:\*|rust:[A-Za-z0-9][A-Za-z0-9_-]*|npm:(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*)$/;
|
|
63
|
+
const DATE = /^\d{4}-\d{2}-\d{2}(?:T[0-9:.]+(?:Z|[+-]\d{2}:\d{2}))?$/;
|
|
64
|
+
|
|
65
|
+
const refuse = (code, message) => Object.assign(new Error(`${code}: ${message}`), { code });
|
|
66
|
+
|
|
67
|
+
/** True when `packageJsonText` is the package.json of RightKit's own release package: the identity of the RightKit repository. */
|
|
68
|
+
export function isRightKitIdentity(packageJsonText) {
|
|
69
|
+
try { return JSON.parse(String(packageJsonText))?.name === RIGHTKIT_IDENTITY_NAME; } catch { return false; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The exception host name for a Node platform: Mac and Dell (Windows) only; every other host is not an exception host. */
|
|
73
|
+
export function exceptionHostOf(platform = process.platform) {
|
|
74
|
+
return platform === "darwin" ? "mac" : platform === "win32" ? "dell" : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `cargo:rightkit-x` / `npm:@rightkit/x` selectors of `--unit` as record unit names; null for no selector (whole run). */
|
|
78
|
+
export function unitsOfSelector(selector) {
|
|
79
|
+
if (selector === undefined || selector === null) return ["*"];
|
|
80
|
+
const cargo = /^cargo:(rightkit-[a-z0-9-]+)$/.exec(selector);
|
|
81
|
+
if (cargo) return [`rust:${cargo[1]}`];
|
|
82
|
+
if (/^npm:@rightkit\/[a-z0-9-]+$/.test(selector)) return [selector];
|
|
83
|
+
return [String(selector)];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function expiryOf(value) {
|
|
87
|
+
const at = Date.parse(/T/.test(value) ? value : `${value}T23:59:59.999Z`);
|
|
88
|
+
return Number.isNaN(at) ? null : at;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** What every record entry states: an id, Adrian's approval, his words and the issue that records the decision. Null when it does. */
|
|
92
|
+
function statedProblem(entry, { issueIsUrl }) {
|
|
93
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return "not an object";
|
|
94
|
+
if (typeof entry.id !== "string" || !entry.id) return "id is required";
|
|
95
|
+
if (entry.approvedBy !== "Adrian") return 'approvedBy must be "Adrian"';
|
|
96
|
+
if (typeof entry.quote !== "string" || !entry.quote.trim()) return "quote (his exact words) is required";
|
|
97
|
+
if (issueIsUrl ? typeof entry.issue !== "string" || !/^https:\/\/\S+$/.test(entry.issue) : typeof entry.issue !== "string" || !entry.issue.trim()) return issueIsUrl ? "issue must be an https URL" : "issue is required";
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `expires` is optional: absent means no date. Anything present must be an ISO date (null, "" and a malformed value are refused, never read as absent). */
|
|
102
|
+
const expiryProblem = (entry) => (Object.hasOwn(entry, "expires") && (typeof entry.expires !== "string" || !DATE.test(entry.expires) || expiryOf(entry.expires) === null) ? "expires, when present, must be an ISO date" : null);
|
|
103
|
+
|
|
104
|
+
function checkEntry(entry, { inRightKit }) {
|
|
105
|
+
const bad = (what) => refuse(BROKER_EXCEPTION_ERRORS.invalid, `exception ${JSON.stringify(entry?.id)} in ${BROKER_EXCEPTIONS_FILE}: ${what}`);
|
|
106
|
+
const stated = statedProblem(entry, { issueIsUrl: true });
|
|
107
|
+
if (stated) throw bad(stated);
|
|
108
|
+
// `units` bounds an exception in RightKit only. Elsewhere it is optional (a repository is built whole) and, when a record states it, is still
|
|
109
|
+
// read as a unit list so a typo is not silently accepted; it never narrows what a repository's run is covered for.
|
|
110
|
+
if ((inRightKit || Object.hasOwn(entry, "units")) && (!Array.isArray(entry.units) || !entry.units.length || !entry.units.every((u) => typeof u === "string" && UNIT.test(u)))) throw bad('units must be a non-empty list of "rust:<crate>", "npm:<package>" or "*"');
|
|
111
|
+
if (!Array.isArray(entry.hosts) || !entry.hosts.length || !entry.hosts.every((h) => HOSTS.includes(h))) throw bad(`hosts must be a non-empty list of ${HOSTS.join(", ")}`);
|
|
112
|
+
const dated = expiryProblem(entry);
|
|
113
|
+
if (dated) throw bad(dated);
|
|
114
|
+
return entry;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Judge `--no-broker <id>`. `record` is the text of the committed record (null when the commit holds none). `inRightKit` is the repository
|
|
119
|
+
* identity of the commit (isRightKitIdentity; true unless the caller says otherwise, the stricter reading). Returns the exception (frozen, with
|
|
120
|
+
* the host and the units this run selected) or throws an error carrying a typed `code`.
|
|
121
|
+
*/
|
|
122
|
+
export function authorizeNoBroker({ id, record, platform = process.platform, units = ["*"], now = Date.now(), inRightKit = true }) {
|
|
123
|
+
if (typeof id !== "string" || !id || id.startsWith("--")) throw refuse(BROKER_EXCEPTION_ERRORS.usage, "--no-broker needs the exception id");
|
|
124
|
+
if (record === null || record === undefined) throw refuse(BROKER_EXCEPTION_ERRORS.missing, `the selected commit holds no ${BROKER_EXCEPTIONS_FILE}; the broker stays mandatory`);
|
|
125
|
+
let parsed;
|
|
126
|
+
try { parsed = JSON.parse(record); } catch (error) { throw refuse(BROKER_EXCEPTION_ERRORS.invalid, `${BROKER_EXCEPTIONS_FILE} is not JSON: ${error.message}`); }
|
|
127
|
+
if (parsed?.schema !== BROKER_EXCEPTION_SCHEMA || !Array.isArray(parsed.exceptions)) throw refuse(BROKER_EXCEPTION_ERRORS.invalid, `${BROKER_EXCEPTIONS_FILE} must be { schema: ${BROKER_EXCEPTION_SCHEMA}, exceptions: [...] }`);
|
|
128
|
+
const matches = parsed.exceptions.filter((e) => e?.id === id);
|
|
129
|
+
if (!matches.length) throw refuse(BROKER_EXCEPTION_ERRORS.missing, `${BROKER_EXCEPTIONS_FILE} has no exception ${JSON.stringify(id)}; the broker stays mandatory`);
|
|
130
|
+
if (matches.length > 1) throw refuse(BROKER_EXCEPTION_ERRORS.invalid, `exception id ${JSON.stringify(id)} appears more than once in ${BROKER_EXCEPTIONS_FILE}`);
|
|
131
|
+
const entry = checkEntry(matches[0], { inRightKit });
|
|
132
|
+
if (entry.expires !== undefined && now > expiryOf(entry.expires)) throw refuse(BROKER_EXCEPTION_ERRORS.expired, `exception ${id} expired ${entry.expires}`);
|
|
133
|
+
const host = exceptionHostOf(platform);
|
|
134
|
+
if (!host || !entry.hosts.includes(host)) throw refuse(BROKER_EXCEPTION_ERRORS.host, `exception ${id} names ${entry.hosts.join(", ")}; this host is ${host ?? platform}`);
|
|
135
|
+
if (inRightKit && !entry.units.includes("*")) {
|
|
136
|
+
const uncovered = units.filter((u) => !entry.units.includes(u));
|
|
137
|
+
if (uncovered.length) throw refuse(BROKER_EXCEPTION_ERRORS.unit, `exception ${id} covers ${entry.units.join(", ")}; not ${uncovered.join(", ")}${units.includes("*") ? " (a run with no --unit selects every unit and needs units [\"*\"])" : ""}`);
|
|
138
|
+
}
|
|
139
|
+
// Outside RightKit the run is the whole repository, whatever units the record happens to list.
|
|
140
|
+
const covered = inRightKit ? [...entry.units] : ["*"];
|
|
141
|
+
return Object.freeze({ exceptionId: entry.id, quote: entry.quote, issue: entry.issue, units: covered, selectedUnits: [...units], host, expires: entry.expires ?? null, inRightKit, note: OUTSIDE_BROKER_NOTE });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The line a run under an exception prints (and the receipt records the same fields). */
|
|
145
|
+
export function describeBrokerException(x) {
|
|
146
|
+
return `exceptionId=${x.exceptionId} host=${x.host} units=${x.units.join(",")} issue=${x.issue} quote=${JSON.stringify(x.quote)}: ${x.note}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Judge `--all-units-exception <id>`: RightKit's whole-repository door run, which is Adrian's exception and never a default. `record` is the
|
|
151
|
+
* text of the commit's ci/all-units-exceptions.json (null when it holds none). Same fields as CI's dispatch reads (id, approvedBy "Adrian", quote,
|
|
152
|
+
* issue); `expires` is optional and honoured when present. Returns the exception (frozen) or throws an error carrying a typed `code`.
|
|
153
|
+
*/
|
|
154
|
+
export function authorizeAllUnits({ id, record, now = Date.now() }) {
|
|
155
|
+
if (typeof id !== "string" || !id || id.startsWith("--")) throw refuse(ALL_UNITS_ERRORS.usage, "--all-units-exception needs the exception id");
|
|
156
|
+
if (record === null || record === undefined) throw refuse(ALL_UNITS_ERRORS.missing, `the selected commit holds no ${ALL_UNITS_EXCEPTIONS_FILE}; an all-units run is Adrian's exception`);
|
|
157
|
+
let parsed;
|
|
158
|
+
try { parsed = JSON.parse(record); } catch (error) { throw refuse(ALL_UNITS_ERRORS.invalid, `${ALL_UNITS_EXCEPTIONS_FILE} is not JSON: ${error.message}`); }
|
|
159
|
+
if (!Array.isArray(parsed?.exceptions)) throw refuse(ALL_UNITS_ERRORS.invalid, `${ALL_UNITS_EXCEPTIONS_FILE} must be { exceptions: [...] }`);
|
|
160
|
+
const matches = parsed.exceptions.filter((e) => e?.id === id);
|
|
161
|
+
if (!matches.length) throw refuse(ALL_UNITS_ERRORS.missing, `${ALL_UNITS_EXCEPTIONS_FILE} has no exception ${JSON.stringify(id)}; an all-units run is Adrian's exception`);
|
|
162
|
+
if (matches.length > 1) throw refuse(ALL_UNITS_ERRORS.invalid, `exception id ${JSON.stringify(id)} appears more than once in ${ALL_UNITS_EXCEPTIONS_FILE}`);
|
|
163
|
+
const entry = matches[0];
|
|
164
|
+
const problem = statedProblem(entry, { issueIsUrl: false }) ?? expiryProblem(entry);
|
|
165
|
+
if (problem) throw refuse(ALL_UNITS_ERRORS.invalid, `exception ${JSON.stringify(id)} in ${ALL_UNITS_EXCEPTIONS_FILE}: ${problem}`);
|
|
166
|
+
if (entry.expires !== undefined && now > expiryOf(entry.expires)) throw refuse(ALL_UNITS_ERRORS.expired, `exception ${id} expired ${entry.expires}`);
|
|
167
|
+
return Object.freeze({ exceptionId: entry.id, quote: entry.quote, issue: entry.issue, expires: entry.expires ?? null });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The line an all-units run prints. */
|
|
171
|
+
export function describeAllUnitsException(x) {
|
|
172
|
+
return `exceptionId=${x.exceptionId} issue=${x.issue} quote=${JSON.stringify(x.quote)}: every unit of RightKit, by exception`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A directory the build definition may name: `.` or a plain repository-relative path (no empty, `.` or `..` segment, no backslash, drive or NUL). */
|
|
176
|
+
const plainWorkspaceDir = (d) => typeof d === "string" && d.length > 0 && (d === "." || d.split("/").every((seg) => seg !== "" && seg !== "." && seg !== ".." && !/[\\:\0]/.test(seg)));
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The fixed build definition of a repository that is not RightKit: compare the Cargo workspaces the commit holds (`discovered`, the
|
|
180
|
+
* `cargoDir` of each discoverRustWorkspaces workspace) with the committed list: the `cargoWorkspaces` array of the commit's ci/gate.config.json
|
|
181
|
+
* (`record` is that file's text, null when the commit holds none). Equal sets pass; a repository with no Cargo workspace needs no list. A
|
|
182
|
+
* commit with workspaces and no list throws, and the message carries the exact list the gate discovered, ready to commit (no grace period, no
|
|
183
|
+
* generated file). A list that differs throws too, naming what is missing from it and what it names that the commit does not hold, and the
|
|
184
|
+
* list that would agree. Returns { workspaces, listed } (frozen).
|
|
185
|
+
*/
|
|
186
|
+
export function checkCargoWorkspaceList({ discovered, record }) {
|
|
187
|
+
const found = [...new Set(discovered)].sort();
|
|
188
|
+
const snippet = `"${CARGO_WORKSPACE_LIST_KEY}": ${JSON.stringify(found)}`;
|
|
189
|
+
let parsed; let unreadable = "";
|
|
190
|
+
if (record !== null && record !== undefined) {
|
|
191
|
+
try { parsed = JSON.parse(record); } catch (error) { unreadable = ` (${CARGO_WORKSPACE_LIST_FILE} is not valid JSON: ${error.message})`; }
|
|
192
|
+
}
|
|
193
|
+
if (parsed === undefined || parsed === null || typeof parsed !== "object" || Array.isArray(parsed) || !Object.hasOwn(parsed, CARGO_WORKSPACE_LIST_KEY)) {
|
|
194
|
+
if (!found.length) return Object.freeze({ workspaces: [], listed: false });
|
|
195
|
+
throw refuse(CARGO_WORKSPACE_LIST_ERRORS.missing, `the commit holds ${found.length} Cargo workspace(s) and ${CARGO_WORKSPACE_LIST_FILE} names none${unreadable}; every repository but RightKit commits one fixed build definition listing every Cargo workspace, and a run builds all of them. The gate discovered exactly these; commit them in ${CARGO_WORKSPACE_LIST_FILE}: ${snippet}`);
|
|
196
|
+
}
|
|
197
|
+
const listed = parsed[CARGO_WORKSPACE_LIST_KEY];
|
|
198
|
+
if (!Array.isArray(listed) || !listed.every(plainWorkspaceDir)) throw refuse(CARGO_WORKSPACE_LIST_ERRORS.invalid, `${CARGO_WORKSPACE_LIST_FILE} ${CARGO_WORKSPACE_LIST_KEY} must be an array of workspace directories ("." for the root, otherwise a plain repository-relative path), e.g. ${snippet}`);
|
|
199
|
+
const sorted = [...listed].sort();
|
|
200
|
+
const duplicate = sorted.find((d, i) => i > 0 && d === sorted[i - 1]);
|
|
201
|
+
if (duplicate !== undefined) throw refuse(CARGO_WORKSPACE_LIST_ERRORS.invalid, `${CARGO_WORKSPACE_LIST_FILE} ${CARGO_WORKSPACE_LIST_KEY} names the workspace ${JSON.stringify(duplicate)} more than once`);
|
|
202
|
+
const unlisted = found.filter((d) => !sorted.includes(d));
|
|
203
|
+
const absent = sorted.filter((d) => !found.includes(d));
|
|
204
|
+
if (unlisted.length || absent.length) {
|
|
205
|
+
const parts = [];
|
|
206
|
+
if (unlisted.length) parts.push(`the commit holds ${unlisted.join(", ")} which the list does not name`);
|
|
207
|
+
if (absent.length) parts.push(`the list names ${absent.join(", ")} which the commit does not hold`);
|
|
208
|
+
throw refuse(CARGO_WORKSPACE_LIST_ERRORS.differs, `${CARGO_WORKSPACE_LIST_FILE} ${CARGO_WORKSPACE_LIST_KEY} and the commit's Cargo workspaces differ: ${parts.join("; ")}; the build definition and the repository must agree before anything runs. The list that agrees: ${snippet}`);
|
|
209
|
+
}
|
|
210
|
+
return Object.freeze({ workspaces: sorted, listed: true });
|
|
211
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// GB-51 / GB-62 (BROKER-CONTRACT section 16, "HANDSHAKE.capabilities"): the gate receives the broker's HANDSHAKE itself and reads the capability list from it.
|
|
2
|
+
//
|
|
3
|
+
// `rustright doctor --json` reports the same list (`.broker.capabilities`), but through the client; a gate that plans a native tool or `cargo ndk` from a list it was handed second-hand has
|
|
4
|
+
// not seen what the broker advertised. This module speaks the one request the client's own health probe sends (`STATUS runtime-health-probe`, a by-id request the front gate answers with
|
|
5
|
+
// the HANDSHAKE as its FIRST event whatever else it says, GB-75), reads that first event, and parses it strictly. The runtime identity the request carries is the manifest beside the
|
|
6
|
+
// installed client (`runtime-manifest.json`, the file the client itself reads); the endpoint is the one RustRight's discovery names (managed-roots.mjs discoverBrokerSocket: the
|
|
7
|
+
// explicit variable, the config's pipe on Windows, `broker.sock` of a managed control root elsewhere). A list is advertised only by a handshake that was received, and the gate plans only
|
|
8
|
+
// against the names it carries: capability names are compared exactly, never by prefix or case.
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import net from "node:net";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { resolveBrokerClient } from "./broker-client.mjs";
|
|
13
|
+
import { discoverBrokerSocket } from "./managed-roots.mjs";
|
|
14
|
+
|
|
15
|
+
/** `rustright_protocol::BUILD_CONTROL_PROTOCOL`: carried in every request envelope. */
|
|
16
|
+
export const BUILD_CONTROL_PROTOCOL = "rustright.build-control.client.v2";
|
|
17
|
+
/** The id of the client's own health probe (`rustright-cli` doctor: PROBE_ID); a by-id request, so it is not held to the exact runtime digest. */
|
|
18
|
+
export const HANDSHAKE_PROBE_ID = "runtime-health-probe";
|
|
19
|
+
export const RUNTIME_MANIFEST_FILE = "runtime-manifest.json";
|
|
20
|
+
export const RUNTIME_MANIFEST_SCHEMA = "rustright.build-control.runtime.v2";
|
|
21
|
+
const LEGACY_RUNTIME_MANIFEST_SCHEMA = "rightkit.build-control.runtime.v2";
|
|
22
|
+
/** A client accepts at most 1 MiB per line (rustright-protocol MAX_BROKER_LINE_BYTES). */
|
|
23
|
+
export const MAX_HANDSHAKE_LINE_BYTES = 1 << 20;
|
|
24
|
+
export const HANDSHAKE_TIMEOUT_MS = 20_000;
|
|
25
|
+
|
|
26
|
+
const isDigest = (v) => typeof v === "string" && /^[0-9a-f]{64}$/.test(v);
|
|
27
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
28
|
+
|
|
29
|
+
/** The runtime identity of a parsed object (`schema` current or legacy, a version, both digests lowercase 64-hex), as RustRight's `RuntimeIdentity::new` validates it. */
|
|
30
|
+
function normalizeIdentity(raw) {
|
|
31
|
+
if (!isObject(raw) || ![RUNTIME_MANIFEST_SCHEMA, LEGACY_RUNTIME_MANIFEST_SCHEMA].includes(raw.schema) || typeof raw.version !== "string" || raw.version === "" || !isDigest(raw.clientDigest) || !isDigest(raw.brokerDigest)) return null;
|
|
32
|
+
return { schema: RUNTIME_MANIFEST_SCHEMA, version: raw.version, clientDigest: raw.clientDigest, brokerDigest: raw.brokerDigest };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The runtime identity a `runtime-manifest.json` text holds: `{ ok: true, identity }` or `{ ok: false, problem }`. */
|
|
36
|
+
export function parseRuntimeIdentity(text) {
|
|
37
|
+
let doc;
|
|
38
|
+
try { doc = JSON.parse(text); } catch { return { ok: false, problem: "the runtime manifest is not JSON" }; }
|
|
39
|
+
const identity = normalizeIdentity(doc);
|
|
40
|
+
return identity ? { ok: true, identity } : { ok: false, problem: "the runtime manifest is not a runtime identity (schema, version, clientDigest and brokerDigest are required)" };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The broker's HANDSHAKE event (a line of JSON or the parsed object) as `{ ok: true, capabilities, runtime }` or `{ ok: false, problem }`. `capabilities` is additive on the wire: absent means the broker
|
|
45
|
+
* advertises none (a broker that predates it), which is an answer; a present value must be an array of non-empty strings, each once, or the handshake is refused (a name that is not a string is no
|
|
46
|
+
* name, and a list that repeats itself is not read).
|
|
47
|
+
*/
|
|
48
|
+
export function parseHandshake(input) {
|
|
49
|
+
let event = input;
|
|
50
|
+
if (typeof input === "string") { try { event = JSON.parse(input); } catch { return { ok: false, problem: "the broker's first reply line is not JSON" }; } }
|
|
51
|
+
if (!isObject(event) || typeof event.type !== "string") return { ok: false, problem: "the broker's first reply is not a HANDSHAKE event (it has no type)" };
|
|
52
|
+
if (event.type !== "HANDSHAKE") return { ok: false, problem: `the broker's first event is ${event.type}, not the HANDSHAKE (every reply begins with it, GB-75): its capabilities were not received` };
|
|
53
|
+
const runtime = normalizeIdentity(event.runtime);
|
|
54
|
+
if (!runtime) return { ok: false, problem: "the broker's HANDSHAKE carries no valid runtime identity" };
|
|
55
|
+
let capabilities = [];
|
|
56
|
+
if (Object.hasOwn(event, "capabilities")) {
|
|
57
|
+
const list = event.capabilities;
|
|
58
|
+
if (!Array.isArray(list)) return { ok: false, problem: "the broker's HANDSHAKE capabilities must be an array of strings" };
|
|
59
|
+
for (const [i, name] of list.entries()) {
|
|
60
|
+
if (typeof name !== "string") return { ok: false, problem: `the broker's HANDSHAKE capabilities[${i}] is not a string` };
|
|
61
|
+
if (name === "") return { ok: false, problem: `the broker's HANDSHAKE capabilities[${i}] is empty` };
|
|
62
|
+
}
|
|
63
|
+
if (new Set(list).size !== list.length) return { ok: false, problem: "the broker's HANDSHAKE lists a capability twice: a list that repeats itself is not read" };
|
|
64
|
+
capabilities = [...list];
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, capabilities, runtime };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Receives the broker's HANDSHAKE: connects to `endpoint` (a Unix socket path or a Windows pipe name, through `connect`, `net.connect` by default), sends the client's own health probe with `identity`
|
|
71
|
+
* and parses the FIRST event of the reply. Resolves, never rejects: `{ ok: true, capabilities, runtime }` or `{ ok: false, problem }` (no endpoint, refused, closed, silent for `timeoutMs`, an
|
|
72
|
+
* oversized line, anything but the HANDSHAKE first, a malformed list).
|
|
73
|
+
*/
|
|
74
|
+
export function readBrokerHandshake({ endpoint, identity, connect = (target) => net.connect({ path: target }), timeoutMs = HANDSHAKE_TIMEOUT_MS, maxLineBytes = MAX_HANDSHAKE_LINE_BYTES }) {
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
let settled = false; let connected = false; let buffer = Buffer.alloc(0); let conn = null; let timer = null;
|
|
77
|
+
const finish = (result) => {
|
|
78
|
+
if (settled) return;
|
|
79
|
+
settled = true; clearTimeout(timer);
|
|
80
|
+
try { conn?.destroy(); } catch { /* already gone */ }
|
|
81
|
+
resolve(result);
|
|
82
|
+
};
|
|
83
|
+
const problem = (text) => finish({ ok: false, problem: text });
|
|
84
|
+
try { conn = connect(endpoint); } catch (error) { problem(`cannot connect to the broker at ${endpoint} (${error?.code ?? error?.message ?? error})`); return; }
|
|
85
|
+
timer = setTimeout(() => problem(`the broker at ${endpoint} sent no HANDSHAKE within ${timeoutMs} ms`), timeoutMs);
|
|
86
|
+
conn.on("connect", () => {
|
|
87
|
+
connected = true;
|
|
88
|
+
try { conn.write(`${JSON.stringify({ type: "STATUS", protocol: BUILD_CONTROL_PROTOCOL, runtime: identity, id: HANDSHAKE_PROBE_ID })}\n`); } catch (error) { problem(`cannot send the probe to the broker at ${endpoint} (${error?.message ?? error})`); }
|
|
89
|
+
});
|
|
90
|
+
conn.on("data", (chunk) => {
|
|
91
|
+
if (settled) return;
|
|
92
|
+
buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))]);
|
|
93
|
+
const at = buffer.indexOf(0x0a);
|
|
94
|
+
if (at < 0) { if (buffer.length > maxLineBytes) problem(`the broker's first line exceeds ${maxLineBytes} bytes`); return; }
|
|
95
|
+
if (at + 1 > maxLineBytes) { problem(`the broker's first line exceeds ${maxLineBytes} bytes`); return; }
|
|
96
|
+
finish(parseHandshake(buffer.subarray(0, at).toString("utf8")));
|
|
97
|
+
});
|
|
98
|
+
conn.on("error", (error) => problem(connected ? `the broker connection at ${endpoint} failed before its HANDSHAKE (${error?.code ?? error?.message ?? error})` : `cannot connect to the broker at ${endpoint} (${error?.code ?? error?.message ?? error})`));
|
|
99
|
+
conn.on("close", () => problem(`the broker at ${endpoint} closed before its HANDSHAKE`));
|
|
100
|
+
conn.on("end", () => problem(`the broker at ${endpoint} closed before its HANDSHAKE`));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const isPipe = (endpoint) => /^\\\\\.\\pipe\\/i.test(String(endpoint));
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The broker's HANDSHAKE as the gate receives it: the installed `client` (an absolute path; a Windows .cmd shim resolves to its .exe), the runtime identity of its manifest, the endpoint RustRight's
|
|
108
|
+
* discovery names, and the handshake read from it. A Unix endpoint must be a socket the current user owns (a planted file or another user's socket is not the broker). `{ ok, capabilities, runtime }`
|
|
109
|
+
* or `{ ok: false, problem }`.
|
|
110
|
+
*/
|
|
111
|
+
export async function queryBrokerHandshake(o, { client = o?.client } = {}) {
|
|
112
|
+
const platform = o?.platform ?? process.platform;
|
|
113
|
+
const env = o?.env ?? process.env;
|
|
114
|
+
const read = o?.readFile ?? ((file) => fs.readFileSync(file, "utf8"));
|
|
115
|
+
if (typeof client !== "string" || !client) return { ok: false, problem: "the RustRight client is not on PATH, so its runtime identity cannot be read to receive the broker's HANDSHAKE" };
|
|
116
|
+
let exe;
|
|
117
|
+
try { exe = resolveBrokerClient(client, { platform, readFile: read }).file; } catch (error) { return { ok: false, problem: `the RustRight client cannot be resolved (${error.message})` }; }
|
|
118
|
+
let real = exe;
|
|
119
|
+
if (platform !== "win32") { try { real = (o?.realpath ?? fs.realpathSync.native)(exe); } catch { real = exe; } }
|
|
120
|
+
const paths = platform === "win32" ? path.win32 : path.posix;
|
|
121
|
+
const manifest = paths.join(paths.dirname(real), RUNTIME_MANIFEST_FILE);
|
|
122
|
+
let text;
|
|
123
|
+
try { text = read(manifest); } catch (error) { return { ok: false, problem: `cannot read the runtime manifest ${manifest} beside the client (${error?.message ?? error}), so the HANDSHAKE probe has no identity to carry` }; }
|
|
124
|
+
const identity = parseRuntimeIdentity(text);
|
|
125
|
+
if (!identity.ok) return { ok: false, problem: `${manifest}: ${identity.problem}` };
|
|
126
|
+
let endpoint;
|
|
127
|
+
try { endpoint = (o?.discoverEndpoint ?? discoverBrokerSocket)(env, { platform, exists: fs.existsSync }); } catch (error) { return { ok: false, problem: `the broker endpoint cannot be discovered (${error.message})` }; }
|
|
128
|
+
if (!endpoint) return { ok: false, problem: "no broker endpoint is configured (no explicit socket variable and no broker.sock under a managed control root)" };
|
|
129
|
+
if (platform !== "win32" && !isPipe(endpoint)) {
|
|
130
|
+
try {
|
|
131
|
+
const stat = (o?.lstat ?? fs.lstatSync)(endpoint);
|
|
132
|
+
const uid = typeof (o?.uid ?? process.getuid?.()) === "number" ? (o?.uid ?? process.getuid()) : null;
|
|
133
|
+
if (!stat.isSocket()) return { ok: false, problem: `${endpoint} is not a socket, so it is not the broker's endpoint` };
|
|
134
|
+
if (uid !== null && stat.uid !== uid) return { ok: false, problem: `${endpoint} is owned by another user (uid ${stat.uid}), so it is not this user's broker` };
|
|
135
|
+
} catch (error) { return { ok: false, problem: `cannot connect to the broker at ${endpoint} (${error?.code ?? error?.message ?? error})` }; }
|
|
136
|
+
}
|
|
137
|
+
return readBrokerHandshake({ endpoint, identity: identity.identity, ...(o?.connectBroker ? { connect: o.connectBroker } : {}), ...(o?.handshakeTimeoutMs ? { timeoutMs: o.handshakeTimeoutMs } : {}) });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The two views of the broker's advertised capabilities: `doctor` (`rustright doctor --json` through the client, `{ ok, capabilities } | { ok: false, problem }`) and `handshake` (received by the gate,
|
|
142
|
+
* `{ ok, capabilities, runtime } | { ok: false, problem } | null` when none was asked). Both answering must agree exactly (a disagreement refuses, naming each side's extra names: a broker that
|
|
143
|
+
* restarted between the reads, or a doctor that does not report what the broker said, plans nothing); one answering plans from it with the other's problem kept beside it; neither refuses
|
|
144
|
+
* with both reasons. `null` handshake leaves the doctor's state untouched.
|
|
145
|
+
*/
|
|
146
|
+
export function mergeCapabilityViews({ doctor, handshake }) {
|
|
147
|
+
if (handshake === null || handshake === undefined) return doctor;
|
|
148
|
+
const d = doctor?.ok === true, h = handshake.ok === true;
|
|
149
|
+
if (d && h) {
|
|
150
|
+
const fromDoctor = new Set(doctor.capabilities), fromHandshake = new Set(handshake.capabilities);
|
|
151
|
+
const onlyDoctor = [...fromDoctor].filter((c) => !fromHandshake.has(c)), onlyHandshake = [...fromHandshake].filter((c) => !fromDoctor.has(c));
|
|
152
|
+
if (onlyDoctor.length || onlyHandshake.length) {
|
|
153
|
+
return { ok: false, problem: `\`rustright doctor --json\` and the broker's HANDSHAKE disagree about what it advertises (only the doctor lists: ${onlyDoctor.join(", ") || "none"}; only the HANDSHAKE lists: ${onlyHandshake.join(", ") || "none"}); nothing is planned from either` };
|
|
154
|
+
}
|
|
155
|
+
return { ok: true, capabilities: [...handshake.capabilities], via: "handshake+doctor" };
|
|
156
|
+
}
|
|
157
|
+
if (h) return { ok: true, capabilities: [...handshake.capabilities], via: "handshake", doctorProblem: doctor?.problem ?? "the doctor did not answer" };
|
|
158
|
+
if (d) return { ok: true, capabilities: [...doctor.capabilities], via: "doctor", handshakeProblem: handshake.problem };
|
|
159
|
+
return { ok: false, problem: `${doctor?.problem ?? "the doctor did not answer"}; and the HANDSHAKE could not be received: ${handshake.problem}` };
|
|
160
|
+
}
|
package/broker-pins.mjs
CHANGED
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
// Never read here: the broker's control root, its runtime manifest, `attestation/key.json` (it holds the SECRET key), an environment
|
|
11
11
|
// variable, a flag, the snapshot or a receipt. A missing record in any repository but RightKit refuses the snapshot compile; nothing
|
|
12
12
|
// is enrolled automatically.
|
|
13
|
+
//
|
|
14
|
+
// Platforms: darwin and win32 (every build on those hosts goes through the broker) and linux (a broker is ALLOWED on Linux, never
|
|
15
|
+
// required: GATE-CONTRACT "Linux needs no broker, but a broker is allowed on Linux"). A Linux host with no declared broker stays direct
|
|
16
|
+
// and never reaches this file; only the declared route (managed-cargo.mjs, RIGHTKIT_LINUX_BROKER_*) loads pins, and there the enrolled
|
|
17
|
+
// record decides: the running-gate fallback for linux is null unless ci/rk-pipeline.json carries `attestation.linux`.
|
|
13
18
|
import crypto from "node:crypto";
|
|
14
19
|
import fs from "node:fs";
|
|
15
20
|
import path from "node:path";
|
|
@@ -28,10 +33,12 @@ const fail = (code, message) => { throw new BrokerPinsError(code, message); };
|
|
|
28
33
|
const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
|
|
29
34
|
const HEX = /^(?:[0-9a-f]{2})+$/;
|
|
30
35
|
|
|
36
|
+
export const BROKER_PLATFORMS = Object.freeze(["darwin", "win32", "linux"]);
|
|
37
|
+
|
|
31
38
|
/** The pin record of one platform under the managed data root. */
|
|
32
39
|
export const pinsFile = ({ dataRoot, platform = process.platform }) => {
|
|
33
40
|
if (!dataRoot || !path.isAbsolute(dataRoot)) fail("RK_BROKER_PINS_ROOT", "the managed data root must be an absolute path");
|
|
34
|
-
if (!
|
|
41
|
+
if (!BROKER_PLATFORMS.includes(platform)) fail("RK_BROKER_PINS_PLATFORM", `${platform} is not a broker host (${BROKER_PLATFORMS.join(", ")} only)`);
|
|
35
42
|
return path.join(dataRoot, PINS_DIR, `broker-${platform}.json`);
|
|
36
43
|
};
|
|
37
44
|
|
|
@@ -72,10 +79,12 @@ const git = (spawn, cwd, args) => {
|
|
|
72
79
|
};
|
|
73
80
|
|
|
74
81
|
/**
|
|
75
|
-
*
|
|
76
|
-
* that repository IS the checkout being compiled (RightKit itself). Null when the gate is not run from a RightKit checkout
|
|
82
|
+
* The parsed `ci/rk-pipeline.json` of the running gate's own revision: the repository that holds `gateFile` and its HEAD commit, only when
|
|
83
|
+
* that repository IS the checkout being compiled (RightKit itself). Null when the gate is not run from a RightKit checkout or the file is absent;
|
|
84
|
+
* a revision that cannot be read, or a file that is not the pipeline schema, is a refusal. Never the working tree, the selected commit or the snapshot.
|
|
85
|
+
* `{ pipeline, revision }`. Shared by the pins fallback below and the scan-tool policy fallback (scan-tool-enrolment.mjs).
|
|
77
86
|
*/
|
|
78
|
-
export function
|
|
87
|
+
export function readGateRevisionPipeline({ gateFile, checkout, spawn = spawnSync, realpath = (p) => fs.realpathSync.native(p) }) {
|
|
79
88
|
const here = path.dirname(gateFile);
|
|
80
89
|
const top = git(spawn, here, ["rev-parse", "--show-toplevel"]);
|
|
81
90
|
if (!top) return null;
|
|
@@ -89,9 +98,19 @@ export function readGateRevisionPins({ gateFile, checkout, platform = process.pl
|
|
|
89
98
|
let pipeline;
|
|
90
99
|
try { pipeline = JSON.parse(String(blob.stdout)); } catch (e) { fail("RK_BROKER_PINS_INVALID", `${PIPELINE_FILE} at the gate's revision ${revision.slice(0, 12)} is not JSON (${e.message})`); }
|
|
91
100
|
if (pipeline?.schema !== PIPELINE_SCHEMA) fail("RK_BROKER_PINS_INVALID", `${PIPELINE_FILE} at the gate's revision is not ${PIPELINE_SCHEMA}`);
|
|
92
|
-
|
|
101
|
+
return { pipeline, revision };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* RightKit's own pins, from the revision of the running gate: `attestation.<platform>` of readGateRevisionPipeline. Null when the gate is
|
|
106
|
+
* not run from a RightKit checkout or the file carries no entry for the platform (linux has none unless the file adds `attestation.linux`).
|
|
107
|
+
*/
|
|
108
|
+
export function readGateRevisionPins({ gateFile, checkout, platform = process.platform, spawn = spawnSync, realpath = (p) => fs.realpathSync.native(p) }) {
|
|
109
|
+
const read = readGateRevisionPipeline({ gateFile, checkout, spawn, realpath });
|
|
110
|
+
if (!read) return null;
|
|
111
|
+
const entry = read.pipeline.attestation?.[platform];
|
|
93
112
|
if (!entry) return null;
|
|
94
|
-
return { ...validatePins(entry, `${PIPELINE_FILE} (${platform}) at the gate's revision ${revision.slice(0, 12)}`), source: "rk-pipeline", revision };
|
|
113
|
+
return { ...validatePins(entry, `${PIPELINE_FILE} (${platform}) at the gate's revision ${read.revision.slice(0, 12)}`), source: "rk-pipeline", revision: read.revision };
|
|
95
114
|
}
|
|
96
115
|
|
|
97
116
|
/**
|
|
@@ -128,7 +147,7 @@ export function enrollBrokerPins({ dataRoot, platform = process.platform, host,
|
|
|
128
147
|
return { file, record, rotated: Boolean(existing) };
|
|
129
148
|
}
|
|
130
149
|
|
|
131
|
-
/** `node broker-pins.mjs enroll --platform <darwin|win32> --data-root <dir> --host H --key-id K --public-key P --broker-digest D [--rotate]` or `show`. */
|
|
150
|
+
/** `node broker-pins.mjs enroll --platform <darwin|win32|linux> --data-root <dir> --host H --key-id K --public-key P --broker-digest D [--rotate]` or `show`. */
|
|
132
151
|
export function main(argv, { out = (m) => process.stdout.write(`${m}\n`), err = (m) => process.stderr.write(`${m}\n`) } = {}) {
|
|
133
152
|
const [command, ...rest] = argv;
|
|
134
153
|
const opt = (name) => { const i = rest.indexOf(name); return i < 0 ? undefined : rest[i + 1]; };
|
|
@@ -144,7 +163,7 @@ export function main(argv, { out = (m) => process.stdout.write(`${m}\n`), err =
|
|
|
144
163
|
out(pins ? JSON.stringify(pins) : "no enrollment");
|
|
145
164
|
return pins ? 0 : 1;
|
|
146
165
|
}
|
|
147
|
-
err("usage: broker-pins.mjs enroll|show --platform <darwin|win32> --data-root <dir> [--host H --key-id K --public-key P --broker-digest D [--rotate]]");
|
|
166
|
+
err("usage: broker-pins.mjs enroll|show --platform <darwin|win32|linux> --data-root <dir> [--host H --key-id K --public-key P --broker-digest D [--rotate]]");
|
|
148
167
|
return 2;
|
|
149
168
|
} catch (e) { err(`${e.code ?? "error"}: ${e.message}`); return 1; }
|
|
150
169
|
}
|