@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,226 @@
|
|
|
1
|
+
// The door's mirror of the broker's `rightkit-pnpm` adapter grammar (rustright-policy adapter.rs `scan`, `check_argv`, `snapshot_argv_check`,
|
|
2
|
+
// `check_environment`; GB-03 and GB-04 of the gate-vs-broker audit).
|
|
3
|
+
//
|
|
4
|
+
// RustRight owns the registered grammar; RightKit validates every pnpm argv and every request environment against that registration BEFORE it is
|
|
5
|
+
// sent, and refuses with a typed reason naming the registration, so a refusal never costs a mark, a process or a stage. The reference registration
|
|
6
|
+
// is RustRight's `adapters.pnpm` entry of docs/contracts/adapter-argv.fixtures.json (test-support/vectors/, pinned in vectors-source.json): the
|
|
7
|
+
// operator installs exactly that registration, and every accepted and refused row of the vector is judged by `adapterArgvRefusal` here.
|
|
8
|
+
//
|
|
9
|
+
// Facts the planner depends on (the registration, not RightKit inventions): a global flag that takes a value is registered as a flag AND as a leading
|
|
10
|
+
// subcommand token (`--dir`, `-C`, `--filter`); `--if-present` is a `run` option and goes after `run`; everything after the target of `run` or
|
|
11
|
+
// `exec` belongs to the target and is opaque (`run x --flag` and `run x -- --flag` are both admitted, so the gate never rewrites them); `exec`
|
|
12
|
+
// admits only the targets in `opaqueTargets.exec`, never an arbitrary executable.
|
|
13
|
+
//
|
|
14
|
+
// The door reads the LIVE registration when it can (liveAdapterGrammar) and otherwise judges by PNPM_ADAPTER_GRAMMAR, the reference. A test compares
|
|
15
|
+
// the constant with the vector and, on a host that has the registration, with the live entry, so neither can drift without a failing test.
|
|
16
|
+
import fs, { readFileSync } from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { resolveManagedRoots } from "./managed-roots.mjs";
|
|
19
|
+
|
|
20
|
+
export const PNPM_ADAPTER = "rightkit-pnpm";
|
|
21
|
+
/** The scratch child prefix of a `scratchPackDir` / `scratchPath` value (adapter.rs SCRATCH_CHILD_PREFIX). */
|
|
22
|
+
const SCRATCH_CHILD_PREFIX = "rk-local-publish-";
|
|
23
|
+
|
|
24
|
+
const frozenList = (v) => Object.freeze(Array.isArray(v) ? v.filter((s) => typeof s === "string") : []);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The reference registration of the pnpm adapter, as RustRight's vector carries it (docs/contracts/adapter-argv.fixtures.json `adapter`, minus the
|
|
28
|
+
* host's pinned script and scratch root): `subcommands` are the registered first arguments, `flags` the registered flags with their value kind,
|
|
29
|
+
* `env` the only non-reserved request-environment keys the adapter takes.
|
|
30
|
+
*/
|
|
31
|
+
export const PNPM_ADAPTER_GRAMMAR = Object.freeze({
|
|
32
|
+
subcommands: Object.freeze(["install", "run", "exec", "pack", "-r", "--dir", "-C", "--filter", "--workspace-concurrency=1"]),
|
|
33
|
+
flags: Object.freeze({
|
|
34
|
+
"--dir": "relpathOrScratchPath", "-C": "relpathOrScratchPath", "--filter": "any", "--if-present": "none", "--workspace-concurrency": "int", "--pack-destination": "scratchPackDir",
|
|
35
|
+
}),
|
|
36
|
+
positionals: "relpath",
|
|
37
|
+
env: Object.freeze(["RK_ISOLATED_CHECKOUT", "RIGHTKIT_QA_APP_BINARY", "RIGHTKIT_QA_SIGNING_EVIDENCE"]),
|
|
38
|
+
opaqueAfterTarget: Object.freeze(["run", "exec"]),
|
|
39
|
+
opaqueTargets: Object.freeze({ exec: Object.freeze(["tauri"]) }),
|
|
40
|
+
snapshotSubcommands: Object.freeze(["run", "exec", "pack"]),
|
|
41
|
+
// The host's scratch root is host configuration (the vector's `{scratch}` placeholder): unknown here, so a scratch-kind value is judged by shape and the
|
|
42
|
+
// broker's canonical containment check decides (a live registration, which names its root, is judged canonically too).
|
|
43
|
+
scratchRoot: undefined,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** The grammar a registered adapter spec declares (the fields the door uses), or null when `spec` is not a usable registration. */
|
|
47
|
+
export function grammarFromSpec(spec) {
|
|
48
|
+
if (!spec || typeof spec !== "object" || (spec.subcommands !== undefined && spec.subcommands !== null && (!Array.isArray(spec.subcommands) || !spec.subcommands.every((s) => typeof s === "string" && s !== "")))) return null;
|
|
49
|
+
if (!Array.isArray(spec.flags) || !spec.flags.every((f) => f && typeof f.name === "string" && typeof f.value === "string")) return null;
|
|
50
|
+
const targets = spec.opaqueTargets && typeof spec.opaqueTargets === "object" && !Array.isArray(spec.opaqueTargets) ? spec.opaqueTargets : {};
|
|
51
|
+
return Object.freeze({
|
|
52
|
+
subcommands: Array.isArray(spec.subcommands) ? Object.freeze([...spec.subcommands]) : null,
|
|
53
|
+
flags: Object.freeze(Object.fromEntries(spec.flags.map((f) => [f.name, f.value]))),
|
|
54
|
+
positionals: typeof spec.positionals === "string" ? spec.positionals : "none",
|
|
55
|
+
env: frozenList(spec.env),
|
|
56
|
+
opaqueAfterTarget: frozenList(spec.opaqueAfterTarget),
|
|
57
|
+
...(Array.isArray(spec.positionalValues) ? { positionalValues: frozenList(spec.positionalValues) } : {}),
|
|
58
|
+
...(spec.flagPrefixes && typeof spec.flagPrefixes === "object" ? { flagPrefixes: Object.freeze({ ...spec.flagPrefixes }) } : {}),
|
|
59
|
+
opaqueTargets: Object.freeze(Object.fromEntries(Object.entries(targets).map(([command, list]) => [command, frozenList(list)]))),
|
|
60
|
+
snapshotSubcommands: frozenList(spec.snapshotSubcommands),
|
|
61
|
+
scratchRoot: typeof spec.scratchRoot === "string" && spec.scratchRoot !== "" ? spec.scratchRoot : null,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The live registration on this host (the managed control root's config.json, adapters["rightkit-pnpm"]), or null when it cannot be read. */
|
|
66
|
+
export function liveAdapterGrammar({ env = process.env, platform = process.platform, readFile = (file) => readFileSync(file, "utf8"), adapter = PNPM_ADAPTER } = {}) {
|
|
67
|
+
try {
|
|
68
|
+
const { configPath } = resolveManagedRoots({ env, platform });
|
|
69
|
+
return grammarFromSpec(JSON.parse(readFile(configPath))?.adapters?.[adapter]);
|
|
70
|
+
} catch { return null; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---- value kinds (adapter.rs value_ok / relpath_ok / scratch_shape_ok) ----
|
|
74
|
+
|
|
75
|
+
const relpathOk = (v) => v !== "" && !v.includes("\0") && !v.startsWith("/") && !v.startsWith("\\") && !(v.length >= 2 && v[1] === ":") && v.split(/[\\/]/).every((part) => part !== "..");
|
|
76
|
+
const isAbsoluteAnywhere = (v) => path.posix.isAbsolute(v) || path.win32.isAbsolute(v) || v.startsWith("\\");
|
|
77
|
+
const scratchShapeOk = (v) => v !== "" && !v.includes("\0") && (path.isAbsolute(v) || path.posix.isAbsolute(v) || path.win32.isAbsolute(v)) && v.split(/[\\/]/).every((part) => part !== "..");
|
|
78
|
+
/** Build settings a caller never sets for an Apple build: they redirect where the build writes (the broker owns the output root) or choose the signing identity (the provisioned signer's). */
|
|
79
|
+
const XCODE_DENIED_SETTINGS = /^(?:SYMROOT|OBJROOT|DSTROOT|BUILD_DIR|BUILD_ROOT|CONFIGURATION_BUILD_DIR|CONFIGURATION_TEMP_DIR|TARGET_BUILD_DIR|SHARED_PRECOMPS_DIR|MODULE_CACHE_DIR|DERIVED_DATA_DIR|CODE_SIGN_IDENTITY|CODE_SIGN_STYLE|OTHER_CODE_SIGN_FLAGS|PROVISIONING_PROFILE(?:_SPECIFIER)?|CODE_SIGN_ENTITLEMENTS|CODE_SIGNING_ALLOWED|CODE_SIGNING_REQUIRED)=/;
|
|
80
|
+
/** CMake cache entries that run or load arbitrary code from a path the caller names. */
|
|
81
|
+
const CMAKE_DENIED_DEFINES = /^(?:CMAKE_COMMAND|CMAKE_MAKE_PROGRAM|CMAKE_PROJECT_INCLUDE(?:_BEFORE)?|CMAKE_MODULE_PATH|CMAKE_PREFIX_PATH|CMAKE_PROGRAM_PATH)[:=]/;
|
|
82
|
+
function valueOk(kind, v) {
|
|
83
|
+
switch (kind) {
|
|
84
|
+
case "identifier": return /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/.test(v);
|
|
85
|
+
case "gradleTask": return /^:?[A-Za-z0-9][A-Za-z0-9_:.-]{0,255}$/.test(v) && !/(?:^|:)(?:publish|upload)|(?:bundle|assemble|install)[A-Za-z0-9]*Release/i.test(v);
|
|
86
|
+
case "actionOrBuildSetting": return v.includes("=") ? valueOk("buildSetting", v) : valueOk("identifier", v);
|
|
87
|
+
case "buildSetting": return /^[A-Z][A-Za-z0-9_]*=[^\0]*$/.test(v) && !XCODE_DENIED_SETTINGS.test(v);
|
|
88
|
+
case "cmakeDefine": return /^[A-Za-z_][A-Za-z0-9_.+-]*(?::[A-Z]+)?=[^\0]*$/.test(v) && !CMAKE_DENIED_DEFINES.test(v);
|
|
89
|
+
case "none": return false;
|
|
90
|
+
case "any": return !v.includes("\0");
|
|
91
|
+
case "int": return v !== "" && /^[0-9]+$/.test(v);
|
|
92
|
+
case "swiftConfiguration": return v === "debug" || v === "release";
|
|
93
|
+
case "swiftArch": return v === "arm64" || v === "x86_64";
|
|
94
|
+
case "relpath": return relpathOk(v);
|
|
95
|
+
case "scratchPath": case "scratchPackDir": return scratchShapeOk(v);
|
|
96
|
+
case "relpathOrScratchPath": return relpathOk(v) || scratchShapeOk(v);
|
|
97
|
+
default: return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const needsScratchCheck = (kind, v) => kind === "scratchPath" || kind === "scratchPackDir" || (kind === "relpathOrScratchPath" && !relpathOk(v));
|
|
101
|
+
|
|
102
|
+
/** adapter.rs `scratch_canonical_ok`: the value exists and, resolved, is strictly inside an `rk-local-publish-*` child of the scratch root (a pack dir: an empty-of-tgz directory). */
|
|
103
|
+
function scratchCanonicalProblem(root, value, packDir, { realpath = (p) => fs.realpathSync.native(p), stat = (p) => fs.statSync(p), list = (p) => fs.readdirSync(p) } = {}) {
|
|
104
|
+
if (!scratchShapeOk(value)) return "must be an absolute path with no '..'";
|
|
105
|
+
let canonicalRoot; let canonical;
|
|
106
|
+
try { canonicalRoot = realpath(root); } catch (e) { return `the scratch root cannot be resolved: ${e.message}`; }
|
|
107
|
+
try { canonical = realpath(value); } catch (e) { return `cannot be resolved: ${e.message}`; }
|
|
108
|
+
const rel = path.relative(canonicalRoot, canonical);
|
|
109
|
+
if (rel === "" ) return "is the scratch root itself";
|
|
110
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) return "resolves outside the scratch root";
|
|
111
|
+
const parts = rel.split(path.sep);
|
|
112
|
+
if (!parts[0].startsWith(SCRATCH_CHILD_PREFIX)) return `is not inside a ${SCRATCH_CHILD_PREFIX}* child of the scratch root`;
|
|
113
|
+
if (parts.length < 2) return "is the scratch child itself; it must be strictly inside it";
|
|
114
|
+
if (packDir) {
|
|
115
|
+
try { if (!stat(canonical).isDirectory()) return "must be an existing directory"; if (list(canonical).some((n) => n.toLowerCase().endsWith(".tgz"))) return "must hold no .tgz yet"; } catch (e) { return `cannot be read: ${e.message}`; }
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const flagNameOf = (word) => (word.startsWith("--") && word.includes("=") ? word.slice(0, word.indexOf("=")) : word);
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The pure pass of adapter.rs `scan`: every argument is a registered flag (with its value), a positional of the allowed kind, or the first-argument
|
|
124
|
+
* subcommand. Returns `{ error }` or `{ command, positionals, opaqueTail, flagValues, scratch }`. A subcommand that is also a registered flag
|
|
125
|
+
* (`--dir`) is read as that flag, so its value is judged by the flag's kind and never taken for a positional.
|
|
126
|
+
*/
|
|
127
|
+
export function scanAdapterArgv(argv, grammar = PNPM_ADAPTER_GRAMMAR) {
|
|
128
|
+
const args = [...argv].map(String);
|
|
129
|
+
const found = { command: null, target: null, positionals: [], opaqueTail: [], flagValues: [], scratch: [] };
|
|
130
|
+
let index = 0; let awaiting = false; let opaque = false; let commandSeen = false;
|
|
131
|
+
const first = args[0];
|
|
132
|
+
const name = grammar.adapter ?? PNPM_ADAPTER;
|
|
133
|
+
if (grammar.subcommands === null) {
|
|
134
|
+
// No registered first argument (an adapter whose tool takes flags and positionals only, such as gradle, cmake and xcodebuild): the scan starts at the first word.
|
|
135
|
+
index = 0;
|
|
136
|
+
} else {
|
|
137
|
+
if (first === undefined) return { error: "a subcommand is required" };
|
|
138
|
+
if (!grammar.subcommands.includes(first)) return { error: `subcommand ${JSON.stringify(first)} is not registered for ${name} (registered: ${grammar.subcommands.join(", ")}); a leading flag such as --if-present, --prefix or --config is refused (--if-present goes after \`run\`)` };
|
|
139
|
+
awaiting = grammar.opaqueAfterTarget.includes(first);
|
|
140
|
+
commandSeen = !first.startsWith("-");
|
|
141
|
+
if (commandSeen) found.command = first;
|
|
142
|
+
index = first.startsWith("-") && Object.hasOwn(grammar.flags, first) ? 0 : 1;
|
|
143
|
+
}
|
|
144
|
+
let afterSeparator = false;
|
|
145
|
+
while (index < args.length) {
|
|
146
|
+
const word = args[index]; index += 1;
|
|
147
|
+
if (word.includes("\0")) return { error: "arguments must not contain NUL" };
|
|
148
|
+
if (opaque) { found.opaqueTail.push(word); continue; }
|
|
149
|
+
if (!afterSeparator && word === "--") { afterSeparator = true; continue; }
|
|
150
|
+
if (afterSeparator || !word.startsWith("-") || word === "-") {
|
|
151
|
+
if (grammar.positionals === "none" || !valueOk(grammar.positionals, word)) return { error: `positional argument ${JSON.stringify(word)} is not allowed` };
|
|
152
|
+
if (grammar.positionalValues && !word.includes("=") && !grammar.positionalValues.includes(word)) return { error: `positional argument ${JSON.stringify(word)} is not registered for ${grammar.adapter ?? PNPM_ADAPTER} (registered: ${grammar.positionalValues.join(", ")})` };
|
|
153
|
+
if (needsScratchCheck(grammar.positionals, word)) found.scratch.push(["positional argument", grammar.positionals, word]);
|
|
154
|
+
found.positionals.push(word);
|
|
155
|
+
if (awaiting) {
|
|
156
|
+
awaiting = false; opaque = true; found.target = word;
|
|
157
|
+
const allowed = grammar.opaqueTargets[found.command ?? ""];
|
|
158
|
+
if (allowed && !allowed.includes(word)) return { error: `target ${JSON.stringify(word)} is not registered for ${found.command} (registered targets: ${allowed.join(", ")}); an arbitrary executable is never admitted through exec` };
|
|
159
|
+
} else if (!commandSeen) {
|
|
160
|
+
commandSeen = true; found.command = word; awaiting = grammar.opaqueAfterTarget.includes(word);
|
|
161
|
+
}
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
// A registered flag PREFIX carries its value in the same word (`-DNAME=value`): the rest of the word is the value, judged by the prefix's kind.
|
|
165
|
+
const prefix = grammar.flagPrefixes ? Object.keys(grammar.flagPrefixes).find((p) => word.startsWith(p) && word.length > p.length) : undefined;
|
|
166
|
+
if (prefix !== undefined) {
|
|
167
|
+
const value = word.slice(prefix.length);
|
|
168
|
+
if (!valueOk(grammar.flagPrefixes[prefix], value)) return { error: `flag ${JSON.stringify(prefix)} has a value of the wrong kind or one ${grammar.adapter ?? PNPM_ADAPTER} never admits (${JSON.stringify(value)})` };
|
|
169
|
+
found.flagValues.push([`flag ${JSON.stringify(prefix)}`, grammar.flagPrefixes[prefix], value]);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const name = flagNameOf(word);
|
|
173
|
+
const inline = word.startsWith("--") && word.includes("=") ? word.slice(word.indexOf("=") + 1) : null;
|
|
174
|
+
if (!Object.hasOwn(grammar.flags, name)) return { error: `flag ${JSON.stringify(name)} is not registered for ${grammar.adapter ?? PNPM_ADAPTER}` };
|
|
175
|
+
const kind = grammar.flags[name];
|
|
176
|
+
let value;
|
|
177
|
+
if (kind === "none") { if (inline !== null) return { error: `flag ${JSON.stringify(name)} takes no value` }; continue; }
|
|
178
|
+
if (inline !== null) value = inline;
|
|
179
|
+
else {
|
|
180
|
+
value = args[index]; index += 1;
|
|
181
|
+
if (value === undefined) return { error: `flag ${JSON.stringify(name)} needs a value` };
|
|
182
|
+
if (value.includes("\0")) return { error: "arguments must not contain NUL" };
|
|
183
|
+
}
|
|
184
|
+
if (!valueOk(kind, value)) return { error: `flag ${JSON.stringify(name)} has a value of the wrong kind (${kind})` };
|
|
185
|
+
found.flagValues.push([`flag ${JSON.stringify(name)}`, kind, value]);
|
|
186
|
+
if (needsScratchCheck(kind, value)) found.scratch.push([`flag ${JSON.stringify(name)}`, kind, value]);
|
|
187
|
+
}
|
|
188
|
+
if (awaiting) return { error: "launcher subcommand requires a target" };
|
|
189
|
+
return found;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The pnpm command token the registration reads from `args` (adapter.rs scan's `command`), or null when the argv does not parse. */
|
|
193
|
+
export function pnpmCommandOf(args, grammar = PNPM_ADAPTER_GRAMMAR) {
|
|
194
|
+
const found = scanAdapterArgv(args, grammar);
|
|
195
|
+
return found.error ? null : found.command;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Why the broker's pnpm adapter would refuse these arguments, or null (adapter.rs `check_argv`; with `snapshot`, also `snapshot_argv_check`'s
|
|
200
|
+
* subcommand and relative-path rules). Judged against `grammar` (the live registration, else the reference). A scratch-kind value is checked canonically
|
|
201
|
+
* against the registration's scratch root when it names one (a value of that kind with no scratch root registered is refused). The tail of an opaque
|
|
202
|
+
* target is the target's own and is never interpreted here.
|
|
203
|
+
*/
|
|
204
|
+
export function adapterArgvRefusal(words, grammar = PNPM_ADAPTER_GRAMMAR, { snapshot = false, fsOps = {} } = {}) {
|
|
205
|
+
const found = scanAdapterArgv(words, grammar);
|
|
206
|
+
if (found.error) return found.error;
|
|
207
|
+
for (const [what, kind, value] of found.scratch) {
|
|
208
|
+
if (grammar.scratchRoot === undefined) continue;
|
|
209
|
+
if (grammar.scratchRoot === null) return `${what}: this adapter registers no scratch root`;
|
|
210
|
+
const problem = scratchCanonicalProblem(grammar.scratchRoot, value, kind === "scratchPackDir", fsOps);
|
|
211
|
+
if (problem) return `${what} ${JSON.stringify(value)}: ${problem}`;
|
|
212
|
+
}
|
|
213
|
+
if (snapshot) {
|
|
214
|
+
if (grammar.snapshotSubcommands.length) {
|
|
215
|
+
if (found.command === null) return "a snapshot-bound call needs a command";
|
|
216
|
+
if (!grammar.snapshotSubcommands.includes(found.command)) return `subcommand ${JSON.stringify(found.command)} is not allowed in a snapshot-bound call (allowed: ${grammar.snapshotSubcommands.join(", ")})`;
|
|
217
|
+
}
|
|
218
|
+
for (const [what, kind, value] of found.flagValues) {
|
|
219
|
+
if ((kind === "relpath" || kind === "relpathOrScratchPath" || kind === "scratchPath") && isAbsoluteAnywhere(value)) return `${what} ${JSON.stringify(value)} must be relative in a snapshot-bound call`;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Kept name of the door's pnpm check: the argv judged by `grammar` (a snapshot-bound call when `snapshot`). */
|
|
226
|
+
export const pnpmGrammarRefusal = (words, grammar = PNPM_ADAPTER_GRAMMAR, options = {}) => adapterArgvRefusal(words, grammar, options);
|
package/progress-control.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { spawn } from "./background-process.mjs";
|
|
1
|
+
import { spawn, spawnSync } from "./background-process.mjs";
|
|
2
|
+
import { brokerClientInvocation } from "./broker-client.mjs";
|
|
2
3
|
|
|
3
4
|
const MANAGED_REQUEST_PATTERN = /\brightkit(?: managed-agent)?: request ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/gi;
|
|
4
5
|
export const RELEASE_LIVENESS_OWNER_MARGIN_MS = 5 * 60 * 1000;
|
|
@@ -30,7 +31,11 @@ export function releaseProgressLimits(env = process.env, { livenessOwnerTimeoutM
|
|
|
30
31
|
|
|
31
32
|
function runRightkitControl(args, { cwd, env, timeoutMs = 30_000 } = {}) {
|
|
32
33
|
return new Promise((resolve) => {
|
|
33
|
-
|
|
34
|
+
// The client starts the way every gate child starts it (broker-client.mjs): no shell; on Windows the .cmd shim's .exe target.
|
|
35
|
+
let launch;
|
|
36
|
+
try { launch = brokerClientInvocation("rustright", args, { cwd, env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true }, { probe: spawnSync }); }
|
|
37
|
+
catch (error) { resolve(Object.freeze({ code: 125, output: "", timedOut: false, error: error.message })); return; }
|
|
38
|
+
const control = spawn(launch.command, launch.args, launch.options);
|
|
34
39
|
let output = "";
|
|
35
40
|
let timedOut = false;
|
|
36
41
|
const append = (chunk) => { output = `${output}${chunk}`.slice(-64 * 1024); };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "The enrolled pins the golden-vector receipt (provenance-real-receipt.fixture.json, rustright status 36efaa30-..., 2026-10-27) was signed under: the Mac broker key a9eb1cee..., from ci/rk-pipeline.json before RustRight rotated it on 2026-10-29T22:17:21Z (BR15, d367ceb6a). The golden vector is verified against THESE pins, never the live ones, so a rotation of the live pin cannot break it; the live pin is exercised by the optional RIGHTKIT_PROVENANCE_FIXTURE vector.",
|
|
3
|
+
"host": "Adrians-Mac-Mini.local",
|
|
4
|
+
"keyId": "a9eb1ceeaa6b1cec899a7e5d01a97a084308a585c3e2c409510d02774ef7134f",
|
|
5
|
+
"publicKey": "04248e98a439add3f13756be789c4091a5e9735fbe4e85768d394eedbffc685f63ae4ad3e1a68dfee87008955b308dcdf5d142b1a760bf45e4ea0b2d3430c22dcb",
|
|
6
|
+
"brokerDigest": "2b0d6c901ccbb1c4dee517d9fbec6bfd72d49aede200fae89a7659b8e141853e"
|
|
7
|
+
}
|
package/provenance-verify.mjs
CHANGED
|
@@ -6,6 +6,11 @@ import { createHash, createPublicKey, verify } from "node:crypto";
|
|
|
6
6
|
export const PROVENANCE_SCHEMA = "rustright.provenance.v1";
|
|
7
7
|
export const PROVENANCE_DOMAIN = "rustright-provenance-v1";
|
|
8
8
|
export const QUALIFICATION_NONCE_DOMAIN = "rightkit-qualification-v1";
|
|
9
|
+
export const CARRIED_CALL_NONCE_DOMAIN = "rightkit-carried-call-v1";
|
|
10
|
+
/** The environment variable that carries a release-bound stage's nonce. It is no carrier variable: every carried call, a Cargo call or an
|
|
11
|
+
* adapter job (a pnpm, node, swift or QA step), derives its own per-call nonce from it (carriedCallNonce) and the broker client never
|
|
12
|
+
* sees the stage nonce itself; no step takes it as its RUSTRIGHT_ATTEST_NONCE (local-publish.mjs). */
|
|
13
|
+
export const ATTEST_STAGE_NONCE_ENV = "RIGHTKIT_ATTEST_STAGE_NONCE";
|
|
9
14
|
|
|
10
15
|
export class ProvenanceError extends Error {
|
|
11
16
|
constructor(code, message) { super(message); this.name = "ProvenanceError"; this.code = code; }
|
|
@@ -103,12 +108,268 @@ export function verifyProvenance(receipt, pins, expected) {
|
|
|
103
108
|
return payload;
|
|
104
109
|
}
|
|
105
110
|
|
|
106
|
-
|
|
111
|
+
// ---- GB-10 / GB-11 / GB-50: the rest of rustright-protocol attestation.rs `verify_receipt` and `verify_receipt_strict`, case for case ----
|
|
112
|
+
// `verifyProvenance` above is the signature, pins, job, nonce and request-digest half. The functions below are the coverage half: a field the receipt carries
|
|
113
|
+
// and the signature does not cover (or the reverse) is refused like a changed one. `verifyReceiptLenient` = both halves (`verify_receipt`);
|
|
114
|
+
// `verifyReceiptStrict` adds the signed result evidence and the BR19 shape (`verify_receipt_strict`), which a gate applies to every compile receipt. The vector
|
|
115
|
+
// docs/contracts/receipt-vectors.v1.json (test-support/vectors/, pinned) is judged here case for case, lenient and strict.
|
|
116
|
+
export const STAGE_EVIDENCE_FIELDS = Object.freeze(["freshness", "attribution", "toolchain"]);
|
|
117
|
+
export const RESULT_EVIDENCE_FIELDS = Object.freeze(["exit", "signal", "timingsMs", "diagnosticsPath"]);
|
|
118
|
+
export const RESULT_TIMING_FIELDS = Object.freeze(["admission", "queue", "bind", "cargo", "afterExitBind", "diagnostics"]);
|
|
119
|
+
export const BR19_EVIDENCE_FIELDS = Object.freeze(["profile", "stage", "resources", "environment"]);
|
|
120
|
+
export const BR19_RESOURCE_FIELDS = Object.freeze(["grantedJobs", "memoryReservation", "priority"]);
|
|
121
|
+
export const RETIRED_RESULT_FIELDS = Object.freeze(["verdict", "refreshed"]);
|
|
122
|
+
const SNAPSHOT_EVIDENCE_NAMES = Object.freeze(["requestId", "brokerVersion", "executor", "commit", "snapshot", "declaredInputDigests", "ownership", "outputDir"]);
|
|
123
|
+
|
|
124
|
+
const equalValue = (a, b) => (a === undefined || b === undefined ? a === b : canonicalJson(a) === canonicalJson(b));
|
|
125
|
+
const isU64 = (v) => Number.isSafeInteger(v) && v >= 0;
|
|
126
|
+
|
|
127
|
+
function covered(receipt, payload, key, fields, what) {
|
|
128
|
+
const evidence = payload[key];
|
|
129
|
+
if (evidence !== undefined) {
|
|
130
|
+
for (const name of fields) if (!equalValue(receipt[name], isRecord(evidence) ? evidence[name] : undefined)) refuse("evidence", `receipt ${name} differs from signed ${what}`);
|
|
131
|
+
} else {
|
|
132
|
+
const name = fields.find((f) => receipt[f] !== undefined);
|
|
133
|
+
if (name !== undefined) refuse("evidence", `receipt ${name} is not covered by signed ${what}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The coverage half of `verify_receipt`: signed snapshot evidence and nested children equal the receipt's copies; stage, result and BR19 evidence are never one-sided; the retired verdict fields are refused. */
|
|
138
|
+
export function verifyReceiptCoverage(receipt, payload) {
|
|
139
|
+
const snapshotEvidence = payload.snapshotEvidence;
|
|
140
|
+
if (snapshotEvidence !== undefined) {
|
|
141
|
+
for (const name of SNAPSHOT_EVIDENCE_NAMES) if (!isRecord(snapshotEvidence) || snapshotEvidence[name] === undefined || !equalValue(receipt[name], snapshotEvidence[name])) refuse("evidence", `receipt ${name} differs from signed snapshot evidence`);
|
|
142
|
+
if (!equalValue(receipt.nestedChildren, snapshotEvidence.nestedChildren)) refuse("evidence", "receipt nestedChildren differs from signed snapshot evidence");
|
|
143
|
+
}
|
|
144
|
+
covered(receipt, payload, "stageEvidence", STAGE_EVIDENCE_FIELDS, "stage evidence");
|
|
145
|
+
covered(receipt, payload, "resultEvidence", RESULT_EVIDENCE_FIELDS, "result evidence");
|
|
146
|
+
covered(receipt, payload, "br19Evidence", BR19_EVIDENCE_FIELDS, "BR19 evidence");
|
|
147
|
+
for (const name of RETIRED_RESULT_FIELDS) {
|
|
148
|
+
if (receipt[name] !== undefined || (isRecord(payload.resultEvidence) && payload.resultEvidence[name] !== undefined)) refuse("evidence", `receipt ${name} is a retired verdict-reuse field`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** `verify_receipt`: the signature half and the coverage half. Returns the signed payload. */
|
|
153
|
+
export function verifyReceiptLenient(receipt, pins, expected) {
|
|
154
|
+
const payload = verifyProvenance(receipt, pins, expected);
|
|
155
|
+
verifyReceiptCoverage(receipt, payload);
|
|
156
|
+
return payload;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** GB-10 `verify_result_evidence`: the signed payload carries `resultEvidence` with exactly exit, signal, timingsMs, diagnosticsPath (null is a value; a missing or extra key is not), timingsMs exactly the six phases, every receipt copy equal. */
|
|
160
|
+
export function verifyResultEvidence(receipt, payload) {
|
|
161
|
+
const evidence = payload.resultEvidence;
|
|
162
|
+
if (!isRecord(evidence)) refuse("evidence", "the signed payload carries no result evidence");
|
|
163
|
+
const extra = Object.keys(evidence).find((name) => !RESULT_EVIDENCE_FIELDS.includes(name));
|
|
164
|
+
if (extra !== undefined) refuse("evidence", `signed result evidence carries an unknown field ${extra}`);
|
|
165
|
+
for (const name of RESULT_EVIDENCE_FIELDS) {
|
|
166
|
+
if (evidence[name] === undefined) refuse("evidence", `signed result evidence lacks ${name}`);
|
|
167
|
+
if (!equalValue(receipt[name], evidence[name])) refuse("evidence", `receipt ${name} differs from signed result evidence`);
|
|
168
|
+
}
|
|
169
|
+
const timings = evidence.timingsMs;
|
|
170
|
+
if (!isRecord(timings)) refuse("evidence", "signed result evidence timingsMs is not an object");
|
|
171
|
+
if (Object.keys(timings).length !== RESULT_TIMING_FIELDS.length || RESULT_TIMING_FIELDS.some((name) => !(timings[name] === null || isU64(timings[name])))) refuse("evidence", "signed result evidence timingsMs must hold exactly the contract phases as numbers or null");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const isUnknownValue = (v) => isRecord(v) && Object.keys(v).length === 2 && v.state === "UNKNOWN" && typeof v.reason === "string" && v.reason !== "";
|
|
175
|
+
const nonEmptyString = (v) => typeof v === "string" && v !== "";
|
|
176
|
+
|
|
177
|
+
/** GB-11 `verify_br19_evidence`: signed `br19Evidence` has exactly profile, stage, resources, environment, equal to the receipt's copies, each well formed (a value or `{state:"UNKNOWN",reason}`; never absent, never an invented zero). */
|
|
178
|
+
export function verifyBr19Evidence(receipt, payload) {
|
|
179
|
+
const evidence = payload.br19Evidence;
|
|
180
|
+
if (!isRecord(evidence)) refuse("evidence", "the signed payload carries no BR19 evidence");
|
|
181
|
+
const extra = Object.keys(evidence).find((name) => !BR19_EVIDENCE_FIELDS.includes(name));
|
|
182
|
+
if (extra !== undefined) refuse("evidence", `signed BR19 evidence carries an unknown field ${extra}`);
|
|
183
|
+
for (const name of BR19_EVIDENCE_FIELDS) {
|
|
184
|
+
if (evidence[name] === undefined) refuse("evidence", `signed BR19 evidence lacks ${name}`);
|
|
185
|
+
if (!equalValue(receipt[name], evidence[name])) refuse("evidence", `receipt ${name} differs from signed BR19 evidence`);
|
|
186
|
+
}
|
|
187
|
+
const bad = (what) => refuse("evidence", `signed BR19 evidence ${what}`);
|
|
188
|
+
if (!nonEmptyString(evidence.profile)) bad("profile is not a non-empty string");
|
|
189
|
+
if (!isRecord(evidence.stage) || !nonEmptyString(evidence.stage.name) || !nonEmptyString(evidence.stage.source)) bad("stage needs a name and a source");
|
|
190
|
+
const resources = isRecord(evidence.resources) ? evidence.resources : {};
|
|
191
|
+
for (const name of BR19_RESOURCE_FIELDS) {
|
|
192
|
+
const value = resources[name];
|
|
193
|
+
if (value === undefined) bad(`resources lacks ${name}`);
|
|
194
|
+
const typed = name === "priority" ? nonEmptyString(value) : isU64(value);
|
|
195
|
+
if (!typed && !isUnknownValue(value)) bad(`resources.${name} is neither a value nor UNKNOWN with a reason`);
|
|
196
|
+
}
|
|
197
|
+
const environment = isRecord(evidence.environment) ? evidence.environment : {};
|
|
198
|
+
const names = (key) => Array.isArray(environment[key]) && environment[key].every(nonEmptyString);
|
|
199
|
+
if (!(typeof environment.digest === "string" && /^[0-9a-f]{64}$/.test(environment.digest) && names("forwarded") && names("dropped"))) bad("environment needs a 64-hex digest and forwarded and dropped name lists");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** `verify_receipt_strict`: `verifyReceiptLenient`, then the signed result evidence and the BR19 shape. A gate applies it to every compile receipt, never the lenient form alone. */
|
|
203
|
+
export function verifyReceiptStrict(receipt, pins, expected) {
|
|
204
|
+
const payload = verifyReceiptLenient(receipt, pins, expected);
|
|
205
|
+
verifyResultEvidence(receipt, payload);
|
|
206
|
+
verifyBr19Evidence(receipt, payload);
|
|
207
|
+
return payload;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---- RA-03: the cause evidence the broker signs inside `freshness` (rustright-broker cargo_evidence.rs, schema rustright.cause-evidence.v1) ----
|
|
211
|
+
// A census that counts rebuilt units proves nothing about WHY they were rebuilt. The broker therefore signs `freshness.causeEvidence`: a state (complete | incomplete |
|
|
212
|
+
// unavailable), a verdict (explained | unexplained | unknown), the basis the verdict rests on, the typed gaps and the counts. `verifyCauseEvidence` reads it and refuses what
|
|
213
|
+
// is missing, of a schema this gate does not read, or not coherent with itself and with the census fields beside it; `requireExplainedCauses` then refuses every verdict but
|
|
214
|
+
// `explained`. An `unknown` verdict is a typed refusal, never read as zero unexplained units, and a broker that signs no causeEvidence (one that predates the schema) is
|
|
215
|
+
// refused as well: nothing here relaxes for an older installed broker. One implementation serves every caller, so no second copy of the rule can drift.
|
|
216
|
+
export const CAUSE_EVIDENCE_SCHEMA = "rustright.cause-evidence.v1";
|
|
217
|
+
/** The refusal codes of a verdict that is not `explained` (the other cause-evidence refusals are `evidence`, like every malformed signed field). */
|
|
218
|
+
export const CAUSE_UNKNOWN = "cause-unknown";
|
|
219
|
+
export const CAUSE_UNEXPLAINED = "cause-unexplained";
|
|
220
|
+
/** cargo_evidence.rs EXPLAINED_CAUSES: the only causes that explain a rebuilt unit; any other (including `unexplained`, `fingerprint-missing` and one this list does not know) does not. */
|
|
221
|
+
export const EXPLAINED_CAUSES = Object.freeze([
|
|
222
|
+
"first-build", "toolchain-changed", "source-or-dependency-changed", "env-rustflags-profile-features-or-target-changed",
|
|
223
|
+
"generation-change", "authorized-eviction", "new-unit", "new-namespace",
|
|
224
|
+
]);
|
|
225
|
+
const CAUSE_STATES = Object.freeze(["complete", "incomplete", "unavailable"]);
|
|
226
|
+
const CAUSE_VERDICTS = Object.freeze(["explained", "unexplained", "unknown"]);
|
|
227
|
+
const CAUSE_BASES = Object.freeze(["reasons", "census-nothing-rebuilt", "nothing-compiled", "none"]);
|
|
228
|
+
const CAUSE_COUNT_FIELDS = Object.freeze(["rows", "rowsDropped", "units", "unexplained", "uncovered"]);
|
|
229
|
+
const shownValue = (v) => { let text; try { text = JSON.stringify(v); } catch { text = undefined; } return text === undefined ? "absent" : text.slice(0, 80); };
|
|
230
|
+
/** cargo_evidence.rs `counted`: a count in a freshness object is a whole number, or a flag standing for one (`true` is one); anything else is no count. */
|
|
231
|
+
const censusCount = (freshness, name) => { const v = freshness[name]; if (typeof v === "boolean") return v ? 1 : 0; return isU64(v) ? v : null; };
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Reads `freshness.causeEvidence` of a compile's signed (compact) freshness and returns `{ schema, state, verdict, basis, unavailable, gaps, counts, unexplainedUnits,
|
|
235
|
+
* unexplainedUnitsTruncated, unexplainedCount }`. Refuses (ProvenanceError `evidence`) when it is absent or null, not an object, of any schema but
|
|
236
|
+
* rustright.cause-evidence.v1, or when any field is missing or mistyped; when its state, verdict, basis, gaps and counts do not agree with one another
|
|
237
|
+
* (a verdict of `explained` needs no unexplained unit and no gap; `unknown` is the verdict of unexplained == 0 with a gap; the unit lists and their truncation counts add up
|
|
238
|
+
* to the counts; truncated reasons and uncovered units are named as gaps); when a basis that read no reasons carries counts, or claims a census the freshness fields
|
|
239
|
+
* do not show (`census-nothing-rebuilt` needs `available: true` and `notFresh: 0`; `nothing-compiled` needs `available: false` with that reason); when the sibling
|
|
240
|
+
* `unexplainedCount` is not the projection (`null` exactly when the verdict is unknown, else the unexplained count); and, for a basis of `reasons`, when `causeCounts`
|
|
241
|
+
* is missing or disagrees with the counts. It judges nothing: see `requireExplainedCauses`.
|
|
242
|
+
*/
|
|
243
|
+
export function verifyCauseEvidence(freshness) {
|
|
244
|
+
if (!isRecord(freshness)) refuse("evidence", "the receipt carries no freshness object to read cause evidence from");
|
|
245
|
+
const cause = freshness.causeEvidence;
|
|
246
|
+
if (cause === undefined || cause === null) refuse("evidence", `freshness carries no causeEvidence: the broker did not say why its units were rebuilt, so nothing is concluded from it (a broker that does not sign ${CAUSE_EVIDENCE_SCHEMA} is refused, never trusted as if it had)`);
|
|
247
|
+
const bad = (what) => refuse("evidence", `freshness causeEvidence ${what}`);
|
|
248
|
+
if (!isRecord(cause)) bad("is not an object");
|
|
249
|
+
if (cause.schema !== CAUSE_EVIDENCE_SCHEMA) bad(`names the schema ${shownValue(cause.schema)}, which this gate does not read (it reads ${CAUSE_EVIDENCE_SCHEMA} only)`);
|
|
250
|
+
const { state, verdict, basis, unavailable, gaps, counts } = cause;
|
|
251
|
+
if (!CAUSE_STATES.includes(state)) bad(`has the unknown state ${shownValue(state)}`);
|
|
252
|
+
if (!CAUSE_VERDICTS.includes(verdict)) bad(`has the unknown verdict ${shownValue(verdict)}`);
|
|
253
|
+
if (!CAUSE_BASES.includes(basis)) bad(`has the unknown basis ${shownValue(basis)}`);
|
|
254
|
+
if (typeof cause.historyMigrationNeeded !== "boolean") bad("does not say whether the history needs migration");
|
|
255
|
+
if (!(unavailable === null || nonEmptyString(unavailable))) bad("unavailable is neither null nor a reason");
|
|
256
|
+
if (!Array.isArray(gaps) || !gaps.every(nonEmptyString)) bad("gaps is not a list of reasons");
|
|
257
|
+
if (!isRecord(counts) || CAUSE_COUNT_FIELDS.some((name) => !isU64(counts[name]))) bad(`counts needs ${CAUSE_COUNT_FIELDS.join(", ")} as whole numbers`);
|
|
258
|
+
for (const [list, truncated, total] of [["unexplainedUnits", "unexplainedUnitsTruncated", counts.unexplained], ["uncoveredUnits", "uncoveredUnitsTruncated", counts.uncovered]]) {
|
|
259
|
+
if (!Array.isArray(cause[list]) || !cause[list].every((label) => typeof label === "string") || !isU64(cause[truncated])) bad(`${list} and ${truncated} are not a list of labels and a count`);
|
|
260
|
+
if (cause[list].length + cause[truncated] !== total) bad(`${list} (${cause[list].length}) and ${truncated} (${cause[truncated]}) do not add up to counts (${total})`);
|
|
261
|
+
}
|
|
262
|
+
const hasGap = (name) => gaps.includes(name);
|
|
263
|
+
const unexplained = counts.unexplained;
|
|
264
|
+
if (basis === "reasons") {
|
|
265
|
+
if (unavailable !== null) bad("names an unavailability reason although its reasons were read");
|
|
266
|
+
if (state !== (gaps.length === 0 ? "complete" : "incomplete")) bad(`state ${state} disagrees with its gaps (${gaps.length})`);
|
|
267
|
+
const expected = unexplained > 0 ? "unexplained" : gaps.length > 0 ? "unknown" : "explained";
|
|
268
|
+
if (verdict !== expected) bad(`verdict ${verdict} disagrees with its ${unexplained} unexplained unit(s) and ${gaps.length} gap(s) (${expected})`);
|
|
269
|
+
if (unexplained > counts.units || counts.units > counts.rows) bad("counts are not nested (unexplained <= units <= rows)");
|
|
270
|
+
if ((counts.rowsDropped > 0) !== hasGap("reasons-truncated")) bad("rowsDropped and the reasons-truncated gap disagree");
|
|
271
|
+
if ((counts.uncovered > 0) !== hasGap("rebuilt-unit-without-reason")) bad("uncovered and the rebuilt-unit-without-reason gap disagree");
|
|
272
|
+
} else {
|
|
273
|
+
if (state !== "unavailable") bad(`state ${state} is not unavailable although no reasons were read (basis ${basis})`);
|
|
274
|
+
if (!nonEmptyString(unavailable)) bad("states no reason why its reasons were not read");
|
|
275
|
+
if (CAUSE_COUNT_FIELDS.some((name) => counts[name] !== 0)) bad(`counts reasons that were never read (basis ${basis})`);
|
|
276
|
+
if (basis === "none") {
|
|
277
|
+
if (verdict !== "unknown" || !hasGap("log-not-read")) bad("basis none is the typed unknown: verdict unknown with the log-not-read gap");
|
|
278
|
+
} else {
|
|
279
|
+
if (verdict !== "explained" || gaps.length !== 0) bad(`basis ${basis} is an explained verdict with no gap`);
|
|
280
|
+
if (basis === "nothing-compiled" && !(freshness.available === false && freshness.reason === "nothing-compiled")) bad("claims nothing was compiled but the census does not say so");
|
|
281
|
+
if (basis === "census-nothing-rebuilt" && !(freshness.available === true && censusCount(freshness, "notFresh") === 0 && (censusCount(freshness, "parseErrors") ?? 0) === 0 && (censusCount(freshness, "notFreshPackagesTruncated") ?? 0) === 0)) bad("claims the census rebuilt nothing but the census does not prove it (available, notFresh 0, no parse errors, no truncated list)");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const projected = verdict === "unknown" ? null : unexplained;
|
|
285
|
+
if (freshness.unexplainedCount !== projected) refuse("evidence", `freshness unexplainedCount (${shownValue(freshness.unexplainedCount)}) is not the projection of causeEvidence (${projected === null ? "null, because the verdict is unknown" : projected})`);
|
|
286
|
+
if (basis === "reasons") {
|
|
287
|
+
const causes = freshness.causeCounts;
|
|
288
|
+
if (!isRecord(causes) || !Object.values(causes).every(isU64)) refuse("evidence", "freshness causeCounts is missing or not a count per cause although causeEvidence read reasons");
|
|
289
|
+
let total = 0; let unexplainedRows = 0;
|
|
290
|
+
for (const [name, n] of Object.entries(causes)) { total += n; if (!EXPLAINED_CAUSES.includes(name)) unexplainedRows += n; }
|
|
291
|
+
if (total !== counts.rows) refuse("evidence", `freshness causeCounts add up to ${total} reason(s), not causeEvidence counts.rows (${counts.rows})`);
|
|
292
|
+
if ((unexplainedRows > 0) !== (unexplained > 0) || unexplained > unexplainedRows) refuse("evidence", `freshness causeCounts name ${unexplainedRows} row(s) without an explained cause, which cannot give ${unexplained} unexplained unit(s)`);
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
schema: cause.schema, state, verdict, basis, unavailable, gaps: [...gaps], counts: { ...counts },
|
|
296
|
+
unexplainedUnits: [...cause.unexplainedUnits], unexplainedUnitsTruncated: cause.unexplainedUnitsTruncated, unexplainedCount: projected,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The gate's warm judgment over a compile's signed freshness: `verifyCauseEvidence`, then a refusal for every verdict but `explained`. `unknown` (a quiet, caller-logged
|
|
302
|
+
* or artifact-running root whose log was not read, dropped output, an unattributable or uncovered unit) is refused as `cause-unknown`; `unexplained` (a unit
|
|
303
|
+
* rebuilt with no explained cause) as `cause-unexplained`, naming the units the receipt kept. Returns the verified evidence only when the verdict is `explained`.
|
|
304
|
+
*/
|
|
305
|
+
export function requireExplainedCauses(freshness) {
|
|
306
|
+
const cause = verifyCauseEvidence(freshness);
|
|
307
|
+
if (cause.verdict === "unknown") {
|
|
308
|
+
const why = cause.unavailable ?? cause.gaps[0] ?? "unknown";
|
|
309
|
+
refuse(CAUSE_UNKNOWN, `the broker could not say why its rebuilt units were rebuilt (${why}${cause.gaps.length > 0 ? `; gaps: ${cause.gaps.join(", ")}` : ""}): cause evidence is mandatory for a compile, and an unknown cause is never read as no unexplained unit, so the call is unverified`);
|
|
310
|
+
}
|
|
311
|
+
if (cause.verdict === "unexplained") {
|
|
312
|
+
const named = cause.unexplainedUnits.slice(0, 5).map((label) => label.slice(0, 120)).join("; ");
|
|
313
|
+
refuse(CAUSE_UNEXPLAINED, `${cause.counts.unexplained} rebuilt unit(s) have no explained cause (${named}${cause.counts.unexplained > 5 || cause.unexplainedUnitsTruncated > 0 ? "; more not listed" : ""}): a rebuild the broker cannot explain is not a success`);
|
|
314
|
+
}
|
|
315
|
+
return cause;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---- GB-50: the full attribution a Clippy (or check) route needs, read from the signed detail descriptor, never from the compact counts ----
|
|
319
|
+
export const STAGE_EVIDENCE_SCHEMA = "rustright.stage-evidence/1";
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* `rustright_protocol::attestation::verify_attribution_detail(receipt, file_bytes)`, case for case (vector attribution-detail-vectors.v1.json). `receipt` is a
|
|
323
|
+
* receipt `verifyReceiptStrict` already accepted (so its compact `attribution`, `toolchain` and the descriptor's sha256 are signed) and `bytes` the evidence file the
|
|
324
|
+
* descriptor names. Checks in order: the descriptor is present and `bytes` hash to its sha256 (`detail`); the schema is rustright.stage-evidence/1 (`schema`); the
|
|
325
|
+
* file's `id` is this receipt's request (`job`) and its `toolchain` the receipt's (`evidence`); its attribution is available, `truncated` is exactly 0 in the file and in
|
|
326
|
+
* the compact twin (`truncated`); every row names package, target and kind with numeric errors and warnings; the compact `units`, `packages`, `errors`, `warnings`,
|
|
327
|
+
* `otherMessages` and `freshUnits` equal the values recomputed from the rows. Returns `{ rows, failedUnits }`. Counts alone never pass.
|
|
328
|
+
*/
|
|
329
|
+
export function verifyAttributionDetail(receipt, bytes) {
|
|
330
|
+
const compact = receipt?.attribution;
|
|
331
|
+
if (!isRecord(compact)) refuse("evidence", "the receipt carries no attribution");
|
|
332
|
+
const descriptor = compact.detail;
|
|
333
|
+
if (!isRecord(descriptor) || typeof descriptor.sha256 !== "string" || typeof descriptor.path !== "string") refuse("detail", "the attribution names no evidence file");
|
|
334
|
+
if (sha256Hex(bytes) !== descriptor.sha256) refuse("detail", "the evidence file is not the one the receipt signed");
|
|
335
|
+
let document;
|
|
336
|
+
try { document = JSON.parse(Buffer.from(bytes).toString("utf8")); } catch { refuse("malformed", "the evidence file is not JSON"); }
|
|
337
|
+
if (!isRecord(document) || document.schema !== STAGE_EVIDENCE_SCHEMA) refuse("schema", "unknown stage evidence schema");
|
|
338
|
+
if (typeof document.id !== "string" || document.id !== receipt.id) refuse("job", "the evidence file is for another request");
|
|
339
|
+
if (typeof document.toolchain !== "string" || document.toolchain !== receipt.toolchain) refuse("evidence", "the evidence file names another toolchain than the receipt");
|
|
340
|
+
const attribution = document.attribution;
|
|
341
|
+
if (!isRecord(attribution) || attribution.available !== true) refuse("evidence", "the evidence file holds no available attribution");
|
|
342
|
+
if (!isU64(attribution.truncated)) refuse("evidence", "the evidence file does not say whether the attribution is complete");
|
|
343
|
+
if (attribution.truncated !== 0 || compact.truncated !== 0) refuse("truncated", "the attribution is truncated or its completeness is not stated");
|
|
344
|
+
const rows = attribution.units;
|
|
345
|
+
if (!Array.isArray(rows)) refuse("evidence", "the evidence file holds no unit rows");
|
|
346
|
+
const packages = new Set(); let errors = 0; let warnings = 0; let other = 0; let fresh = 0; let failedUnits = 0;
|
|
347
|
+
for (const row of rows) {
|
|
348
|
+
const text = (name) => (isRecord(row) && typeof row[name] === "string" && row[name] !== "" ? row[name] : null);
|
|
349
|
+
const count = (name) => (isRecord(row) && isU64(row[name]) ? row[name] : null);
|
|
350
|
+
const [pkg, target, kind, e, w] = [text("package"), text("target"), text("kind"), count("errors"), count("warnings")];
|
|
351
|
+
if (pkg === null || target === null || kind === null || e === null || w === null) refuse("evidence", "a unit row lacks its package, target, kind, errors or warnings");
|
|
352
|
+
packages.add(pkg); errors += e; warnings += w; other += count("otherMessages") ?? 0; fresh += row.fresh === true ? 1 : 0; failedUnits += e > 0 ? 1 : 0;
|
|
353
|
+
}
|
|
354
|
+
const derived = { units: rows.length, packages: packages.size, errors, warnings, otherMessages: other, freshUnits: fresh };
|
|
355
|
+
for (const [name, value] of Object.entries(derived)) if (compact[name] !== value) refuse("evidence", `the receipt's compact attribution ${name} disagrees with the evidence file's rows`);
|
|
356
|
+
return { rows, failedUnits };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** The stage nonce a unit gate run sets (RIGHTKIT_ATTEST_STAGE_NONCE) for the broker jobs of one stage; the hosted admission recomputes it from
|
|
107
360
|
* what it knows of the dispatched commit, so a receipt of another commit, tree, unit or stage cannot match. 64 lowercase hex. */
|
|
108
361
|
export function qualificationNonce({ commit, tree, unit, stage }) {
|
|
109
362
|
return sha256Hex(`${QUALIFICATION_NONCE_DOMAIN}\n${commit}\n${tree}\n${unit}\n${stage}`);
|
|
110
363
|
}
|
|
111
364
|
|
|
365
|
+
/** The nonce of one carried cargo call of a stage (rightkit#1301 B8): derived from the stage nonce, the directory of the repository the call
|
|
366
|
+
* runs in (`.` or `crates/x`, posix, relative) and the argv it sends (the locked words, no `cargo`), so a call's receipt binds the stage,
|
|
367
|
+
* the directory and the command, and a stage nonce is never itself a carrier nonce. 64 lowercase hex. The gate derives it per call
|
|
368
|
+
* (snapshot-broker.mjs carryBrokerCargo) and the hosted admission recomputes it from each stage row. */
|
|
369
|
+
export function carriedCallNonce({ stageNonce, cwd, argv }) {
|
|
370
|
+
return sha256Hex(`${CARRIED_CALL_NONCE_DOMAIN}\n${stageNonce}\n${cwd}\n${canonicalJson([...argv])}`);
|
|
371
|
+
}
|
|
372
|
+
|
|
112
373
|
/** Broker request ids the client prints (`rustright: request <id>`) in one command's output. */
|
|
113
374
|
export function brokerRequestIds(text) {
|
|
114
375
|
return [...String(text ?? "").matchAll(/^rustright: request ([0-9a-f-]{36})/gm)].map((m) => m[1]);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The one definition of a SUCCESSFUL publication row (GB-45; GATE E9, same-bytes retry reconciliation), shared by its producer (scripts/ci/upload.mjs
|
|
2
|
+
// writes the rows) and its consumer (unit-gate.mjs reads them back). A leaf module: node builtins only.
|
|
3
|
+
//
|
|
4
|
+
// Two outcomes are success, and both mean the registry serves the sealed bytes: `published` (this attempt sent them and read them back) and
|
|
5
|
+
// `published-by-earlier-attempt` (an earlier attempt sent them; this attempt found the exact version AND hash on the registry before sending
|
|
6
|
+
// anything, and `publishedBy: "earlier-attempt"` says so). A generic "already exists" answer is neither: the producer only records the second after
|
|
7
|
+
// reconcileUnit proved the same bytes, and the consumer still reads the registry back itself against the row's hash.
|
|
8
|
+
export const PUBLISHED = "published";
|
|
9
|
+
export const PUBLISHED_BY_EARLIER_ATTEMPT = "published-by-earlier-attempt";
|
|
10
|
+
export const SUCCESSFUL_PUBLICATION_OUTCOMES = Object.freeze([PUBLISHED, PUBLISHED_BY_EARLIER_ATTEMPT]);
|
|
11
|
+
|
|
12
|
+
/** Why `row` is not a verified successful publication row, or null when it is. */
|
|
13
|
+
export function publicationRowProblem(row) {
|
|
14
|
+
if (!row || typeof row !== "object") return "no row";
|
|
15
|
+
if (row.published !== true) return "it is not recorded as published";
|
|
16
|
+
if (row.registryVerified !== true) return "it is not registry-verified";
|
|
17
|
+
if (!SUCCESSFUL_PUBLICATION_OUTCOMES.includes(row.outcome)) return `its outcome is ${JSON.stringify(row.outcome ?? null)}, not one of ${SUCCESSFUL_PUBLICATION_OUTCOMES.join(" or ")}`;
|
|
18
|
+
if (!/^[0-9a-f]{64}$/.test(row.sha256 ?? "")) return "it carries no sha256";
|
|
19
|
+
if (row.outcome === PUBLISHED_BY_EARLIER_ATTEMPT && row.publishedBy !== "earlier-attempt") return "its provenance is ambiguous (a reconciled prior publication must name publishedBy: earlier-attempt)";
|
|
20
|
+
return null;
|
|
21
|
+
}
|
package/publish-update.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { verifyPlatformTrust } from "./github-release.mjs";
|
|
|
17
17
|
import { admitInstalledQa } from "./release-qa-audit.mjs";
|
|
18
18
|
import { RIGHT_SUITE_API_ORIGIN, RIGHT_SUITE_GATED_ARTIFACT_HOST, RIGHT_SUITE_PUBLIC_INSTALLER_BASE } from "./suite-api.generated.mjs";
|
|
19
19
|
import { assertAssetName, assertGitHubRepository, assertUpdaterKeyId, assertUpdaterPlatform, assertUpdaterSignature, buildLatestJson, githubAssetUrl, githubTag, publishGitHubUpdaterRelease, resolveUpdaterTrust } from "./github-updater-release.mjs";
|
|
20
|
+
import { platformDirOf } from "./platform-dir.mjs";
|
|
20
21
|
|
|
21
22
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
22
23
|
const API_BASE = process.env.RIGHTAPPS_API_URL || RIGHT_SUITE_API_ORIGIN;
|
|
@@ -71,7 +72,7 @@ if (!dryRun || releaseId) {
|
|
|
71
72
|
assertPrimaryReleaseCheckout(repoRoot);
|
|
72
73
|
const commit = git(["rev-parse", "HEAD"]);
|
|
73
74
|
assertCleanSource({ status: git(["status", "--porcelain=v1", "-z", "--untracked-files=all"]), commandId: "right-release publish-update" });
|
|
74
|
-
sealed = verifyPublicationRelease({ sealedDir: path.join(repoRoot, ".right-release", "sealed", releaseId, platform
|
|
75
|
+
sealed = verifyPublicationRelease({ sealedDir: path.join(repoRoot, ".right-release", "sealed", releaseId, platformDirOf(platform)), repoRoot, appRoot: root, commit, app: config.app, version: versionForStorage, platform, configPath, releaseId });
|
|
75
76
|
const routes = sealed.manifest.routes?.[githubLane ? "update" : tier] ?? [];
|
|
76
77
|
const configured = githubLane
|
|
77
78
|
? artifacts.map((item) => ({ ...item, key: githubKey(item), role: "updater" }))
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `node qa-journeys-prebuilt-run.mjs <rightkit-qa> -- journeys run <args>`: one `rightkit-qa journeys run` step of a snapshot stage (GB-79, capability
|
|
3
|
+
// `journeys-prebuilt-input`) as the gate's build-once route (qa-journeys-prebuilt.mjs): carry the build, sign the products, hand QA the verified input.
|
|
4
|
+
// build-launch.mjs hands such a step to this entry instead of refusing it.
|
|
5
|
+
import { carriedCargoMapOf } from "./carried-cargo.mjs";
|
|
6
|
+
import { runJourneysPrebuilt } from "./qa-journeys-prebuilt.mjs";
|
|
7
|
+
|
|
8
|
+
const stop = (message, status = 101) => { process.stderr.write(`rightkit-gate: ${message}\n`); process.exit(status); };
|
|
9
|
+
const [qa, separator, ...args] = process.argv.slice(2);
|
|
10
|
+
if (!qa || separator !== "--") stop("RK_QA_JOURNEYS_USAGE: usage: qa-journeys-prebuilt-run.mjs <rightkit-qa> -- journeys run <args>");
|
|
11
|
+
const carried = carriedCargoMapOf(process.env);
|
|
12
|
+
if (!carried?.map) stop(`RK_QA_JOURNEYS_CARRIER_MISSING: ${carried?.error ?? "no carried map in the environment"}; a snapshot stage's build is never run around its carrier`);
|
|
13
|
+
try {
|
|
14
|
+
const { status } = runJourneysPrebuilt({ qa, args, map: carried.map });
|
|
15
|
+
process.exit(status);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
stop(String(error.message ?? error).startsWith("RK_") ? error.message : `${error.code ?? "RK_QA_JOURNEYS_FAILED"}: ${error.message}`, Number.isInteger(error.status) ? error.status : 101);
|
|
18
|
+
}
|