@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
package/snapshot-request.mjs
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// rustright-cli shims.rs `snapshot_carrier`, the verifier rustright-protocol attestation.rs `verify_receipt`).
|
|
3
3
|
//
|
|
4
4
|
// For each `rustright cargo` request that builds the commit snapshot on a broker host the gate:
|
|
5
|
-
// 1. picks a fresh nonce (
|
|
6
|
-
// 2. writes the carrier `
|
|
7
|
-
// directory the client accepts, never a link) and points
|
|
5
|
+
// 1. picks a fresh nonce (RUSTRIGHT_ATTEST_NONCE: an attested request; the carrier must carry the same nonce);
|
|
6
|
+
// 2. writes the carrier `rustright.snapshot-request/1` as an owner-only file under `<control root>/snapshot-requests/` (the
|
|
7
|
+
// directory the client accepts, never a link) and points RUSTRIGHT_SNAPSHOT_REQUEST at it as `{"path","sha256"}` (at most
|
|
8
8
|
// 4096 bytes; the file at most 4 MiB). The carrier binds the commit and tree, the snapshot and its expected tree, the
|
|
9
9
|
// declared inputs, the build-ownership handle, the cargo working directory (primaryCwd) and sha256 of the canonical cargo
|
|
10
10
|
// argv (without `cargo`), so it cannot be replayed onto another request;
|
|
@@ -13,16 +13,24 @@
|
|
|
13
13
|
// snapshot, declaredInputs and ownership), and the signed snapshotEvidence: the receipt's copies equal the signed ones,
|
|
14
14
|
// the commit and handle are the carrier's, and the snapshot held the expected tree at START and at exit.
|
|
15
15
|
// The carrier is set only on that one cargo invocation and is removed after it; nothing the gate exports carries it.
|
|
16
|
+
// A pnpm, node or QA step of a snapshot stage is carried the same way as an ADAPTER request (`rustright run <adapter> -- <args>`,
|
|
17
|
+
// rustright#190: snapshot-adapter-v1): the same carrier, the adapter's name in the request core, and a signed `nestedChildren` list (in snapshotEvidence, mirrored on the receipt) of the
|
|
18
|
+
// compiles that job's own children made (the Tauri CLI's Cargo calls), which the gate verifies as it does the call (rightkit#1301).
|
|
16
19
|
|
|
17
20
|
import crypto from "node:crypto";
|
|
18
21
|
import fs from "node:fs";
|
|
19
22
|
import path from "node:path";
|
|
20
|
-
import {
|
|
23
|
+
import { REQUEST_OVER_CAP_CODE, requestOverCap } from "./broker-wire.mjs";
|
|
24
|
+
import { ownerFileOf, writeCarrierOwner } from "./carrier-census.mjs";
|
|
25
|
+
import { BROKER_COMPILE_COMMANDS, CARGO_COMMAND_ALIASES, cargoCommandIndex, cargoSubcommandOf, expandCargoAliases, isCompileCommand, isPackageObservation } from "./cargo-shapes.mjs";
|
|
26
|
+
import { canonicalJson, CAUSE_EVIDENCE_SCHEMA, CAUSE_UNEXPLAINED, CAUSE_UNKNOWN, ProvenanceError, requireExplainedCauses, verifyBr19Evidence, verifyCauseEvidence, verifyProvenance, verifyReceiptCoverage, verifyResultEvidence } from "./provenance-verify.mjs";
|
|
21
27
|
|
|
22
|
-
export const SNAPSHOT_REQUEST_SCHEMA = "
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
export const
|
|
28
|
+
export const SNAPSHOT_REQUEST_SCHEMA = "rustright.snapshot-request/1";
|
|
29
|
+
// One spelling per variable, the current one (rustright-cli shims.rs reads RUSTRIGHT_* first, then the legacy RIGHTKIT_* spelling, which task
|
|
30
|
+
// L-1 removes): the gate never sets both.
|
|
31
|
+
export const SNAPSHOT_REQUEST_ENV = "RUSTRIGHT_SNAPSHOT_REQUEST";
|
|
32
|
+
export const ATTEST_NONCE_ENV = "RUSTRIGHT_ATTEST_NONCE";
|
|
33
|
+
export const REQUEST_ID_FILE_ENV = "RUSTRIGHT_REQUEST_ID_FILE";
|
|
26
34
|
export const REQUEST_REF_SCHEMA = "rustright.request-ref.v1";
|
|
27
35
|
export const DESCRIPTOR_MAX_BYTES = 4096;
|
|
28
36
|
export const CARRIER_MAX_BYTES = 4 * 1024 * 1024;
|
|
@@ -37,32 +45,124 @@ export const MANAGED_RUST_ENVIRONMENT_KEYS = Object.freeze([
|
|
|
37
45
|
export const SNAPSHOT_EVIDENCE_FIELDS = Object.freeze(["requestId", "brokerVersion", "executor", "commit", "snapshot", "declaredInputDigests", "ownership", "outputDir"]);
|
|
38
46
|
/** The stage evidence the broker signs in payload.stageEvidence and copies onto the receipt (attestation.rs STAGE_EVIDENCE_FIELDS). */
|
|
39
47
|
export const STAGE_EVIDENCE_FIELDS = Object.freeze(["freshness", "attribution", "toolchain"]);
|
|
40
|
-
|
|
48
|
+
/** The result fields the broker signs in payload.resultEvidence and mirrors on the receipt (attestation.rs RESULT_EVIDENCE_FIELDS). */
|
|
49
|
+
export const RESULT_EVIDENCE_FIELDS = Object.freeze(["exit", "signal", "timingsMs", "diagnosticsPath"]);
|
|
41
50
|
const TIMING_KEYS =Object.freeze(["admission", "queue", "bind", "cargo", "afterExitBind", "diagnostics"]);
|
|
51
|
+
/** The broker's code for a compile whose toolchain identity cannot be built (checked_toolchain_identity), and the gate's typed error code for it. */
|
|
52
|
+
export const TOOLCHAIN_UNAVAILABLE_CODE = "RK_TOOLCHAIN_IDENTITY_UNAVAILABLE";
|
|
53
|
+
export const TOOLCHAIN_UNAVAILABLE = "toolchain-unavailable";
|
|
54
|
+
/** `{"state":"UNKNOWN","reason":<non-empty string>}` exactly: the one spelling of an identity the broker could not establish (BR19). */
|
|
55
|
+
export const isToolchainUnknown = (v) => v !== null && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 2 && v.state === "UNKNOWN" && typeof v.reason === "string" && v.reason !== "";
|
|
42
56
|
/** `<toolchain>|cargo:<sha256>|rustc:<sha256>` (rustright toolchain_identity). */
|
|
43
57
|
const TOOLCHAIN_FORMAT = /^[^|\s][^|]*\|cargo:[0-9a-f]{64}\|rustc:[0-9a-f]{64}$/;
|
|
44
58
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
export
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
59
|
+
// GB-01/GB-02: the command-shape table is cargo-shapes.mjs (one versioned table, exercised against RustRight's cargo-shapes vector); these names
|
|
60
|
+
// are its views for the carried call. `isCompileClass` decides which evidence a receipt must carry from the broker's own compile rule (the `compile`
|
|
61
|
+
// column of the vector), so bench, run, rustdoc and a compiling `package` owe it too and a read-only metadata or `package --list` legitimately carries none.
|
|
62
|
+
export { BROKER_COMPILE_COMMANDS, CARGO_COMMAND_ALIASES, cargoSubcommandOf, isPackageObservation };
|
|
63
|
+
/** Whether the broker classifies this argv (without the tool name) as a compile. `package` is one unless it is the observation listing. */
|
|
64
|
+
export const isCompileClass = (argv) => isCompileCommand(argv);
|
|
65
|
+
/** The commands that build or run, and so can rewrite Cargo.lock inside a snapshot unless they are locked (rightkit#1301): the same table as `isCompileClass`. */
|
|
66
|
+
export const isLockBound = (argv) => isCompileClass(argv);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The argv a carried call sends: a build or run command with neither `--locked` nor `--frozen` (before any `--`) gets `--locked` right
|
|
70
|
+
* after its subcommand. RustRight refuses an unlocked grant-minted build, and an unlocked build could rewrite Cargo.lock in the snapshot.
|
|
71
|
+
* Adding it, not refusing, is the less surprising rule: the call runs as the caller meant it, except that a stale lock fails instead of
|
|
72
|
+
* being rewritten. The ledger and the signed request digest both carry the argv as sent.
|
|
73
|
+
*/
|
|
74
|
+
/** cargo-ndk's own options that take a value (outer grammar): `cargo ndk [-t <abi>] [-p|--platform <api>] [-o <dir>] ... <cargo subcommand> [cargo args]`. */
|
|
75
|
+
const NDK_VALUE_OPTIONS = new Set(["-t", "--target", "-o", "--output-dir", "-p", "--platform"]);
|
|
76
|
+
const NDK_INNER_LOCKED = new Set(["build", "b", "check", "c", "test", "t", "bench", "clippy", "doc", "rustc", "rustdoc", "run", "r"]);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* GB-62: `cargo ndk <outer options> build <cargo args>` is two grammars. The immutable-dependency flag belongs to the INNER Cargo command, so it is
|
|
80
|
+
* inserted right after that inner subcommand (never after `ndk`, where cargo-ndk would read it as its own option, and never before it). Returns the argv
|
|
81
|
+
* unchanged when the inner command is not a locked-class command, already carries `--locked`/`--frozen` before `--`, or cannot be found.
|
|
82
|
+
*/
|
|
83
|
+
function lockedNdkArgv(words) {
|
|
84
|
+
const end = words.indexOf("--");
|
|
85
|
+
const own = end < 0 ? words : words.slice(0, end);
|
|
86
|
+
let i = words.findIndex((w) => w === "ndk");
|
|
87
|
+
if (i < 0) return words;
|
|
88
|
+
for (i += 1; i < own.length; i += 1) {
|
|
89
|
+
const w = own[i];
|
|
90
|
+
if (NDK_VALUE_OPTIONS.has(w)) { i += 1; continue; }
|
|
56
91
|
if (w.startsWith("-")) continue;
|
|
57
|
-
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
if (i >= own.length || !NDK_INNER_LOCKED.has(own[i])) return words;
|
|
95
|
+
if (own.slice(i + 1).some((w) => w === "--locked" || w === "--frozen")) return words;
|
|
96
|
+
return [...words.slice(0, i + 1), "--locked", ...words.slice(i + 1)];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function lockedArgv(argv) {
|
|
100
|
+
// GB-01: the standard aliases are expanded BEFORE locking and hashing, so what the gate sends, signs and digests is what the broker classifies
|
|
101
|
+
// (a committed `.cargo/config.toml` [alias] can never redefine it). Only the subcommand token changes.
|
|
102
|
+
const words = expandCargoAliases(argv);
|
|
103
|
+
if (cargoSubcommandOf(words) === "ndk") return lockedNdkArgv(words);
|
|
104
|
+
if (!isLockBound(words)) return words;
|
|
105
|
+
const end = words.indexOf("--");
|
|
106
|
+
if ((end < 0 ? words : words.slice(0, end)).some((w) => w === "--locked" || w === "--frozen")) return words;
|
|
107
|
+
const i = cargoCommandIndex(words);
|
|
108
|
+
if (i === null) return words;
|
|
109
|
+
return [...words.slice(0, i + 1), "--locked", ...words.slice(i + 1)];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const CARRIED_REDIRECT = /^(--config|-Z|-C)(=|$)|^-[A-Za-z]*[CZ]/;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The one place that turns a caller's cargo arguments (without `cargo`) into the arguments a carried call sends (carried-cargo.mjs
|
|
116
|
+
* `runCarriedCargo`; stage-commands.mjs derives what a stage's signed receipts must show from the same function, so the two cannot drift):
|
|
117
|
+
* `--manifest-path <p>` or `--manifest-path=<p>` before any `--` is removed (the client refuses to rewrite an attested call; the call
|
|
118
|
+
* runs from the manifest's directory instead, the last one wins) and returned as `manifestPath`; `--target-dir` and the redirecting
|
|
119
|
+
* options are refused. Returns `{ words, manifestPath }` (`manifestPath` null when none) or `{ refusal: { code, message } }`.
|
|
120
|
+
* `words` are not yet locked: the signed request carries `lockedArgv(words)` (carryBrokerCargo).
|
|
121
|
+
*/
|
|
122
|
+
export function splitCarriedArgs(args) {
|
|
123
|
+
const original = [...args];
|
|
124
|
+
const words = [];
|
|
125
|
+
let manifestPath = null;
|
|
126
|
+
let atSeparator = false;
|
|
127
|
+
for (let i = 0; i < original.length; i += 1) {
|
|
128
|
+
const w = original[i];
|
|
129
|
+
if (atSeparator) { words.push(w); continue; }
|
|
130
|
+
if (w === "--") { atSeparator = true; words.push(w); continue; }
|
|
131
|
+
if (w === "--manifest-path" || w.startsWith("--manifest-path=")) {
|
|
132
|
+
const value = w === "--manifest-path" ? original[i + 1] : w.slice("--manifest-path=".length);
|
|
133
|
+
if (w === "--manifest-path") i += 1;
|
|
134
|
+
if (typeof value !== "string" || !value) return { refusal: { code: "RK_CARGO_BROKER_MANIFEST_PATH", message: "--manifest-path names no manifest" } };
|
|
135
|
+
manifestPath = value;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (w === "--target-dir" || w.startsWith("--target-dir=")) return { refusal: { code: "RK_CARGO_REDIRECT", message: `\`cargo ${original.join(" ")}\` passes --target-dir; the broker pins a carried call's output directory` } };
|
|
139
|
+
if (CARRIED_REDIRECT.test(w)) return { refusal: { code: "RK_CARGO_REDIRECT", message: `\`cargo ${original.join(" ")}\` passes ${w}, which moves its working directory, configuration or output` } };
|
|
140
|
+
words.push(w);
|
|
58
141
|
}
|
|
59
|
-
return
|
|
142
|
+
return { words, manifestPath };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* What a carried call sends the broker for `args` run from `cwd` (a posix path relative to the workspace root, "." for the root): the
|
|
147
|
+
* argv as signed (`requestedArgv`: no --manifest-path, `--locked` added to a build or run) and the directory it runs in (`cwd` joined
|
|
148
|
+
* with the manifest's directory). Returns `{ argv, cwd }` or null when the call would be refused.
|
|
149
|
+
*/
|
|
150
|
+
export function carriedRequestOf(args, cwd = ".") {
|
|
151
|
+
const split = splitCarriedArgs(args);
|
|
152
|
+
if (split.refusal) return null;
|
|
153
|
+
const dir = split.manifestPath === null ? cwd : path.posix.normalize(path.posix.join(cwd, path.posix.dirname(split.manifestPath.replaceAll("\\", "/"))));
|
|
154
|
+
return { argv: lockedArgv(split.words), cwd: dir === "" ? "." : dir };
|
|
60
155
|
}
|
|
61
|
-
export const isCompileClass = (argv) => COMPILE_CLASS_COMMANDS.includes(cargoSubcommandOf(argv));
|
|
62
156
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Where the declared compile inputs are listed, as read from the snapshot (the broker's COMPILE_INPUTS; GB-49): `.rustright/compile-inputs.json` first,
|
|
159
|
+
* `.rightkit/compile-inputs.json` ONLY when the canonical file is absent. A present but invalid canonical declaration is an error, never a fallback.
|
|
160
|
+
*/
|
|
161
|
+
export const COMPILE_INPUTS_CANONICAL = ".rustright/compile-inputs.json";
|
|
162
|
+
export const COMPILE_INPUTS_LEGACY = ".rightkit/compile-inputs.json";
|
|
163
|
+
export const COMPILE_INPUTS = COMPILE_INPUTS_CANONICAL;
|
|
164
|
+
export const COMPILE_INPUT_DECLARATIONS = Object.freeze([COMPILE_INPUTS_CANONICAL, COMPILE_INPUTS_LEGACY]);
|
|
165
|
+
export const MAX_DECLARATION_BYTES = 4 * 1024 * 1024;
|
|
66
166
|
const MAX_INPUT_FILES = 200_000;
|
|
67
167
|
const MAX_INPUT_BYTES = 4 * 1024 * 1024 * 1024;
|
|
68
168
|
|
|
@@ -98,14 +198,26 @@ export function observedEnvOf(env, platform = process.platform) {
|
|
|
98
198
|
return out;
|
|
99
199
|
}
|
|
100
200
|
|
|
201
|
+
/**
|
|
202
|
+
* The next per-call lease handle of a build ownership: `<ownership-handle>#<uuid>`. A workspace index or a per-process counter is not unique
|
|
203
|
+
* (metadata, check and test reuse a workspace; wrapper children are separate processes), so each invocation draws its own random UUID. The broker accepts one
|
|
204
|
+
* completion per handle on a snapshot, and its status for the bare ownership handle covers every `#` sub-handle.
|
|
205
|
+
*/
|
|
206
|
+
export const nextCallHandle = (handle) => `${handle}#${crypto.randomUUID()}`;
|
|
207
|
+
/** Whether `callHandle` is exactly one sub-handle of the ownership `handle` (`<handle>#<token>`, never the bare handle or another's). */
|
|
208
|
+
export const isCallHandleOf = (callHandle, handle) => typeof callHandle === "string" && typeof handle === "string" && handle !== "" && callHandle.length > handle.length + 1 && callHandle.startsWith(`${handle}#`) && /^[A-Za-z0-9._-]+$/.test(callHandle.slice(handle.length + 1));
|
|
209
|
+
/** The ownership handle a signed (sub-)handle belongs to: everything before the first `#`. */
|
|
210
|
+
export const ownershipOfHandle = (handle) => (typeof handle === "string" ? handle.split("#")[0] : handle);
|
|
211
|
+
|
|
101
212
|
/**
|
|
102
213
|
* Writes the carrier for one cargo request and returns `{ file, sha256, descriptor, carrier, env }`; `env` is what that one
|
|
103
214
|
* invocation adds (carrier descriptor, nonce, request-id file). Refuses before writing anything the reader would refuse.
|
|
104
215
|
*/
|
|
105
|
-
export function writeSnapshotRequest({ controlRoot, commit, snapshot, declaredInputs = [], handle, primaryCwd, argv, nonce = freshNonce(), requestIdFile, uuid = crypto.randomUUID }) {
|
|
216
|
+
export function writeSnapshotRequest({ controlRoot, commit, snapshot, declaredInputs = [], handle, callHandle, primaryCwd, argv, nonce = freshNonce(), requestIdFile, uuid = crypto.randomUUID }) {
|
|
106
217
|
if (!objectId(commit?.sha) || !objectId(commit?.tree)) fail("invalid", "commit sha and tree must be full lowercase object ids");
|
|
107
218
|
if (!snapshot?.path || !path.isAbsolute(snapshot.path) || snapshot.expectedTree !== commit.tree) fail("invalid", "the snapshot needs an absolute path and the commit's tree as expectedTree");
|
|
108
219
|
if (typeof handle !== "string" || !handle.trim()) fail("invalid", "the build-ownership handle is missing");
|
|
220
|
+
if (callHandle !== undefined && !isCallHandleOf(callHandle, handle)) fail("invalid", `the call handle ${JSON.stringify(callHandle)} is not a sub-handle (<ownership-handle>#<n>) of the build ownership`);
|
|
109
221
|
if (!primaryCwd || !path.isAbsolute(primaryCwd)) fail("invalid", "primaryCwd must be the cargo request's absolute working directory");
|
|
110
222
|
if (!Array.isArray(argv) || !argv.every((a) => typeof a === "string")) fail("invalid", "argv must be the cargo arguments (without cargo)");
|
|
111
223
|
if (typeof nonce !== "string" || !/^[0-9a-f]{64,128}$/.test(nonce)) fail("invalid", "the nonce must be 64 to 128 lowercase hex characters");
|
|
@@ -120,32 +232,48 @@ export function writeSnapshotRequest({ controlRoot, commit, snapshot, declaredIn
|
|
|
120
232
|
commit: { sha: commit.sha, tree: commit.tree },
|
|
121
233
|
snapshot: { path: snapshot.path, expectedTree: snapshot.expectedTree },
|
|
122
234
|
declaredInputs: { inputs: [...declaredInputs] },
|
|
123
|
-
|
|
235
|
+
// One completion per (snapshot, lease handle) at the broker (rustright snapshot.rs:330-343): each carried request names its own
|
|
236
|
+
// sub-handle `<ownership-handle>#<n>`, which the broker's bare-handle lease status aggregates (snapshot.rs:469).
|
|
237
|
+
ownership: { handle: callHandle ?? nextCallHandle(handle) },
|
|
124
238
|
primaryCwd,
|
|
125
239
|
argvSha256: argvSha256(argv),
|
|
126
240
|
nonce,
|
|
127
241
|
};
|
|
128
242
|
const bytes = Buffer.from(JSON.stringify(carrier), "utf8");
|
|
129
243
|
if (bytes.length > CARRIER_MAX_BYTES) fail("too-large", `the snapshot request is ${bytes.length} bytes, more than ${CARRIER_MAX_BYTES}`);
|
|
244
|
+
// GB-53: the request line is 1 MiB, newline included, one unit with the response limit. The declared inputs ride in it with the envelope: a request that cannot fit is
|
|
245
|
+
// refused here, before the carrier is written, before it counts as sent, so no job exists.
|
|
246
|
+
const overCap = requestOverCap(Buffer.byteLength(JSON.stringify(carrier.declaredInputs)) + Buffer.byteLength(JSON.stringify({ commit: carrier.commit, snapshot: carrier.snapshot, ownership: carrier.ownership, primaryCwd, argv })));
|
|
247
|
+
if (overCap) fail(REQUEST_OVER_CAP_CODE, overCap);
|
|
130
248
|
const file = path.join(dir, `${uuid()}.json`);
|
|
131
|
-
fs.writeFileSync(file, bytes, { flag: "wx", mode: 0o600 });
|
|
132
249
|
const digest = sha256(bytes);
|
|
133
250
|
const descriptor = JSON.stringify({ path: file, sha256: digest });
|
|
134
|
-
if (Buffer.byteLength(descriptor) > DESCRIPTOR_MAX_BYTES)
|
|
251
|
+
if (Buffer.byteLength(descriptor) > DESCRIPTOR_MAX_BYTES) fail("too-large", "the snapshot request descriptor exceeds 4096 bytes");
|
|
252
|
+
// GB-76 (BROKER-CONTRACT section 16): the creator is recorded FIRST, in `<carrier>.owner.json` (the gate's identity as RustRight reads it, the call's nonce and sub-handle, the creation time), then
|
|
253
|
+
// the carrier; both are removed in the call's `finally`. A broker sweep retires a leftover only on proof that the gate is gone and nothing is held under the handle, never on age alone. A
|
|
254
|
+
// record that cannot be written leaves no carrier.
|
|
255
|
+
try { writeCarrierOwner({ file, handle: carrier.ownership.handle, nonce }); } catch (e) { if (e.code !== "EEXIST") fs.rmSync(ownerFileOf(file), { force: true }); fail("invalid", `the carrier's owner record cannot be written (${e.code ?? e.message}), so no carrier is left behind`); }
|
|
256
|
+
try { fs.writeFileSync(file, bytes, { flag: "wx", mode: 0o600 }); } catch (e) { fs.rmSync(ownerFileOf(file), { force: true }); throw e; }
|
|
135
257
|
const env = { [SNAPSHOT_REQUEST_ENV]: descriptor, [ATTEST_NONCE_ENV]: nonce, ...(requestIdFile ? { [REQUEST_ID_FILE_ENV]: requestIdFile } : {}) };
|
|
136
258
|
return { file, sha256: digest, descriptor, carrier, env };
|
|
137
259
|
}
|
|
138
260
|
|
|
139
|
-
/**
|
|
140
|
-
|
|
261
|
+
/**
|
|
262
|
+
* The request core the broker digests for a snapshot request: the base core plus commit, snapshot, declaredInputs and ownership. A cargo
|
|
263
|
+
* request has `shim: "cargo"` and no adapter; a carried adapter request (rustright#190 D2) has `shim: "adapter"`, the registered adapter's
|
|
264
|
+
* name, no observed environment (the client captures none for an adapter), and `env`: the request's own environment object, exactly the
|
|
265
|
+
* variables RUSTRIGHT_ADAPTER_ENV names (rustright-protocol attestation.rs request_core `environment`; adapter-env.mjs), always empty for Cargo.
|
|
266
|
+
* The adapter spelling is RustRight's; see snapshot-broker.mjs carryBrokerAdapter.
|
|
267
|
+
*/
|
|
268
|
+
export function snapshotRequestCore({ argv, workspace, observedEnv = {}, carrier, adapter = null, adapterEnv = {} }) {
|
|
141
269
|
return {
|
|
142
|
-
adapter: null
|
|
270
|
+
adapter, env: adapter === null ? {} : { ...adapterEnv }, inputManifest: null, nonce: carrier.nonce, observedEnv: adapter === null ? observedEnv : {}, outputs: [], requestedArgv: [...argv], shim: adapter === null ? "cargo" : "adapter", workspace,
|
|
143
271
|
commit: carrier.commit, snapshot: carrier.snapshot, declaredInputs: carrier.declaredInputs, ownership: carrier.ownership,
|
|
144
272
|
};
|
|
145
273
|
}
|
|
146
274
|
export const requestDigestOf = (core) => sha256(canonicalJson(core));
|
|
147
275
|
|
|
148
|
-
/** The request reference the client writes at submit (
|
|
276
|
+
/** The request reference the client writes at submit (RUSTRIGHT_REQUEST_ID_FILE), or a throw. */
|
|
149
277
|
export function readRequestRef(file) {
|
|
150
278
|
let ref;
|
|
151
279
|
try { ref = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { fail("request", `no request reference at ${file} (${e.message})`); }
|
|
@@ -160,49 +288,69 @@ const same = (a, b) => canonicalJson(a ?? null) === canonicalJson(b ?? null);
|
|
|
160
288
|
const lstatOrNull = (p) => { try { return fs.lstatSync(p); } catch (e) { if (e.code === "ENOENT") return null; throw e; } };
|
|
161
289
|
|
|
162
290
|
/**
|
|
163
|
-
* The declared inputs of a snapshot
|
|
164
|
-
*
|
|
165
|
-
* is
|
|
291
|
+
* The declared inputs of a snapshot, read FROM THE SNAPSHOT, never the checkout, by the one rule every RightKit reader shares (the broker's, vector
|
|
292
|
+
* docs/contracts/declared-inputs.fixtures.json): `.rustright/compile-inputs.json` when it exists, else `.rightkit/compile-inputs.json`, else none. Either file
|
|
293
|
+
* is judged by the same checks: no link on its path, a regular file of at most 4 MiB, JSON with `inputs` a list of plain relative paths (not absolute, no `.`,
|
|
294
|
+
* `..` or empty component, not inside `.git`) and no input that is, or holds, either declaration file. A present but invalid canonical declaration is an
|
|
295
|
+
* error: it is never read as the legacy one.
|
|
166
296
|
*/
|
|
167
297
|
export function readDeclaredInputs(snapshotRoot) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
298
|
+
let file = null; let at = null; let meta = null;
|
|
299
|
+
for (const candidate of COMPILE_INPUT_DECLARATIONS) {
|
|
300
|
+
let here = snapshotRoot; let found = true;
|
|
301
|
+
for (const part of candidate.split("/")) {
|
|
302
|
+
here = path.join(here, part);
|
|
303
|
+
const m = lstatOrNull(here);
|
|
304
|
+
if (m === null) { found = false; break; }
|
|
305
|
+
if (m.isSymbolicLink()) fail("declared-inputs", `${candidate} runs through a link at ${here}`);
|
|
306
|
+
meta = m;
|
|
307
|
+
}
|
|
308
|
+
if (found) { file = candidate; at = here; break; }
|
|
175
309
|
}
|
|
176
|
-
if (
|
|
177
|
-
if (meta.
|
|
310
|
+
if (file === null) return [];
|
|
311
|
+
if (!meta.isFile()) fail("declared-inputs", `${file} is not a regular file`);
|
|
312
|
+
if (meta.size > MAX_DECLARATION_BYTES) fail("declared-inputs", `${file} is larger than ${MAX_DECLARATION_BYTES} bytes`);
|
|
178
313
|
let value;
|
|
179
|
-
try { value = JSON.parse(fs.readFileSync(at, "utf8")); } catch (e) { fail("declared-inputs", `${
|
|
180
|
-
if (!value || typeof value !== "object" || !Array.isArray(value.inputs)) fail("declared-inputs", `${
|
|
314
|
+
try { value = JSON.parse(fs.readFileSync(at, "utf8")); } catch (e) { fail("declared-inputs", `${file} is not JSON (${e.message})`); }
|
|
315
|
+
if (!value || typeof value !== "object" || !Array.isArray(value.inputs)) fail("declared-inputs", `${file}: \`inputs\` must be a list of paths`);
|
|
181
316
|
for (const input of value.inputs) {
|
|
182
|
-
if (typeof input !== "string" || !input) fail("declared-inputs", `${
|
|
317
|
+
if (typeof input !== "string" || !input) fail("declared-inputs", `${file}: every input is a non-empty relative path`);
|
|
183
318
|
const components = input.split(/[\\/]/);
|
|
184
|
-
if (path.isAbsolute(input) || path.posix.isAbsolute(input) || path.win32.isAbsolute(input) || components.some((c) => c === "" || c === "." || c === "..")) fail("declared-inputs", `${
|
|
185
|
-
if (components.some((c) => c.toLowerCase() === ".git")) fail("declared-inputs", `${
|
|
186
|
-
|
|
319
|
+
if (path.isAbsolute(input) || path.posix.isAbsolute(input) || path.win32.isAbsolute(input) || components.some((c) => c === "" || c === "." || c === "..")) fail("declared-inputs", `${file}: ${input} is not a plain relative path inside the snapshot`);
|
|
320
|
+
if (components.some((c) => c.toLowerCase() === ".git")) fail("declared-inputs", `${file}: ${input} is inside .git; repository metadata is never a compile input`);
|
|
321
|
+
for (const declaration of COMPILE_INPUT_DECLARATIONS) {
|
|
322
|
+
const parts = declaration.split("/");
|
|
323
|
+
if (components.length <= parts.length && components.every((c, i) => c === parts[i])) fail("declared-inputs", `${file}: ${input} overlaps the declaration file itself (${declaration})`);
|
|
324
|
+
}
|
|
187
325
|
}
|
|
188
326
|
return [...value.inputs];
|
|
189
327
|
}
|
|
190
328
|
|
|
329
|
+
/** The inputs a request supplied must be exactly the declaration the snapshot holds (the broker's check): a throw naming the difference. */
|
|
330
|
+
export function assertSuppliedInputs(snapshotRoot, supplied) {
|
|
331
|
+
const declared = readDeclaredInputs(snapshotRoot);
|
|
332
|
+
if (!same(declared, supplied)) fail("declared-inputs", "the snapshot's declared inputs are not the ones this request carried (declaredInputs differs from the snapshot declaration)");
|
|
333
|
+
return declared;
|
|
334
|
+
}
|
|
335
|
+
|
|
191
336
|
/**
|
|
192
337
|
* The `declaredInputDigests` the broker records for these inputs, computed here from the snapshot: per input, sha256 over, for each
|
|
193
338
|
* regular file in directory order (names sorted by their bytes), the file's relative name, a NUL, its bytes and a NUL. A link
|
|
194
|
-
* anywhere, a special file, or a limit breach is a refusal.
|
|
195
|
-
*
|
|
339
|
+
* anywhere, a special file, or a limit breach is a refusal. The name is the input's path components joined by `/` on EVERY platform (GB-49: RustRight hashes
|
|
340
|
+
* `/` on Windows too, so a `\` in a declaration or a platform separator never reaches the hash), UTF-8, no Unicode normalisation.
|
|
341
|
+
*
|
|
342
|
+
* `allowAbsent` is set ONLY for a non-compiling snapshot-bound request (the carried read-only set): a declared input that does not exist in the
|
|
343
|
+
* snapshot (ENOENT/ENOTDIR on a component; a link is still refused) is then the entry `{ path, absent: true }` (no `sha256`, 0 files, 0 bytes), the
|
|
344
|
+
* wire form RustRight signs. A compile request never allows it: a missing declared input is a refusal there (the build outputs must exist).
|
|
196
345
|
*/
|
|
197
|
-
export function declaredInputDigestsOf(snapshotRoot, inputs, {
|
|
198
|
-
const sep = platform === "win32" ? "\\" : "/";
|
|
346
|
+
export function declaredInputDigestsOf(snapshotRoot, inputs, { allowAbsent = false } = {}) {
|
|
199
347
|
let files = 0; let bytes = 0;
|
|
200
348
|
const walk = (full, name, hasher) => {
|
|
201
349
|
const meta = fs.lstatSync(full);
|
|
202
350
|
if (meta.isSymbolicLink()) fail("declared-inputs", `declared compile input holds a link at ${name}`);
|
|
203
351
|
if (meta.isDirectory()) {
|
|
204
352
|
const children = fs.readdirSync(full).sort((a, b) => Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")));
|
|
205
|
-
for (const child of children) walk(path.join(full, child),
|
|
353
|
+
for (const child of children) walk(path.join(full, child), `${name}/${child}`, hasher);
|
|
206
354
|
return;
|
|
207
355
|
}
|
|
208
356
|
if (!meta.isFile()) fail("declared-inputs", `declared compile input ${name} is not a regular file or directory`);
|
|
@@ -230,11 +378,14 @@ export function declaredInputDigestsOf(snapshotRoot, inputs, { platform = proces
|
|
|
230
378
|
for (const part of input.split(/[\\/]/)) {
|
|
231
379
|
at = path.join(at, part);
|
|
232
380
|
let meta;
|
|
233
|
-
try { meta = fs.lstatSync(at); } catch (e) {
|
|
381
|
+
try { meta = fs.lstatSync(at); } catch (e) {
|
|
382
|
+
if (allowAbsent && (e.code === "ENOENT" || e.code === "ENOTDIR")) return { path: input, absent: true };
|
|
383
|
+
fail("declared-inputs", `${input}: ${e.message}`);
|
|
384
|
+
}
|
|
234
385
|
if (meta.isSymbolicLink()) fail("declared-inputs", `${input} runs through a link at ${at}`);
|
|
235
386
|
}
|
|
236
387
|
const hasher = crypto.createHash("sha256");
|
|
237
|
-
walk(at, input, hasher);
|
|
388
|
+
walk(at, input.split(/[\\/]/).join("/"), hasher);
|
|
238
389
|
return { path: input, sha256: hasher.digest("hex") };
|
|
239
390
|
});
|
|
240
391
|
}
|
|
@@ -248,7 +399,15 @@ const inside = (child, parent, platform) => {
|
|
|
248
399
|
};
|
|
249
400
|
|
|
250
401
|
/**
|
|
251
|
-
* The
|
|
402
|
+
* The leaf names of a managed generation (`g/<gen>/<leaf>`). The leaf is per GENERATION, not per platform (rustright-state lib.rs:52-62 and
|
|
403
|
+
* :422-461): a generation keeps the leaf it was created with, and only new generations default to `t` on Windows and `target` elsewhere, so
|
|
404
|
+
* either may appear on either platform. The gate takes the leaf from the signed outputDir; it never infers it and never picks a directory.
|
|
405
|
+
*/
|
|
406
|
+
export const GENERATION_LEAVES = Object.freeze(["target", "t"]);
|
|
407
|
+
const isGenerationLeaf = (segment) => GENERATION_LEAVES.includes(segment);
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The generation target the broker pins an app's outputs to: `<root>/<12 hex>/g/<NNN>/<leaf>` (leaf `target` or `t`, GENERATION_LEAVES) under one of the managed `ws` roots
|
|
252
411
|
* (`<external data root>/rightkit-build-control/ws`; BROKER-CONTRACT "Source identity", output pinning). The 12-hex key is
|
|
253
412
|
* rustright-state's workspace_key, which the gate never computes (the snapshot's own key is `snapshots/<sha256(repo path)[:16]>`, a
|
|
254
413
|
* different thing); it reads the receipt's outputDir and checks only this shape. The generation number (3+ digits) is the broker's.
|
|
@@ -258,16 +417,96 @@ export function isGenerationTarget(outputDir, outputRoots, platform = process.pl
|
|
|
258
417
|
for (const root of outputRoots) {
|
|
259
418
|
const rel = mod.relative(platform === "win32" ? mod.resolve(root).toLowerCase() : mod.resolve(root), platform === "win32" ? mod.resolve(outputDir).toLowerCase() : mod.resolve(outputDir));
|
|
260
419
|
const segments = rel.split(/[\\/]/);
|
|
261
|
-
if (segments.length === 4 && segments[0] !== ".." && !mod.isAbsolute(rel) && /^[0-9a-f]{12}$/.test(segments[0]) && segments[1] === "g" && /^[0-9]{3,}$/.test(segments[2]) && segments[3]
|
|
420
|
+
if (segments.length === 4 && segments[0] !== ".." && !mod.isAbsolute(rel) && /^[0-9a-f]{12}$/.test(segments[0]) && segments[1] === "g" && /^[0-9]{3,}$/.test(segments[2]) && isGenerationLeaf(segments[3])) return true;
|
|
262
421
|
}
|
|
263
422
|
return false;
|
|
264
423
|
}
|
|
265
424
|
|
|
266
425
|
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
267
426
|
|
|
427
|
+
/** Whether `dir` is a managed generation target or a directory inside one (a nested compile's own directory under the adapter job's target). */
|
|
428
|
+
export function isInsideGenerationTarget(dir, outputRoots, platform = process.platform) {
|
|
429
|
+
const mod = platform === "win32" ? path.win32 : path;
|
|
430
|
+
for (const root of outputRoots) {
|
|
431
|
+
const rel = mod.relative(platform === "win32" ? mod.resolve(root).toLowerCase() : mod.resolve(root), platform === "win32" ? mod.resolve(dir).toLowerCase() : mod.resolve(dir));
|
|
432
|
+
const segments = rel.split(/[\\/]/);
|
|
433
|
+
if (segments.length >= 4 && segments[0] !== ".." && !mod.isAbsolute(rel) && /^[0-9a-f]{12}$/.test(segments[0]) && segments[1] === "g" && /^[0-9]{3,}$/.test(segments[2]) && isGenerationLeaf(segments[3])) return true;
|
|
434
|
+
}
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
|
|
268
439
|
/**
|
|
269
|
-
*
|
|
270
|
-
*
|
|
440
|
+
* The filesystem-free identity of a signed `snapshotEvidence` (rightkit#1301 B9), shared by the gate's own verification of a receipt
|
|
441
|
+
* (verifySnapshotReceipt) and the hosted admission (qualification-verify.mjs): the commit is `commit` ({ sha, tree }), the ownership
|
|
442
|
+
* handle is `handle` when one is known, the request id is the job's, the snapshot path is `snapshotPath` when one is known (compared with
|
|
443
|
+
* `samePath`, which the gate gives a real-path comparison), and the snapshot held `tree` at START and at exit. Throws a
|
|
444
|
+
* SnapshotRequestError (`evidence`); returns the evidence's `snapshot`.
|
|
445
|
+
*/
|
|
446
|
+
export function verifySnapshotEvidenceIdentity(evidence, { commit, tree = commit?.tree, jobId, handle, snapshotPath, samePath = (a, b) => a === b }) {
|
|
447
|
+
if (!evidence || typeof evidence !== "object") fail("evidence", "the signed payload carries no snapshotEvidence: the broker did not build this as a snapshot request");
|
|
448
|
+
if (!same(evidence.commit, commit)) fail("evidence", "the signed commit is not the one this request carried");
|
|
449
|
+
if (handle !== undefined && evidence.ownership?.handle !== handle) fail("evidence", "the signed ownership handle is not this gate's");
|
|
450
|
+
if (typeof evidence.requestId !== "string" || evidence.requestId !== jobId) fail("evidence", "the signed request id is not this job");
|
|
451
|
+
const snap = evidence.snapshot ?? {};
|
|
452
|
+
if (snapshotPath !== undefined && !samePath(snap.path, snapshotPath)) fail("evidence", `the signed snapshot ${snap.path} is not ${snapshotPath}`);
|
|
453
|
+
if (snap.treeAtStart !== tree || snap.treeAtExit !== tree) {
|
|
454
|
+
fail("evidence", `the snapshot was tree ${snap.treeAtStart} at START and ${snap.treeAtExit} at exit, not ${tree} throughout: the build changed a tracked file (or added or removed one) in the commit's snapshot, so the result is discarded; that is the app owner's defect to fix (write generated output to an ignored path or an output directory, or generate before commit)`);
|
|
455
|
+
}
|
|
456
|
+
return snap;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export const NESTED_CHILD_FIELDS = Object.freeze(["requestId", "parentRequestId", "shim", "argv", "cwd", "exitCode", "status", "outputDir"]);
|
|
460
|
+
/** The most children a root job's list may carry (rustright#190: the broker's cap; a refused child is never listed). */
|
|
461
|
+
export const NESTED_CHILDREN_CAP = 8192;
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The nested children of a snapshot-bound adapter job (rustright#190, final): the list the broker signs at `payload.snapshotEvidence.nestedChildren`
|
|
465
|
+
* and mirrors at the receipt's `nestedChildren`; BOTH are required and must be equal. Entries are in admission order, each exactly
|
|
466
|
+
* `{ requestId, parentRequestId, shim, argv, cwd, exitCode, status, outputDir }`: `argv` is redacted (cargo only), `exitCode` is an integer or
|
|
467
|
+
* null, `status` is "UNFINISHED" for a child that had not ended when the root's receipt was built, `outputDir` is null for a child that does not
|
|
468
|
+
* compile (`rustc -vV`, `metadata`). A refused child is not listed. Returns the normalised list or throws a SnapshotRequestError (`nested`).
|
|
469
|
+
* Refused: a missing or differing list, more than the cap, a child whose `parentRequestId` is neither the root job nor an earlier listed child, a repeated request id, an
|
|
470
|
+
* UNFINISHED child, a null or non-zero exit code, a cwd outside the snapshot, and a non-null `outputDir` that is not absolute, is inside the
|
|
471
|
+
* snapshot or the checkout, or is not a managed generation target or inside one.
|
|
472
|
+
*/
|
|
473
|
+
export function verifyNestedChildren(evidence, receipt, { snapshotPath, checkout, outputRoots, platform = process.platform, canonicalPath = (p) => fs.realpathSync.native(p), jobId }) {
|
|
474
|
+
const list = evidence?.nestedChildren;
|
|
475
|
+
if (!Array.isArray(list)) fail("nested", "the signed snapshotEvidence carries no nestedChildren list: the broker did not sign what this adapter job's children ran");
|
|
476
|
+
if (!Array.isArray(receipt?.nestedChildren)) fail("nested", "the receipt carries no nestedChildren list (the mirror of the signed one)");
|
|
477
|
+
if (!same(receipt.nestedChildren, list)) fail("nested", "receipt nestedChildren differs from the signed list");
|
|
478
|
+
if (list.length > NESTED_CHILDREN_CAP) fail("nested", `the nestedChildren list holds ${list.length} entries, more than the cap ${NESTED_CHILDREN_CAP}`);
|
|
479
|
+
const canon = (p) => { try { return canonicalPath(p); } catch { return p; } };
|
|
480
|
+
const ids = new Set();
|
|
481
|
+
return list.map((child, index) => {
|
|
482
|
+
const where = `nestedChildren[${index}]`;
|
|
483
|
+
if (!isObject(child) || Object.keys(child).sort().join() !== [...NESTED_CHILD_FIELDS].sort().join()) fail("nested", `${where} is not exactly { ${NESTED_CHILD_FIELDS.join(", ")} }`);
|
|
484
|
+
if (typeof child.requestId !== "string" || !child.requestId || child.requestId === jobId || ids.has(child.requestId)) fail("nested", `${where} has no request id of its own`);
|
|
485
|
+
const name = `${where} (${child.requestId})`;
|
|
486
|
+
// The parent is the job the request named: this job, or a child listed before it (admission order; depth >= 2, rustright#190 ad9ecb37).
|
|
487
|
+
if (child.parentRequestId !== jobId && !ids.has(child.parentRequestId)) fail("nested", `${name} names parent ${JSON.stringify(child.parentRequestId)}, neither this job ${jobId} nor an earlier listed child`);
|
|
488
|
+
ids.add(child.requestId);
|
|
489
|
+
if (typeof child.shim !== "string" || !child.shim) fail("nested", `${name} names no shim`);
|
|
490
|
+
if (!Array.isArray(child.argv) || !child.argv.every((a) => typeof a === "string")) fail("nested", `${name} argv is not a list of strings`);
|
|
491
|
+
if (child.status === "UNFINISHED") fail("nested", `${name} (${child.argv.join(" ")}) had not finished when the job's receipt was built (UNFINISHED)`);
|
|
492
|
+
if (typeof child.status !== "string" || !child.status) fail("nested", `${name} has no status`);
|
|
493
|
+
if (!Number.isSafeInteger(child.exitCode) || child.exitCode !== 0) fail("nested", `${name} (${child.argv.join(" ")}) exited ${child.exitCode === null ? "with no exit code" : child.exitCode}, not 0: a child of this job failed or never finished`);
|
|
494
|
+
if (typeof child.cwd !== "string" || (!path.isAbsolute(child.cwd) && !path.win32.isAbsolute(child.cwd))) fail("nested", `${name} names no absolute working directory`);
|
|
495
|
+
if (!inside(child.cwd, snapshotPath, platform) && !inside(canon(child.cwd), canon(snapshotPath), platform)) fail("nested", `${name} ran in ${child.cwd}, outside the snapshot ${snapshotPath}`);
|
|
496
|
+
const out = child.outputDir;
|
|
497
|
+
if (out !== null) {
|
|
498
|
+
if (typeof out !== "string" || (!path.isAbsolute(out) && !path.win32.isAbsolute(out))) fail("nested", `${name} names an output directory that is neither null nor absolute`);
|
|
499
|
+
if (inside(out, snapshotPath, platform) || inside(out, canon(snapshotPath), platform)) fail("nested", `${name} output directory ${out} is inside the snapshot`);
|
|
500
|
+
if (inside(out, checkout, platform) || inside(out, canon(checkout), platform)) fail("nested", `${name} output directory ${out} is inside the checkout`);
|
|
501
|
+
if (!isInsideGenerationTarget(out, outputRoots, platform)) fail("nested", `${name} output directory ${out} is not inside a managed generation target under ${outputRoots.join(", ")}`);
|
|
502
|
+
}
|
|
503
|
+
return { requestId: child.requestId, parentRequestId: child.parentRequestId, shim: child.shim, argv: [...child.argv], cwd: child.cwd, exitCode: 0, status: child.status, outputDir: out };
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Verifies the receipt of one snapshot request (a cargo call, or with `adapter` an adapter job). `pins` come from this host's enrollment, never from the receipt.
|
|
509
|
+
* Returns `{ payload, evidence, attribution, toolchain, nestedChildren, requestDigest, causeEvidence }` (`causeEvidence` is null for a command that carries none); throws ProvenanceError (legacy, signature, pins, job, nonce, request, evidence, cause-unknown, cause-unexplained) or SnapshotRequestError (evidence).
|
|
271
510
|
*
|
|
272
511
|
* Beyond the signature, the job, the nonce and the request digest, the receipt must carry (item 7 row 7.22):
|
|
273
512
|
* - signed SUCCEEDED with exit 0, and signed snapshotEvidence equal to the receipt's copies (commit, ownership handle, request id,
|
|
@@ -275,42 +514,53 @@ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
|
|
|
275
514
|
* - `declaredInputDigests` equal to the gate's own digest of the snapshot's declared inputs, read from the snapshot now;
|
|
276
515
|
* - `outputDir` absolute, outside the snapshot and the checkout, and a managed generation target under one of `outputRoots`;
|
|
277
516
|
* - signed stageEvidence (freshness, attribution, toolchain) equal to the receipt's;
|
|
278
|
-
* -
|
|
517
|
+
* - for a compile, freshness.causeEvidence (rustright.cause-evidence.v1) coherent with its census and with a verdict of `explained`: refused as `evidence` when missing,
|
|
518
|
+
* of another schema or incoherent, `cause-unknown` when the broker could not say why units were rebuilt, `cause-unexplained` when a rebuilt unit has no explained cause (RA-03);
|
|
519
|
+
* - timingsMs, exit, diagnosticsPath, brokerVersion and executor present with their types; the retired `verdict` and `refreshed` are refused (GB-30: Class A and its verdict reuse are the gate's).
|
|
279
520
|
* `checkout` and `outputRoots` are required: without them nothing can be said about where the outputs went, so it refuses.
|
|
280
521
|
*/
|
|
281
|
-
export function verifySnapshotReceipt(receipt, { pins, jobId, carrier, argv, workspace, observedEnv = {}, checkout, outputRoots, platform = process.platform, canonicalPath = (p) => fs.realpathSync.native(p), digestInputs = declaredInputDigestsOf, readInputs = readDeclaredInputs }) {
|
|
282
|
-
const requestDigest = requestDigestOf(snapshotRequestCore({ argv, workspace, observedEnv, carrier }));
|
|
522
|
+
export function verifySnapshotReceipt(receipt, { pins, jobId, carrier, argv, workspace, observedEnv = {}, adapter = null, adapterEnv = {}, checkout, outputRoots, platform = process.platform, canonicalPath = (p) => fs.realpathSync.native(p), digestInputs = declaredInputDigestsOf, readInputs = readDeclaredInputs }) {
|
|
523
|
+
const requestDigest = requestDigestOf(snapshotRequestCore({ argv, workspace, observedEnv, carrier, adapter, adapterEnv }));
|
|
283
524
|
const payload = verifyProvenance(receipt, pins, { jobId, nonce: carrier.nonce, requestDigest });
|
|
284
525
|
// The signed outcome, not the client's exit code: a passing row needs the broker to have signed SUCCEEDED with exit 0 and no
|
|
285
526
|
// signal (service.rs provenance_payload `result`). An authentic FAILED, or a payload without a result, is not a success.
|
|
286
527
|
const result = payload.result;
|
|
287
528
|
if (!result || typeof result !== "object") fail("result", "the signed payload carries no result: the broker did not sign this request's outcome");
|
|
288
529
|
if (result.status !== "SUCCEEDED" || result.exitCode !== 0 || (result.signal !== undefined && result.signal !== null)) {
|
|
289
|
-
|
|
530
|
+
// Any terminal status but SUCCEEDED fails the call, whatever the broker says about why (a tracked write is refused at exit with a
|
|
531
|
+
// `snapshot exit bind: ...` message; the field that carries it is pending RustRight's confirmation, so the usual spellings are read).
|
|
532
|
+
// The signed result of a broker verdict after a clean run (FAILED with exit 0: RR_UNEXPLAINED_COLD, ...) carries no reason; the receipt's own
|
|
533
|
+
// unsigned `error` names it. It is shown for diagnosis only and decides nothing: the verdict is the signed status.
|
|
534
|
+
// GB-66: a compile the broker refused to run because it could not name its toolchain fails with this code (and an UNKNOWN toolchain on its receipt).
|
|
535
|
+
if ([result.code, receipt?.code].includes(TOOLCHAIN_UNAVAILABLE_CODE)) fail(TOOLCHAIN_UNAVAILABLE, `the broker did not run this compile: it could not build the toolchain identity (${TOOLCHAIN_UNAVAILABLE_CODE}${isToolchainUnknown(receipt?.toolchain) ? `: ${receipt.toolchain.reason}` : ""}); register the toolchain's cargo and rustc and its name, then ask again`);
|
|
536
|
+
const why = [result.message, result.error, result.reason].find((v) => typeof v === "string" && v !== "");
|
|
537
|
+
const unsigned = why ? "" : [receipt?.error, receipt?.code].find((v) => typeof v === "string" && v !== "");
|
|
538
|
+
fail("result", `the broker signed ${result.status ?? "no status"} (exit ${result.exitCode ?? "none"}${result.signal ? `, signal ${result.signal}` : ""}${why ? `; ${why}` : ""}${unsigned ? `; the receipt's unsigned error: ${unsigned}` : ""}), not SUCCEEDED with exit 0`);
|
|
290
539
|
}
|
|
291
540
|
const evidence = payload.snapshotEvidence;
|
|
292
541
|
if (!evidence || typeof evidence !== "object") fail("evidence", "the signed payload carries no snapshotEvidence: the broker did not build this as a snapshot request");
|
|
293
542
|
for (const name of SNAPSHOT_EVIDENCE_FIELDS) {
|
|
294
543
|
if (evidence[name] === undefined || !same(receipt[name], evidence[name])) fail("evidence", `receipt ${name} differs from the signed snapshot evidence`);
|
|
295
544
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
let samePath = false;
|
|
301
|
-
try { samePath = canonicalPath(snap.path) === canonicalPath(carrier.snapshot.path); } catch { samePath = false; }
|
|
302
|
-
if (!samePath) fail("evidence", `the signed snapshot ${snap.path} is not ${carrier.snapshot.path}`);
|
|
303
|
-
if (snap.treeAtStart !== carrier.snapshot.expectedTree || snap.treeAtExit !== carrier.snapshot.expectedTree) {
|
|
304
|
-
fail("evidence", `the snapshot was tree ${snap.treeAtStart} at START and ${snap.treeAtExit} at exit, not ${carrier.snapshot.expectedTree} throughout: the build changed a tracked file (or added or removed one) in the commit's snapshot, so the result is discarded; that is the app owner's defect to fix (write generated output to an ignored path or an output directory, or generate before commit)`);
|
|
305
|
-
}
|
|
545
|
+
verifySnapshotEvidenceIdentity(evidence, {
|
|
546
|
+
commit: carrier.commit, tree: carrier.snapshot.expectedTree, jobId, handle: carrier.ownership.handle, snapshotPath: carrier.snapshot.path,
|
|
547
|
+
samePath: (a, b) => { try { return canonicalPath(a) === canonicalPath(b); } catch { return false; } },
|
|
548
|
+
});
|
|
306
549
|
// Declared inputs: what the broker digested in place must be what the gate digests in the snapshot, from the same manifest.
|
|
550
|
+
// An absent entry ({path, absent:true}) is evidence only for a non-compiling snapshot-bound request, and only when the gate's own read of the
|
|
551
|
+
// snapshot finds the input absent too: the gate digests with `allowAbsent` for that class alone, so a receipt that says absent where the gate
|
|
552
|
+
// sees the input present (or present where it is absent) differs below and is refused. A compile receipt never carries one.
|
|
553
|
+
const nonCompile = adapter === null && !isCompileClass(argv);
|
|
554
|
+
if (!nonCompile && Array.isArray(evidence.declaredInputDigests) && evidence.declaredInputDigests.some((d) => isObject(d) && (d.absent !== undefined || d.sha256 === undefined))) {
|
|
555
|
+
fail("evidence", "the signed declaredInputDigests carry an absent declared input on a compile request (cargo " + `${cargoSubcommandOf(argv)}); only a non-compiling request may record one`);
|
|
556
|
+
}
|
|
307
557
|
let gateInputs; let gateDigests;
|
|
308
558
|
try {
|
|
309
559
|
gateInputs = readInputs(carrier.snapshot.path);
|
|
310
|
-
gateDigests = digestInputs(carrier.snapshot.path, gateInputs, { platform });
|
|
560
|
+
gateDigests = digestInputs(carrier.snapshot.path, gateInputs, { platform, allowAbsent: nonCompile });
|
|
311
561
|
} catch (e) { fail("evidence", `the gate cannot digest the snapshot's declared inputs (${e.message})`); }
|
|
312
|
-
if (!same(gateInputs, carrier.declaredInputs?.inputs)) fail("evidence", "the snapshot's declared inputs are not the ones this request carried");
|
|
313
|
-
if (!same(evidence.declaredInputDigests, gateDigests)) fail("evidence", "the signed declaredInputDigests are not the gate's own digest of the snapshot's declared inputs");
|
|
562
|
+
if (!same(gateInputs, carrier.declaredInputs?.inputs)) fail("evidence", "the snapshot's declared inputs are not the ones this request carried (declaredInputs differs from the snapshot declaration)");
|
|
563
|
+
if (!same(evidence.declaredInputDigests, gateDigests)) fail("evidence", "the signed declaredInputDigests are not the gate's own digest of the snapshot's declared inputs (an input the receipt records absent must be absent in the snapshot, and the reverse)");
|
|
314
564
|
// Output directory: where the outputs went must be the broker's managed generation target, never under the snapshot or the checkout.
|
|
315
565
|
const out = evidence.outputDir;
|
|
316
566
|
if (typeof out !== "string" || !path.isAbsolute(out) && !path.win32.isAbsolute(out)) fail("evidence", "the signed evidence names no absolute output directory");
|
|
@@ -318,46 +568,89 @@ export function verifySnapshotReceipt(receipt, { pins, jobId, carrier, argv, wor
|
|
|
318
568
|
const canon = (p) => { try { return canonicalPath(p); } catch { return p; } };
|
|
319
569
|
if (inside(out, carrier.snapshot.path, platform) || inside(out, canon(carrier.snapshot.path), platform)) fail("evidence", `the output directory ${out} is inside the snapshot`);
|
|
320
570
|
if (inside(out, checkout, platform) || inside(out, canon(checkout), platform)) fail("evidence", `the output directory ${out} is inside the checkout`);
|
|
321
|
-
if (!isGenerationTarget(out, outputRoots, platform)) fail("evidence", `the output directory ${out} is not a managed generation target (<ws root>/<repo-key>/g/<generation>/target) under ${outputRoots.join(", ")}`);
|
|
322
|
-
// Stage evidence is signed with the rest and must equal the receipt's copy. `freshness`
|
|
323
|
-
//
|
|
324
|
-
//
|
|
571
|
+
if (!isGenerationTarget(out, outputRoots, platform)) fail("evidence", `the output directory ${out} is not a managed generation target (<ws root>/<repo-key>/g/<generation>/{target|t}) under ${outputRoots.join(", ")}`);
|
|
572
|
+
// Stage evidence is signed with the rest and must equal the receipt's copy. `freshness`, `attribution` and `toolchain` are
|
|
573
|
+
// required only for a compile-class command (check, clippy, build, doc, rustc, test): the broker emits them for compiles only, so a
|
|
574
|
+
// read-only metadata, fetch --locked, fmt --check or tree --locked passes without them (rightkit#1311: a carried metadata job has
|
|
575
|
+
// no freshness census; the Mac witness, request ed37214e). When either side carries one
|
|
325
576
|
// anyway, both must carry the same value.
|
|
326
577
|
const stage = payload.stageEvidence;
|
|
327
578
|
if (!isObject(stage)) fail("evidence", "the signed payload carries no stageEvidence");
|
|
328
|
-
|
|
579
|
+
// An adapter job is not a cargo command: whether it signs attribution and toolchain is the adapter's; when either side carries one,
|
|
580
|
+
// both must carry the same value (below).
|
|
581
|
+
const compile = adapter === null && isCompileClass(argv);
|
|
329
582
|
for (const name of STAGE_EVIDENCE_FIELDS) {
|
|
330
583
|
const signed = stage[name] ?? null; const copy = receipt[name] ?? null;
|
|
331
|
-
const required =
|
|
332
|
-
if (required && (signed === null || copy === null)) fail("evidence", `${name} is missing from the signed stage evidence or the receipt
|
|
584
|
+
const required = compile;
|
|
585
|
+
if (required && (signed === null || copy === null)) fail("evidence", `${name} is missing from the signed stage evidence or the receipt (a compile-class command, cargo ${cargoSubcommandOf(argv)}, carries it)`);
|
|
333
586
|
if (!same(copy, signed)) fail("evidence", `receipt ${name} differs from the signed stage evidence`);
|
|
334
587
|
}
|
|
335
588
|
// Shapes RustRight emits (item 4 row 4.18): attribution is `{available, ...}`, and an unavailable one names its reason
|
|
336
589
|
// (`cargo-json-message-format-not-requested` without a JSON message stream; a consumer that needs per-package attribution treats
|
|
337
590
|
// `available:false` as FAILED, not partial); toolchain is `<toolchain>|cargo:<sha256>|rustc:<sha256>`.
|
|
591
|
+
// GB-02 (BROKER-CONTRACT section 14): a compile's freshness is an object with a boolean `available`, and `available: false` always names a reason: absence is never an
|
|
592
|
+
// unavailable census, and an unavailable one on a SUCCEEDED compile is the broker's own failure (RR_FRESHNESS_UNAVAILABLE), never evidence.
|
|
593
|
+
if (compile) {
|
|
594
|
+
const freshness = receipt.freshness;
|
|
595
|
+
if (!isObject(freshness) || typeof freshness.available !== "boolean") fail("evidence", "receipt freshness is not {available, ...}");
|
|
596
|
+
if (freshness.available === false && !(typeof freshness.reason === "string" && freshness.reason !== "")) fail("evidence", "receipt freshness is unavailable and names no reason");
|
|
597
|
+
}
|
|
338
598
|
const attribution = receipt.attribution ?? null; const toolchain = receipt.toolchain ?? null;
|
|
339
599
|
if (attribution !== null) {
|
|
340
600
|
if (!isObject(attribution) || typeof attribution.available !== "boolean") fail("evidence", "receipt attribution is not {available, ...}");
|
|
341
601
|
if (attribution.available === false && !(typeof attribution.reason === "string" && attribution.reason !== "")) fail("evidence", "receipt attribution is unavailable and names no reason");
|
|
342
602
|
}
|
|
603
|
+
// GB-66: the broker signs `<name>|cargo:<sha256>|rustc:<sha256>` only when it could build that identity; when it could not (no configured name, an unregistered or
|
|
604
|
+
// changed cargo or rustc) the receipt's toolchain is exactly {"state":"UNKNOWN","reason":...}. That is a typed unavailable identity, never evidence and never a
|
|
605
|
+
// string with an empty component.
|
|
606
|
+
if (isToolchainUnknown(toolchain)) fail(TOOLCHAIN_UNAVAILABLE, `the broker could not name this compile's toolchain (${TOOLCHAIN_UNAVAILABLE_CODE}: UNKNOWN, ${toolchain.reason}); an unknown toolchain identity is not evidence, so the call is unverified and nothing is concluded from it`);
|
|
343
607
|
if (toolchain !== null && !(typeof toolchain === "string" && TOOLCHAIN_FORMAT.test(toolchain))) fail("evidence", "receipt toolchain is not <toolchain>|cargo:<sha256>|rustc:<sha256>");
|
|
608
|
+
// RA-03: a census that counts rebuilt units says nothing about why they were rebuilt. A compile's signed freshness must carry `causeEvidence`
|
|
609
|
+
// (rustright.cause-evidence.v1: provenance-verify.mjs requireExplainedCauses, the one implementation) whose verdict is `explained`: a missing or unrecognized
|
|
610
|
+
// schema or an incoherent one is `evidence`, an `unknown` verdict (a quiet, caller-logged or artifact-running root whose log was not read, dropped output, an
|
|
611
|
+
// uncovered unit) is `cause-unknown`, and a unit rebuilt with no explained cause is `cause-unexplained`; none is a success, and none is relaxed for an older
|
|
612
|
+
// broker that signs no cause evidence. The judged object is the SIGNED copy (the receipt's equals it, checked above). A command that is not a compile
|
|
613
|
+
// legitimately carries no census; when one carries cause evidence anyway it must still be well formed, but nothing is judged for it (nothing was rebuilt).
|
|
614
|
+
const signedFreshness = stage.freshness ?? null;
|
|
615
|
+
let causeEvidence = null;
|
|
616
|
+
if (compile) causeEvidence = requireExplainedCauses(signedFreshness);
|
|
617
|
+
else if (isObject(signedFreshness) && signedFreshness.causeEvidence !== undefined) causeEvidence = verifyCauseEvidence(signedFreshness);
|
|
344
618
|
// Fields the signature does not cover must still be present with their types (the consumer reads them). Every carried command:
|
|
345
|
-
//
|
|
346
|
-
//
|
|
619
|
+
// exit (an integer, or null with a signal) and signal, timingsMs{admission, queue, bind, cargo, afterExitBind, diagnostics} (each a
|
|
620
|
+
// number or null) and diagnosticsPath (a path, or null). Success needs exit 0 and no signal, as signed. `verdict` and `refreshed`
|
|
621
|
+
// are retired: the gate owns Class A, and RustRight refuses a receipt that carries either (rustright bc24d55, attestation.rs
|
|
622
|
+
// RETIRED_RESULT_FIELDS), so the gate refuses them too.
|
|
347
623
|
const timingsOk = isObject(receipt.timingsMs) && TIMING_KEYS.every((k) => Object.hasOwn(receipt.timingsMs, k) && (receipt.timingsMs[k] === null || Number.isFinite(receipt.timingsMs[k])));
|
|
348
624
|
const typed = [
|
|
349
|
-
["
|
|
625
|
+
["timingsMs", timingsOk], ["exit", receipt.exit === null || Number.isSafeInteger(receipt.exit)],
|
|
350
626
|
["signal", receipt.signal === null || typeof receipt.signal === "string" || Number.isSafeInteger(receipt.signal)],
|
|
351
627
|
["diagnosticsPath", receipt.diagnosticsPath === null || (typeof receipt.diagnosticsPath === "string" && receipt.diagnosticsPath !== "")], ["brokerVersion", typeof receipt.brokerVersion === "string" && receipt.brokerVersion !== ""],
|
|
352
|
-
["executor", typeof receipt.executor === "string" && receipt.executor !== ""],
|
|
628
|
+
["executor", typeof receipt.executor === "string" && receipt.executor !== ""],
|
|
353
629
|
];
|
|
354
630
|
for (const [name, ok] of typed) if (!ok) fail("evidence", `receipt ${name} is missing or has the wrong type`);
|
|
631
|
+
for (const name of ["verdict", "refreshed"]) if (Object.hasOwn(receipt, name)) fail("evidence", `receipt carries the retired field ${name}`);
|
|
355
632
|
if (receipt.exit !== 0 || receipt.signal !== null) fail("evidence", `receipt exit ${receipt.exit} and signal ${receipt.signal} are not a success (exit 0, no signal)`);
|
|
356
|
-
// RustRight signs
|
|
357
|
-
|
|
358
|
-
|
|
633
|
+
// RustRight signs the result fields under payload.resultEvidence (service.rs provenance_payload; attestation.rs RESULT_EVIDENCE_FIELDS), never at
|
|
634
|
+
// the payload's top level. Every receipt copy must equal its signed counterpart, and a receipt with no signed resultEvidence is refused: the
|
|
635
|
+
// exit, signal, timings and diagnostics path it shows would otherwise be this receipt's own say-so.
|
|
636
|
+
const signedResult = payload.resultEvidence;
|
|
637
|
+
if (!isObject(signedResult)) fail("evidence", "the signed payload carries no resultEvidence: the receipt's exit, signal, timingsMs and diagnosticsPath are not covered by the signature");
|
|
638
|
+
for (const name of RESULT_EVIDENCE_FIELDS) {
|
|
639
|
+
if (!Object.hasOwn(signedResult, name) || !same(receipt[name], signedResult[name])) fail("evidence", `receipt ${name} differs from the signed result evidence`);
|
|
359
640
|
}
|
|
360
|
-
|
|
641
|
+
for (const name of ["verdict", "refreshed"]) if (Object.hasOwn(signedResult, name)) fail("evidence", `the signed result evidence carries the retired field ${name}`);
|
|
642
|
+
// Symmetric both ways: a field the broker signed that the protocol does not name is as unexplained as one the receipt lacks (an added field).
|
|
643
|
+
for (const name of Object.keys(signedResult)) if (!RESULT_EVIDENCE_FIELDS.includes(name)) fail("evidence", `the signed result evidence carries the unknown field ${name}`);
|
|
644
|
+
for (const name of Object.keys(isObject(receipt.timingsMs) ? receipt.timingsMs : {})) if (!TIMING_KEYS.includes(name)) fail("evidence", `receipt timingsMs carries the unknown key ${name}`);
|
|
645
|
+
// A snapshot-bound adapter job signs the compiles its own children made (rustright#190 D8).
|
|
646
|
+
const nestedChildren = adapter === null ? null : verifyNestedChildren(evidence, receipt, { snapshotPath: carrier.snapshot.path, checkout, outputRoots, platform, canonicalPath, jobId });
|
|
647
|
+
// GB-10 / GB-11: whatever the checks above did not already name, the production strict verifier (`verify_receipt_strict`, provenance-verify.mjs, judged
|
|
648
|
+
// against RustRight's receipt vector case for case) must also accept: no signed-only or receipt-only field anywhere, the signed result evidence with exactly
|
|
649
|
+
// its four fields and six phases, and the BR19 shape (profile, stage, resources, environment; a value or a typed UNKNOWN, never absent).
|
|
650
|
+
verifyReceiptCoverage(receipt, payload);
|
|
651
|
+
verifyResultEvidence(receipt, payload);
|
|
652
|
+
verifyBr19Evidence(receipt, payload);
|
|
653
|
+
return { payload, evidence, attribution, toolchain, nestedChildren, requestDigest, causeEvidence };
|
|
361
654
|
}
|
|
362
655
|
|
|
363
|
-
export { ProvenanceError };
|
|
656
|
+
export { CAUSE_EVIDENCE_SCHEMA, CAUSE_UNEXPLAINED, CAUSE_UNKNOWN, ProvenanceError };
|