@rightkit/release 0.6.8 → 0.6.11
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 +30 -12
- package/admission-files.mjs +4 -1
- package/admission.mjs +2 -2
- package/advisory-exceptions.mjs +41 -5
- package/android-play-upload.mjs +16 -15
- package/app-check.mjs +105 -27
- package/assemble-app-command.mjs +151 -0
- package/assembled-app.mjs +445 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/bin/rightkit-gate.mjs +1608 -345
- package/broker-pins.mjs +152 -0
- package/build-launch.mjs +88 -0
- package/build-ownership.mjs +1231 -0
- package/build-release.mjs +82 -28
- package/cargo-contract.mjs +3 -1
- package/cargo-ledger.mjs +62 -0
- package/cargo-target-shim.mjs +238 -0
- package/class-a-launch-guard.mjs +126 -0
- package/cli/right-release.mjs +31 -5
- package/clippy-equivalence.mjs +202 -0
- package/clippy-route.mjs +543 -0
- package/codesign-shim.mjs +11 -1
- package/committed-source.mjs +39 -5
- package/crate-workspaces.mjs +87 -0
- package/create-mac-updater.mjs +14 -2
- package/deterministic-scans.mjs +997 -71
- package/development-signing.mjs +4 -1
- package/development.mjs +120 -21
- package/direct-receipt.mjs +135 -0
- package/door-policy.mjs +71 -6
- package/door.mjs +6 -6
- package/freshness-census.mjs +257 -0
- package/gate-executor.mjs +14 -2
- package/gate-stages.mjs +91 -15
- package/heavy-command.mjs +3 -2
- package/hook-retirement.mjs +65 -0
- package/init-command.mjs +1 -1
- package/install-app.mjs +11 -2
- package/install-assembled-app.mjs +423 -0
- package/inventory-check.mjs +11 -8
- package/ios-dev.mjs +70 -3
- package/ios-upload.mjs +20 -15
- package/linux-artifacts.mjs +93 -0
- package/local-publish.mjs +224 -53
- package/local-qualify.mjs +5 -3
- package/local-runtime.mjs +5 -4
- package/mac-package-recipe.mjs +4 -1
- package/managed-cargo.mjs +47 -5
- package/managed-root-guard.mjs +34 -0
- package/mirror-root-artifact.mjs +18 -5
- package/native-helpers.mjs +355 -0
- package/ownership-class-rules.mjs +1 -1
- package/ownership-evidence.mjs +37 -13
- package/ownership-hooks.mjs +20 -59
- package/ownership-lockfiles.mjs +14 -1
- package/ownership-policy-bundle.mjs +3 -2
- package/ownership-staged.mjs +2 -1
- package/ownership.mjs +24 -0
- package/package-recipe-targets.mjs +84 -10
- package/package.json +4 -3
- package/pipeline-admission.mjs +4 -4
- package/pipeline-receipts.mjs +302 -302
- package/preflight.mjs +27 -4
- package/project-type.mjs +2 -2
- package/provenance-verify.mjs +115 -0
- package/publish-cargo.mjs +12 -9
- package/qa-admission-policy.json +10 -1
- package/recipe-build.mjs +106 -14
- package/release-artifact-path.mjs +341 -0
- package/release-config.mjs +1 -1
- package/release-invocation.mjs +8 -1
- package/release-qa-command.mjs +25 -9
- package/release-source-brand.mjs +17 -0
- package/release-source-identity.mjs +171 -0
- package/release.mjs +59 -22
- package/rightkit-versions.json +73 -47
- package/runtime-budgets.mjs +6 -2
- package/scan-digest.mjs +71 -5
- package/sign-updater.mjs +6 -3
- package/snapshot-broker.mjs +172 -0
- package/snapshot-check.mjs +312 -0
- package/snapshot-git.mjs +166 -0
- package/snapshot-request.mjs +363 -0
- package/snapshot-root-config.mjs +76 -0
- package/snapshot.mjs +969 -0
- package/source-split.mjs +73 -0
- package/stage-commands.mjs +60 -0
- package/stage-ledger.mjs +204 -0
- package/stage-sidecar-command.mjs +5 -1
- package/stage-sidecar.mjs +157 -27
- package/swap-helper-bundle.mjs +5 -2
- package/test-command.mjs +17 -4
- package/unit-gate.mjs +301 -0
- package/web-build.mjs +40 -6
- package/win-package-recipe.mjs +20 -7
package/snapshot-git.mjs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// The sealed git invocation of the gate (item 5 D3, signed/item5-sage-decisions-2.md; docs/GATE-CONTRACT.md §1 "exact snapshot",
|
|
2
|
+
// rightkit#1255). The snapshot's identity and every object read the gate makes depend on the commit's objects and the snapshot's
|
|
3
|
+
// bytes ONLY. The checkout's git configuration cannot reach them: no `filter.*` clean or smudge driver, no core.autocrlf or eol
|
|
4
|
+
// conversion, no attributes from the checkout's `.git/info/attributes` or any attributes file, no hooks, no system, global or user
|
|
5
|
+
// configuration, no replace refs, no inherited GIT_* variable. (Probes P3a to P3d of the decision: today's unsealed invocation ran a
|
|
6
|
+
// checkout filter and hid a byte tamper and a line-ending tamper behind the commit's own id.)
|
|
7
|
+
//
|
|
8
|
+
// ONE runner serves every identity operation: `<sha>^{tree}`, `ls-tree`, `cat-file --batch`, `read-tree`, `add -A`, `write-tree` and
|
|
9
|
+
// the 5.09d ignore oracle (`check-ignore`). Only resolving a revision NAME to a commit runs against the checkout's own git directory
|
|
10
|
+
// (it needs the refs), under the same environment plus GIT_NO_REPLACE_OBJECTS (`discoveryGit`).
|
|
11
|
+
//
|
|
12
|
+
// The private git directory T (mkdtemp, mode 0700, outside the checkout and the snapshot, removed afterwards) holds:
|
|
13
|
+
// HEAD `ref: refs/heads/none`; an empty refs/; objects/info/alternates naming `<checkout git common dir>/objects` (so every read is
|
|
14
|
+
// still against the checkout's object database, and every object `add` and `write-tree` writes lands in T, never in the
|
|
15
|
+
// checkout); `config` with only core.repositoryformatversion and core.bare (plus extensions.objectFormat for SHA-256);
|
|
16
|
+
// info/attributes SEALED_ATTRIBUTES (the highest-precedence attribute source); an empty info/exclude (so the only ignore rules are
|
|
17
|
+
// the commit's own `.gitignore` files); an empty hooks/; an empty file `empty`.
|
|
18
|
+
// The environment is built from nothing (sealedEnv). The `-c` flags are SEALED_CONFIG, in this order (sealedConfigArgs).
|
|
19
|
+
//
|
|
20
|
+
// The exact flags live in SEALED_GIT, one frozen constant: the broker (rustright check_admission.rs `sealed_git()`) implements the
|
|
21
|
+
// identical invocation, and the three-way compare of row 5.11 cites this constant.
|
|
22
|
+
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import os from "node:os";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { spawnSync } from "./background-process.mjs";
|
|
27
|
+
|
|
28
|
+
/** info/attributes of T, the highest-precedence attribute source: nothing converts, rewrites, identifies or re-encodes a byte. */
|
|
29
|
+
export const SEALED_ATTRIBUTES = "* -text -ident -filter -working-tree-encoding !eol\n";
|
|
30
|
+
|
|
31
|
+
/** The `-c key=value` list, in order. `@FILEMODE` is true on darwin and linux and false on win32 (NTFS has no exec bit: a 100755 lives in the seeded index, signed 5.04); `@T/x` is T/x. */
|
|
32
|
+
export const SEALED_CONFIG = Object.freeze([
|
|
33
|
+
["core.autocrlf", "false"], ["core.safecrlf", "false"], ["core.eol", "lf"],
|
|
34
|
+
["core.fileMode", "@FILEMODE"], ["core.symlinks", "true"],
|
|
35
|
+
["core.ignoreCase", "false"], ["core.precomposeUnicode", "false"],
|
|
36
|
+
["core.attributesFile", "@T/empty"], ["core.excludesFile", "@T/empty"], ["core.hooksPath", "@T/hooks"],
|
|
37
|
+
["core.fsmonitor", "false"], ["core.untrackedCache", "false"], ["core.splitIndex", "false"],
|
|
38
|
+
["core.sparseCheckout", "false"], ["index.sparse", "false"], ["core.useReplaceRefs", "false"],
|
|
39
|
+
].map((pair) => Object.freeze(pair)));
|
|
40
|
+
|
|
41
|
+
/** Environment variables the invocation sets to a fixed value; the others (PATH, HOME, XDG_CONFIG_HOME, LC_ALL, GIT_CONFIG_GLOBAL, GIT_INDEX_FILE, GIT_DIR, GIT_WORK_TREE, and on Windows SystemRoot, WINDIR, PATHEXT, TEMP, TMP) are built by sealedEnv. */
|
|
42
|
+
export const SEALED_ENV_FIXED = Object.freeze({
|
|
43
|
+
GIT_CONFIG_NOSYSTEM: "1", GIT_ATTR_NOSYSTEM: "1", GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** The sealed invocation, whole: what the three-way compare (5.11) and the broker's `sealed_git()` cite. */
|
|
47
|
+
export const SEALED_GIT = Object.freeze({
|
|
48
|
+
attributes: SEALED_ATTRIBUTES,
|
|
49
|
+
config: SEALED_CONFIG,
|
|
50
|
+
envFixed: SEALED_ENV_FIXED,
|
|
51
|
+
envBuilt: Object.freeze(["PATH", "HOME", "XDG_CONFIG_HOME", "LC_ALL", "GIT_CONFIG_GLOBAL", "GIT_INDEX_FILE", "GIT_DIR", "GIT_WORK_TREE", "SystemRoot", "WINDIR", "PATHEXT", "TEMP", "TMP"]),
|
|
52
|
+
layout: Object.freeze({ head: "ref: refs/heads/none\n", alternates: "<git common dir>/objects", excludeIsEmpty: true, hooksIsEmpty: true, workingDirectory: "the snapshot" }),
|
|
53
|
+
gitVersionNote: "GIT_NO_LAZY_FETCH needs git 2.45 or later; older git ignores it",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const MAX_BUFFER = 1 << 30;
|
|
57
|
+
const WINDOWS_ENV = ["SystemRoot", "WINDIR", "PATHEXT"];
|
|
58
|
+
|
|
59
|
+
/** `-c k=v ...` for T and `platform`, in SEALED_CONFIG's order. */
|
|
60
|
+
export function sealedConfigArgs(T, platform = process.platform) {
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const [key, value] of SEALED_CONFIG) {
|
|
63
|
+
const v = value === "@FILEMODE" ? (platform === "win32" ? "false" : "true") : value.startsWith("@T/") ? path.join(T, value.slice(3)) : value;
|
|
64
|
+
out.push("-c", `${key}=${v}`);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const fromEnv = (base, name) => { const k = Object.keys(base).find((x) => x.toLowerCase() === name.toLowerCase()); return k === undefined ? undefined : base[k]; };
|
|
70
|
+
|
|
71
|
+
/** The environment for git: built from nothing, never inherited. `T` is the private git directory (or the discovery home), `snapshot` the work tree. */
|
|
72
|
+
export function sealedEnv(T, { snapshot, platform = process.platform, base = process.env, gitDir = T, index = true } = {}) {
|
|
73
|
+
const env = {};
|
|
74
|
+
const p = fromEnv(base, "PATH");
|
|
75
|
+
if (p !== undefined) env.PATH = p;
|
|
76
|
+
if (platform === "win32") {
|
|
77
|
+
for (const name of WINDOWS_ENV) { const v = fromEnv(base, name); if (v !== undefined) env[name] = v; }
|
|
78
|
+
env.TEMP = T; env.TMP = T;
|
|
79
|
+
}
|
|
80
|
+
env.HOME = T; env.XDG_CONFIG_HOME = T; env.LC_ALL = "C";
|
|
81
|
+
env.GIT_CONFIG_GLOBAL = path.join(T, "empty");
|
|
82
|
+
Object.assign(env, SEALED_ENV_FIXED);
|
|
83
|
+
if (index) env.GIT_INDEX_FILE = path.join(T, "index");
|
|
84
|
+
if (gitDir) env.GIT_DIR = gitDir;
|
|
85
|
+
if (snapshot) env.GIT_WORK_TREE = snapshot;
|
|
86
|
+
return env;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Creates T. `commonDir` (the checkout's git common directory) wires the object database; `objectFormat` is "sha1" or "sha256". */
|
|
90
|
+
export function createSealedGitDir({ commonDir, objectFormat = "sha1" } = {}) {
|
|
91
|
+
const T = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "rk-sealed-git-")));
|
|
92
|
+
try {
|
|
93
|
+
for (const d of ["refs", path.join("objects", "info"), "info", "hooks"]) fs.mkdirSync(path.join(T, d), { recursive: true });
|
|
94
|
+
fs.writeFileSync(path.join(T, "HEAD"), SEALED_GIT.layout.head);
|
|
95
|
+
fs.writeFileSync(path.join(T, "config"), `[core]\n\trepositoryformatversion = ${objectFormat === "sha256" ? 1 : 0}\n\tbare = false\n${objectFormat === "sha256" ? "[extensions]\n\tobjectFormat = sha256\n" : ""}`);
|
|
96
|
+
fs.writeFileSync(path.join(T, "info", "attributes"), SEALED_ATTRIBUTES);
|
|
97
|
+
fs.writeFileSync(path.join(T, "info", "exclude"), "");
|
|
98
|
+
fs.writeFileSync(path.join(T, "empty"), "");
|
|
99
|
+
if (commonDir) fs.writeFileSync(path.join(T, "objects", "info", "alternates"), `${commonDir}/objects\n`);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
fs.rmSync(T, { recursive: true, force: true });
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
return T;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const why = (r, args) => `git ${args.filter((a) => a !== "-c" && !/^(core|index)\./.test(a)).join(" ")}: ${r.error ? r.error.message : String(r.stderr ?? "").trim() || `exit ${r.status}`}`;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Runs `fn(git)` with a fresh T; `git.run(args, { input, binary, cwd })` returns the spawn result, `git.ok(...)` its stdout or throws an
|
|
111
|
+
* Error naming the command. The working directory is `snapshot` when given, else T. T is removed afterwards.
|
|
112
|
+
*/
|
|
113
|
+
export function withSealedGit({ commonDir, objectFormat, snapshot, platform = process.platform } = {}, fn) {
|
|
114
|
+
const T = createSealedGitDir({ commonDir, objectFormat });
|
|
115
|
+
const env = sealedEnv(T, { snapshot, platform });
|
|
116
|
+
const prefix = sealedConfigArgs(T, platform);
|
|
117
|
+
const run = (args, { input, binary = false, cwd } = {}) => spawnSync("git", [...prefix, ...args], { cwd: cwd ?? snapshot ?? T, env, input, maxBuffer: MAX_BUFFER, ...(binary ? {} : { encoding: "utf8" }) });
|
|
118
|
+
const ok = (args, options) => { const r = run(args, options); if (r.error || r.status !== 0) throw new Error(why(r, args)); return r.stdout; };
|
|
119
|
+
try { return fn({ run, ok, T, env }); } finally { fs.rmSync(T, { recursive: true, force: true }); }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- discovery: finding a repository and resolving a revision NAME, the only reads that need the checkout's own git directory -------------
|
|
123
|
+
|
|
124
|
+
let home = null;
|
|
125
|
+
/** One empty home directory per process for discovery runs, removed at exit. */
|
|
126
|
+
function discoveryHome() {
|
|
127
|
+
if (home && fs.existsSync(home)) return home;
|
|
128
|
+
home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "rk-git-home-")));
|
|
129
|
+
fs.writeFileSync(path.join(home, "empty"), "");
|
|
130
|
+
const mine = home;
|
|
131
|
+
process.once("exit", () => { try { fs.rmSync(mine, { recursive: true, force: true }); } catch { /* best effort */ } });
|
|
132
|
+
return home;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** git run in `cwd` against the checkout's own repository (found from `cwd`), under the sealed environment (no GIT_DIR, no index) plus GIT_NO_REPLACE_OBJECTS. Returns the spawn result. */
|
|
136
|
+
export function discoveryRun(cwd, args, { platform = process.platform, input } = {}) {
|
|
137
|
+
const env = sealedEnv(discoveryHome(), { platform, gitDir: null, index: false });
|
|
138
|
+
return spawnSync("git", args, { cwd, env, input, encoding: "utf8", maxBuffer: MAX_BUFFER });
|
|
139
|
+
}
|
|
140
|
+
/** As discoveryRun, returning trimmed-by-caller stdout or throwing an Error naming the command. */
|
|
141
|
+
export function discoveryGit(cwd, args, options) {
|
|
142
|
+
const r = discoveryRun(cwd, args, options);
|
|
143
|
+
if (r.error || r.status !== 0) throw new Error(why(r, args));
|
|
144
|
+
return r.stdout;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const infoCache = new Map();
|
|
148
|
+
/** `{ commonDir, objectFormat }` of the repository `checkout` belongs to, found by discovery and cached per canonical path. */
|
|
149
|
+
export function sealedRepoInfo(checkout) {
|
|
150
|
+
let key;
|
|
151
|
+
try { key = fs.realpathSync.native(checkout); } catch { key = path.resolve(checkout); }
|
|
152
|
+
const cached = infoCache.get(key);
|
|
153
|
+
if (cached && fs.existsSync(path.join(cached.commonDir, "objects"))) return cached;
|
|
154
|
+
let common;
|
|
155
|
+
try { common = discoveryGit(key, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).trim(); }
|
|
156
|
+
catch { common = path.resolve(key, discoveryGit(key, ["rev-parse", "--git-common-dir"]).trim()); }
|
|
157
|
+
const info = { commonDir: common, objectFormat: discoveryGit(key, ["rev-parse", "--show-object-format"]).trim() || "sha1" };
|
|
158
|
+
infoCache.set(key, info);
|
|
159
|
+
return info;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A single sealed git command against `checkout`'s object database (T is made and removed around it). Returns the spawn result. */
|
|
163
|
+
export function sealedRun(checkout, args, options = {}) {
|
|
164
|
+
const info = options.repo ?? sealedRepoInfo(checkout);
|
|
165
|
+
return withSealedGit({ ...info, snapshot: options.snapshot }, (git) => git.run(args, options));
|
|
166
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// The gate's half of a broker snapshot build (joint plan step 4d; GATE-CONTRACT "Broker interface"; the reader is RustRight's
|
|
2
|
+
// rustright-cli shims.rs `snapshot_carrier`, the verifier rustright-protocol attestation.rs `verify_receipt`).
|
|
3
|
+
//
|
|
4
|
+
// For each `rustright cargo` request that builds the commit snapshot on a broker host the gate:
|
|
5
|
+
// 1. picks a fresh nonce (RIGHTKIT_ATTEST_NONCE: an attested request; the carrier must carry the same nonce);
|
|
6
|
+
// 2. writes the carrier `rightkit.snapshot-request/1` as an owner-only file under `<control root>/snapshot-requests/` (the
|
|
7
|
+
// directory the client accepts, never a link) and points RIGHTKIT_SNAPSHOT_REQUEST at it as `{"path","sha256"}` (at most
|
|
8
|
+
// 4096 bytes; the file at most 4 MiB). The carrier binds the commit and tree, the snapshot and its expected tree, the
|
|
9
|
+
// declared inputs, the build-ownership handle, the cargo working directory (primaryCwd) and sha256 of the canonical cargo
|
|
10
|
+
// argv (without `cargo`), so it cannot be replayed onto another request;
|
|
11
|
+
// 3. after the run, verifies the signed receipt: the provenance signature against this host's enrolled pins (never from the
|
|
12
|
+
// receipt), the job id, the nonce, the signed result (SUCCEEDED, exit 0), the request digest it recomputes over the EXTENDED core (the base core plus commit,
|
|
13
|
+
// snapshot, declaredInputs and ownership), and the signed snapshotEvidence: the receipt's copies equal the signed ones,
|
|
14
|
+
// the commit and handle are the carrier's, and the snapshot held the expected tree at START and at exit.
|
|
15
|
+
// The carrier is set only on that one cargo invocation and is removed after it; nothing the gate exports carries it.
|
|
16
|
+
|
|
17
|
+
import crypto from "node:crypto";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { canonicalJson, ProvenanceError, verifyProvenance } from "./provenance-verify.mjs";
|
|
21
|
+
|
|
22
|
+
export const SNAPSHOT_REQUEST_SCHEMA = "rightkit.snapshot-request/1";
|
|
23
|
+
export const SNAPSHOT_REQUEST_ENV = "RIGHTKIT_SNAPSHOT_REQUEST";
|
|
24
|
+
export const ATTEST_NONCE_ENV = "RIGHTKIT_ATTEST_NONCE";
|
|
25
|
+
export const REQUEST_ID_FILE_ENV = "RIGHTKIT_REQUEST_ID_FILE";
|
|
26
|
+
export const REQUEST_REF_SCHEMA = "rustright.request-ref.v1";
|
|
27
|
+
export const DESCRIPTOR_MAX_BYTES = 4096;
|
|
28
|
+
export const CARRIER_MAX_BYTES = 4 * 1024 * 1024;
|
|
29
|
+
/** rustright-cli shims.rs MANAGED_RUST_ENVIRONMENT_KEYS: what the client records as `observedEnv` for a cargo request. */
|
|
30
|
+
export const MANAGED_RUST_ENVIRONMENT_KEYS = Object.freeze([
|
|
31
|
+
"CARGO_HOME", "CARGO_TARGET_DIR", "CARGO_BUILD_TARGET", "CARGO_BUILD_TARGET_DIR", "CARGO_BUILD_BUILD_DIR", "CARGO_BUILD_RUSTC",
|
|
32
|
+
"CARGO_BUILD_RUSTDOC", "CARGO_BUILD_RUSTC_WRAPPER", "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", "CARGO_BUILD_RUSTFLAGS",
|
|
33
|
+
"CARGO_ENCODED_RUSTFLAGS", "CARGO_INSTALL_ROOT", "RUSTUP_HOME", "RUSTC", "RUSTDOC", "RUSTFLAGS", "RUSTDOCFLAGS", "RUSTC_WRAPPER",
|
|
34
|
+
"RUSTC_WORKSPACE_WRAPPER",
|
|
35
|
+
]);
|
|
36
|
+
/** The fields the broker copies onto the receipt and signs in payload.snapshotEvidence (attestation.rs). */
|
|
37
|
+
export const SNAPSHOT_EVIDENCE_FIELDS = Object.freeze(["requestId", "brokerVersion", "executor", "commit", "snapshot", "declaredInputDigests", "ownership", "outputDir"]);
|
|
38
|
+
/** The stage evidence the broker signs in payload.stageEvidence and copies onto the receipt (attestation.rs STAGE_EVIDENCE_FIELDS). */
|
|
39
|
+
export const STAGE_EVIDENCE_FIELDS = Object.freeze(["freshness", "attribution", "toolchain"]);
|
|
40
|
+
const SIGNED_TOP_LEVEL_FIELDS = Object.freeze(["verdict", "refreshed", "exit", "signal", "timingsMs", "diagnosticsPath"]);
|
|
41
|
+
const TIMING_KEYS =Object.freeze(["admission", "queue", "bind", "cargo", "afterExitBind", "diagnostics"]);
|
|
42
|
+
/** `<toolchain>|cargo:<sha256>|rustc:<sha256>` (rustright toolchain_identity). */
|
|
43
|
+
const TOOLCHAIN_FORMAT = /^[^|\s][^|]*\|cargo:[0-9a-f]{64}\|rustc:[0-9a-f]{64}$/;
|
|
44
|
+
|
|
45
|
+
/** The compile-class cargo commands (and their aliases): the ones whose receipt carries attribution and toolchain. */
|
|
46
|
+
export const COMPILE_CLASS_COMMANDS = Object.freeze(["check", "clippy", "build", "doc", "rustc", "test", "b", "c", "t", "d"]);
|
|
47
|
+
const GLOBAL_VALUE_OPTIONS = new Set(["--color", "--explain"]);
|
|
48
|
+
|
|
49
|
+
/** The cargo subcommand of an argv (without `cargo`): after an optional `+toolchain` and the global options, before `--`; null when there is none. */
|
|
50
|
+
export function cargoSubcommandOf(argv) {
|
|
51
|
+
const words = [...argv];
|
|
52
|
+
for (let i = words[0]?.startsWith("+") ? 1 : 0; i < words.length; i += 1) {
|
|
53
|
+
const w = words[i];
|
|
54
|
+
if (w === "--") return null;
|
|
55
|
+
if (GLOBAL_VALUE_OPTIONS.has(w)) { i += 1; continue; }
|
|
56
|
+
if (w.startsWith("-")) continue;
|
|
57
|
+
return w;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
export const isCompileClass = (argv) => COMPILE_CLASS_COMMANDS.includes(cargoSubcommandOf(argv));
|
|
62
|
+
|
|
63
|
+
/** Where the gate's declared compile inputs are listed, as read from the snapshot (GATE-CONTRACT "Request"; the broker's COMPILE_INPUTS). */
|
|
64
|
+
export const COMPILE_INPUTS = ".rightkit/compile-inputs.json";
|
|
65
|
+
export const MAX_DECLARATION_BYTES = 1024 * 1024;
|
|
66
|
+
const MAX_INPUT_FILES = 200_000;
|
|
67
|
+
const MAX_INPUT_BYTES = 4 * 1024 * 1024 * 1024;
|
|
68
|
+
|
|
69
|
+
export class SnapshotRequestError extends Error {
|
|
70
|
+
constructor(code, message) { super(message); this.name = "SnapshotRequestError"; this.code = code; }
|
|
71
|
+
}
|
|
72
|
+
const fail = (code, message) => { throw new SnapshotRequestError(code, message); };
|
|
73
|
+
const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
|
|
74
|
+
const objectId = (s) => typeof s === "string" && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(s);
|
|
75
|
+
|
|
76
|
+
/** A fresh attestation nonce: 64 lowercase hex characters. */
|
|
77
|
+
export const freshNonce = () => crypto.randomBytes(32).toString("hex");
|
|
78
|
+
|
|
79
|
+
/** sha256 of the canonical JSON of the cargo argv (without `cargo`), as the client computes it. */
|
|
80
|
+
export const argvSha256 = (argv) => sha256(canonicalJson([...argv]));
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The managed Rust variables the client will report as observedEnv, from the environment the gate runs it with. RustRight's
|
|
84
|
+
* capture_managed_rust_environment (shims.rs) matches the name case-insensitively on every platform and keeps the key exactly as
|
|
85
|
+
* spelled (`Cargo_Home` stays `Cargo_Home`), so the digest binds that spelling. On Windows the child sees only one of several
|
|
86
|
+
* names that differ by case: Node passes the first in sorted order (child_process "env" on Windows), so the rest are not observed.
|
|
87
|
+
*/
|
|
88
|
+
export function observedEnvOf(env, platform = process.platform) {
|
|
89
|
+
const out = {};
|
|
90
|
+
const entries = Object.entries(env ?? {});
|
|
91
|
+
if (platform === "win32") entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
for (const [key, value] of entries) {
|
|
94
|
+
const upper = key.toUpperCase();
|
|
95
|
+
if (platform === "win32") { if (seen.has(upper)) continue; seen.add(upper); }
|
|
96
|
+
if (MANAGED_RUST_ENVIRONMENT_KEYS.includes(upper) && typeof value === "string") out[key] = value;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Writes the carrier for one cargo request and returns `{ file, sha256, descriptor, carrier, env }`; `env` is what that one
|
|
103
|
+
* invocation adds (carrier descriptor, nonce, request-id file). Refuses before writing anything the reader would refuse.
|
|
104
|
+
*/
|
|
105
|
+
export function writeSnapshotRequest({ controlRoot, commit, snapshot, declaredInputs = [], handle, primaryCwd, argv, nonce = freshNonce(), requestIdFile, uuid = crypto.randomUUID }) {
|
|
106
|
+
if (!objectId(commit?.sha) || !objectId(commit?.tree)) fail("invalid", "commit sha and tree must be full lowercase object ids");
|
|
107
|
+
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
|
+
if (typeof handle !== "string" || !handle.trim()) fail("invalid", "the build-ownership handle is missing");
|
|
109
|
+
if (!primaryCwd || !path.isAbsolute(primaryCwd)) fail("invalid", "primaryCwd must be the cargo request's absolute working directory");
|
|
110
|
+
if (!Array.isArray(argv) || !argv.every((a) => typeof a === "string")) fail("invalid", "argv must be the cargo arguments (without cargo)");
|
|
111
|
+
if (typeof nonce !== "string" || !/^[0-9a-f]{64,128}$/.test(nonce)) fail("invalid", "the nonce must be 64 to 128 lowercase hex characters");
|
|
112
|
+
if (!Array.isArray(declaredInputs) || declaredInputs.some((p) => typeof p !== "string" || !p || path.isAbsolute(p) || path.posix.isAbsolute(p))) fail("invalid", "declared inputs must be relative paths");
|
|
113
|
+
if (!controlRoot || !path.isAbsolute(controlRoot)) fail("invalid", "the control root must be absolute");
|
|
114
|
+
const dir = path.join(controlRoot, "snapshot-requests");
|
|
115
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
116
|
+
const meta = fs.lstatSync(dir);
|
|
117
|
+
if (meta.isSymbolicLink() || !meta.isDirectory()) fail("invalid", `${dir} is not a real directory`);
|
|
118
|
+
const carrier = {
|
|
119
|
+
schema: SNAPSHOT_REQUEST_SCHEMA,
|
|
120
|
+
commit: { sha: commit.sha, tree: commit.tree },
|
|
121
|
+
snapshot: { path: snapshot.path, expectedTree: snapshot.expectedTree },
|
|
122
|
+
declaredInputs: { inputs: [...declaredInputs] },
|
|
123
|
+
ownership: { handle },
|
|
124
|
+
primaryCwd,
|
|
125
|
+
argvSha256: argvSha256(argv),
|
|
126
|
+
nonce,
|
|
127
|
+
};
|
|
128
|
+
const bytes = Buffer.from(JSON.stringify(carrier), "utf8");
|
|
129
|
+
if (bytes.length > CARRIER_MAX_BYTES) fail("too-large", `the snapshot request is ${bytes.length} bytes, more than ${CARRIER_MAX_BYTES}`);
|
|
130
|
+
const file = path.join(dir, `${uuid()}.json`);
|
|
131
|
+
fs.writeFileSync(file, bytes, { flag: "wx", mode: 0o600 });
|
|
132
|
+
const digest = sha256(bytes);
|
|
133
|
+
const descriptor = JSON.stringify({ path: file, sha256: digest });
|
|
134
|
+
if (Buffer.byteLength(descriptor) > DESCRIPTOR_MAX_BYTES) { fs.rmSync(file, { force: true }); fail("too-large", "the snapshot request descriptor exceeds 4096 bytes"); }
|
|
135
|
+
const env = { [SNAPSHOT_REQUEST_ENV]: descriptor, [ATTEST_NONCE_ENV]: nonce, ...(requestIdFile ? { [REQUEST_ID_FILE_ENV]: requestIdFile } : {}) };
|
|
136
|
+
return { file, sha256: digest, descriptor, carrier, env };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The request core the broker digests for a snapshot cargo request: the base core plus commit, snapshot, declaredInputs and ownership. */
|
|
140
|
+
export function snapshotRequestCore({ argv, workspace, observedEnv = {}, carrier }) {
|
|
141
|
+
return {
|
|
142
|
+
adapter: null, env: {}, inputManifest: null, nonce: carrier.nonce, observedEnv, outputs: [], requestedArgv: [...argv], shim: "cargo", workspace,
|
|
143
|
+
commit: carrier.commit, snapshot: carrier.snapshot, declaredInputs: carrier.declaredInputs, ownership: carrier.ownership,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
export const requestDigestOf = (core) => sha256(canonicalJson(core));
|
|
147
|
+
|
|
148
|
+
/** The request reference the client writes at submit (RIGHTKIT_REQUEST_ID_FILE), or a throw. */
|
|
149
|
+
export function readRequestRef(file) {
|
|
150
|
+
let ref;
|
|
151
|
+
try { ref = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { fail("request", `no request reference at ${file} (${e.message})`); }
|
|
152
|
+
if (ref?.schema !== REQUEST_REF_SCHEMA || typeof ref.requestId !== "string" || !ref.requestId || typeof ref.workspace !== "string" || !path.isAbsolute(ref.workspace)) fail("request", `${file} is not a ${REQUEST_REF_SCHEMA}`);
|
|
153
|
+
return ref;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const same = (a, b) => canonicalJson(a ?? null) === canonicalJson(b ?? null);
|
|
157
|
+
|
|
158
|
+
// ---- declared compile inputs, read and digested the way the broker does (check_admission.rs declared_inputs / snapshot_inputs) ----
|
|
159
|
+
|
|
160
|
+
const lstatOrNull = (p) => { try { return fs.lstatSync(p); } catch (e) { if (e.code === "ENOENT") return null; throw e; } };
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The declared inputs of a snapshot: `inputs` of `.rightkit/compile-inputs.json` read FROM THE SNAPSHOT, never the checkout. No
|
|
164
|
+
* manifest means an empty set; a link on its path, a file that is not regular, an oversized or malformed manifest, or an input that
|
|
165
|
+
* is not a plain relative path (absolute, `.`/`..`, inside `.git`, the manifest itself) is refused before any request is made.
|
|
166
|
+
*/
|
|
167
|
+
export function readDeclaredInputs(snapshotRoot) {
|
|
168
|
+
const parts = COMPILE_INPUTS.split("/");
|
|
169
|
+
let at = snapshotRoot; let meta = null;
|
|
170
|
+
for (const part of parts) {
|
|
171
|
+
at = path.join(at, part);
|
|
172
|
+
meta = lstatOrNull(at);
|
|
173
|
+
if (meta === null) return [];
|
|
174
|
+
if (meta.isSymbolicLink()) fail("declared-inputs", `${COMPILE_INPUTS} runs through a link at ${at}`);
|
|
175
|
+
}
|
|
176
|
+
if (!meta.isFile()) fail("declared-inputs", `${COMPILE_INPUTS} is not a regular file`);
|
|
177
|
+
if (meta.size > MAX_DECLARATION_BYTES) fail("declared-inputs", `${COMPILE_INPUTS} is larger than ${MAX_DECLARATION_BYTES} bytes`);
|
|
178
|
+
let value;
|
|
179
|
+
try { value = JSON.parse(fs.readFileSync(at, "utf8")); } catch (e) { fail("declared-inputs", `${COMPILE_INPUTS} is not JSON (${e.message})`); }
|
|
180
|
+
if (!value || typeof value !== "object" || !Array.isArray(value.inputs)) fail("declared-inputs", `${COMPILE_INPUTS}: \`inputs\` must be a list of paths`);
|
|
181
|
+
for (const input of value.inputs) {
|
|
182
|
+
if (typeof input !== "string" || !input) fail("declared-inputs", `${COMPILE_INPUTS}: every input is a non-empty relative path`);
|
|
183
|
+
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", `${COMPILE_INPUTS}: ${input} is not a plain relative path inside the snapshot`);
|
|
185
|
+
if (components.some((c) => c.toLowerCase() === ".git")) fail("declared-inputs", `${COMPILE_INPUTS}: ${input} is inside .git; repository metadata is never a compile input`);
|
|
186
|
+
if (components.length <= parts.length && components.every((c, i) => c === parts[i])) fail("declared-inputs", `${COMPILE_INPUTS}: ${input} overlaps the declaration file itself`);
|
|
187
|
+
}
|
|
188
|
+
return [...value.inputs];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The `declaredInputDigests` the broker records for these inputs, computed here from the snapshot: per input, sha256 over, for each
|
|
193
|
+
* 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. Child names are joined with the platform's separator, as the broker's
|
|
195
|
+
* `Path::join` does.
|
|
196
|
+
*/
|
|
197
|
+
export function declaredInputDigestsOf(snapshotRoot, inputs, { platform = process.platform } = {}) {
|
|
198
|
+
const sep = platform === "win32" ? "\\" : "/";
|
|
199
|
+
let files = 0; let bytes = 0;
|
|
200
|
+
const walk = (full, name, hasher) => {
|
|
201
|
+
const meta = fs.lstatSync(full);
|
|
202
|
+
if (meta.isSymbolicLink()) fail("declared-inputs", `declared compile input holds a link at ${name}`);
|
|
203
|
+
if (meta.isDirectory()) {
|
|
204
|
+
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), name.endsWith("/") || name.endsWith("\\") ? `${name}${child}` : `${name}${sep}${child}`, hasher);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (!meta.isFile()) fail("declared-inputs", `declared compile input ${name} is not a regular file or directory`);
|
|
209
|
+
files += 1;
|
|
210
|
+
if (files > MAX_INPUT_FILES) fail("declared-inputs", `the declared compile inputs exceed ${MAX_INPUT_FILES} files`);
|
|
211
|
+
hasher.update(name); hasher.update(Buffer.from([0]));
|
|
212
|
+
const fd = fs.openSync(full, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
213
|
+
try {
|
|
214
|
+
const after = fs.fstatSync(fd);
|
|
215
|
+
if (!after.isFile() || (process.platform !== "win32" && (after.dev !== meta.dev || after.ino !== meta.ino))) fail("declared-inputs", `${name} changed while it was read`);
|
|
216
|
+
const chunk = Buffer.allocUnsafe(64 * 1024);
|
|
217
|
+
for (;;) {
|
|
218
|
+
const n = fs.readSync(fd, chunk, 0, chunk.length, null);
|
|
219
|
+
if (n === 0) break;
|
|
220
|
+
bytes += n;
|
|
221
|
+
if (bytes > MAX_INPUT_BYTES) fail("declared-inputs", `the declared compile inputs exceed ${MAX_INPUT_BYTES} bytes`);
|
|
222
|
+
hasher.update(chunk.subarray(0, n));
|
|
223
|
+
}
|
|
224
|
+
} finally { fs.closeSync(fd); }
|
|
225
|
+
hasher.update(Buffer.from([0]));
|
|
226
|
+
};
|
|
227
|
+
return inputs.map((input) => {
|
|
228
|
+
// Every component of the declared path is walked without following a link.
|
|
229
|
+
let at = snapshotRoot;
|
|
230
|
+
for (const part of input.split(/[\\/]/)) {
|
|
231
|
+
at = path.join(at, part);
|
|
232
|
+
let meta;
|
|
233
|
+
try { meta = fs.lstatSync(at); } catch (e) { fail("declared-inputs", `${input}: ${e.message}`); }
|
|
234
|
+
if (meta.isSymbolicLink()) fail("declared-inputs", `${input} runs through a link at ${at}`);
|
|
235
|
+
}
|
|
236
|
+
const hasher = crypto.createHash("sha256");
|
|
237
|
+
walk(at, input, hasher);
|
|
238
|
+
return { path: input, sha256: hasher.digest("hex") };
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---- the receipt ----
|
|
243
|
+
|
|
244
|
+
const inside = (child, parent, platform) => {
|
|
245
|
+
const norm = (p) => (platform === "win32" ? path.win32.resolve(p).toLowerCase() : path.resolve(p));
|
|
246
|
+
const rel = (platform === "win32" ? path.win32 : path).relative(norm(parent), norm(child));
|
|
247
|
+
return rel === "" || !(rel === ".." || /^\.\.[\\/]/.test(rel) || path.isAbsolute(rel) || path.win32.isAbsolute(rel));
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The generation target the broker pins an app's outputs to: `<root>/<12 hex>/g/<NNN>/target` under one of the managed `ws` roots
|
|
252
|
+
* (`<external data root>/rightkit-build-control/ws`; BROKER-CONTRACT "Source identity", output pinning). The 12-hex key is
|
|
253
|
+
* rustright-state's workspace_key, which the gate never computes (the snapshot's own key is `snapshots/<sha256(repo path)[:16]>`, a
|
|
254
|
+
* different thing); it reads the receipt's outputDir and checks only this shape. The generation number (3+ digits) is the broker's.
|
|
255
|
+
*/
|
|
256
|
+
export function isGenerationTarget(outputDir, outputRoots, platform = process.platform) {
|
|
257
|
+
const mod = platform === "win32" ? path.win32 : path;
|
|
258
|
+
for (const root of outputRoots) {
|
|
259
|
+
const rel = mod.relative(platform === "win32" ? mod.resolve(root).toLowerCase() : mod.resolve(root), platform === "win32" ? mod.resolve(outputDir).toLowerCase() : mod.resolve(outputDir));
|
|
260
|
+
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] === "target") return true;
|
|
262
|
+
}
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Verifies the receipt of one snapshot cargo request. `pins` come from this host's enrollment, never from the receipt.
|
|
270
|
+
* Returns `{ payload, evidence }`; throws ProvenanceError (legacy, signature, pins, job, nonce, request) or SnapshotRequestError (evidence).
|
|
271
|
+
*
|
|
272
|
+
* Beyond the signature, the job, the nonce and the request digest, the receipt must carry (item 7 row 7.22):
|
|
273
|
+
* - signed SUCCEEDED with exit 0, and signed snapshotEvidence equal to the receipt's copies (commit, ownership handle, request id,
|
|
274
|
+
* snapshot path, `treeAtStart` = `treeAtExit` = the expected tree);
|
|
275
|
+
* - `declaredInputDigests` equal to the gate's own digest of the snapshot's declared inputs, read from the snapshot now;
|
|
276
|
+
* - `outputDir` absolute, outside the snapshot and the checkout, and a managed generation target under one of `outputRoots`;
|
|
277
|
+
* - signed stageEvidence (freshness, attribution, toolchain) equal to the receipt's;
|
|
278
|
+
* - verdict, timingsMs, exit, diagnosticsPath, brokerVersion, executor and refreshed present with their types.
|
|
279
|
+
* `checkout` and `outputRoots` are required: without them nothing can be said about where the outputs went, so it refuses.
|
|
280
|
+
*/
|
|
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 }));
|
|
283
|
+
const payload = verifyProvenance(receipt, pins, { jobId, nonce: carrier.nonce, requestDigest });
|
|
284
|
+
// 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
|
+
// signal (service.rs provenance_payload `result`). An authentic FAILED, or a payload without a result, is not a success.
|
|
286
|
+
const result = payload.result;
|
|
287
|
+
if (!result || typeof result !== "object") fail("result", "the signed payload carries no result: the broker did not sign this request's outcome");
|
|
288
|
+
if (result.status !== "SUCCEEDED" || result.exitCode !== 0 || (result.signal !== undefined && result.signal !== null)) {
|
|
289
|
+
fail("result", `the broker signed ${result.status ?? "no status"} (exit ${result.exitCode ?? "none"}${result.signal ? `, signal ${result.signal}` : ""}), not SUCCEEDED with exit 0`);
|
|
290
|
+
}
|
|
291
|
+
const evidence = payload.snapshotEvidence;
|
|
292
|
+
if (!evidence || typeof evidence !== "object") fail("evidence", "the signed payload carries no snapshotEvidence: the broker did not build this as a snapshot request");
|
|
293
|
+
for (const name of SNAPSHOT_EVIDENCE_FIELDS) {
|
|
294
|
+
if (evidence[name] === undefined || !same(receipt[name], evidence[name])) fail("evidence", `receipt ${name} differs from the signed snapshot evidence`);
|
|
295
|
+
}
|
|
296
|
+
if (!same(evidence.commit, carrier.commit)) fail("evidence", "the signed commit is not the one this request carried");
|
|
297
|
+
if (evidence.ownership?.handle !== carrier.ownership.handle) fail("evidence", "the signed ownership handle is not this gate's");
|
|
298
|
+
if (evidence.requestId !== jobId) fail("evidence", "the signed request id is not this job");
|
|
299
|
+
const snap = evidence.snapshot ?? {};
|
|
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
|
+
}
|
|
306
|
+
// Declared inputs: what the broker digested in place must be what the gate digests in the snapshot, from the same manifest.
|
|
307
|
+
let gateInputs; let gateDigests;
|
|
308
|
+
try {
|
|
309
|
+
gateInputs = readInputs(carrier.snapshot.path);
|
|
310
|
+
gateDigests = digestInputs(carrier.snapshot.path, gateInputs, { platform });
|
|
311
|
+
} 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");
|
|
314
|
+
// Output directory: where the outputs went must be the broker's managed generation target, never under the snapshot or the checkout.
|
|
315
|
+
const out = evidence.outputDir;
|
|
316
|
+
if (typeof out !== "string" || !path.isAbsolute(out) && !path.win32.isAbsolute(out)) fail("evidence", "the signed evidence names no absolute output directory");
|
|
317
|
+
if (!checkout || !path.isAbsolute(checkout) || !Array.isArray(outputRoots) || outputRoots.length === 0) fail("evidence", "the gate has no checkout or managed output root to judge the output directory against");
|
|
318
|
+
const canon = (p) => { try { return canonicalPath(p); } catch { return p; } };
|
|
319
|
+
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
|
+
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` is required for every carried command;
|
|
323
|
+
// `attribution` and `toolchain` only for a compile-class command (check, clippy, build, doc, rustc, test: the broker emits them
|
|
324
|
+
// for compiles only), so metadata, fetch --locked, fmt --check or tree --locked pass without them. When either side carries one
|
|
325
|
+
// anyway, both must carry the same value.
|
|
326
|
+
const stage = payload.stageEvidence;
|
|
327
|
+
if (!isObject(stage)) fail("evidence", "the signed payload carries no stageEvidence");
|
|
328
|
+
const compile = isCompileClass(argv);
|
|
329
|
+
for (const name of STAGE_EVIDENCE_FIELDS) {
|
|
330
|
+
const signed = stage[name] ?? null; const copy = receipt[name] ?? null;
|
|
331
|
+
const required = name === "freshness" || compile;
|
|
332
|
+
if (required && (signed === null || copy === null)) fail("evidence", `${name} is missing from the signed stage evidence or the receipt${name === "freshness" ? "" : ` (a compile-class command, cargo ${cargoSubcommandOf(argv)}, carries it)`}`);
|
|
333
|
+
if (!same(copy, signed)) fail("evidence", `receipt ${name} differs from the signed stage evidence`);
|
|
334
|
+
}
|
|
335
|
+
// Shapes RustRight emits (item 4 row 4.18): attribution is `{available, ...}`, and an unavailable one names its reason
|
|
336
|
+
// (`cargo-json-message-format-not-requested` without a JSON message stream; a consumer that needs per-package attribution treats
|
|
337
|
+
// `available:false` as FAILED, not partial); toolchain is `<toolchain>|cargo:<sha256>|rustc:<sha256>`.
|
|
338
|
+
const attribution = receipt.attribution ?? null; const toolchain = receipt.toolchain ?? null;
|
|
339
|
+
if (attribution !== null) {
|
|
340
|
+
if (!isObject(attribution) || typeof attribution.available !== "boolean") fail("evidence", "receipt attribution is not {available, ...}");
|
|
341
|
+
if (attribution.available === false && !(typeof attribution.reason === "string" && attribution.reason !== "")) fail("evidence", "receipt attribution is unavailable and names no reason");
|
|
342
|
+
}
|
|
343
|
+
if (toolchain !== null && !(typeof toolchain === "string" && TOOLCHAIN_FORMAT.test(toolchain))) fail("evidence", "receipt toolchain is not <toolchain>|cargo:<sha256>|rustc:<sha256>");
|
|
344
|
+
// Fields the signature does not cover must still be present with their types (the consumer reads them). Every carried command:
|
|
345
|
+
// verdict, refreshed, exit (an integer, or null with a signal) and signal, timingsMs{admission, queue, bind, cargo, afterExitBind,
|
|
346
|
+
// diagnostics} (each a number or null) and diagnosticsPath (a path, or null). Success needs exit 0 and no signal, as signed.
|
|
347
|
+
const timingsOk = isObject(receipt.timingsMs) && TIMING_KEYS.every((k) => Object.hasOwn(receipt.timingsMs, k) && (receipt.timingsMs[k] === null || Number.isFinite(receipt.timingsMs[k])));
|
|
348
|
+
const typed = [
|
|
349
|
+
["verdict", isObject(receipt.verdict)], ["timingsMs", timingsOk], ["exit", receipt.exit === null || Number.isSafeInteger(receipt.exit)],
|
|
350
|
+
["signal", receipt.signal === null || typeof receipt.signal === "string" || Number.isSafeInteger(receipt.signal)],
|
|
351
|
+
["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 !== ""], ["refreshed", Array.isArray(receipt.refreshed)],
|
|
353
|
+
];
|
|
354
|
+
for (const [name, ok] of typed) if (!ok) fail("evidence", `receipt ${name} is missing or has the wrong type`);
|
|
355
|
+
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 these top-level fields in the payload (item 4 row 4.18): when the signed payload carries one, the receipt's copy must equal it.
|
|
357
|
+
for (const name of SIGNED_TOP_LEVEL_FIELDS) {
|
|
358
|
+
if (payload[name] !== undefined && !same(receipt[name], payload[name])) fail("evidence", `receipt ${name} differs from the signed payload`);
|
|
359
|
+
}
|
|
360
|
+
return { payload, evidence, attribution, toolchain };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export { ProvenanceError };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The gate's per-user host configuration file (docs/GATE-CONTRACT.md §1 "Broker interface" Snapshot root: "a durable root named in
|
|
2
|
+
// gate configuration"; item 5 row 5.16). It is the ONE place a Linux host names its snapshot root.
|
|
3
|
+
//
|
|
4
|
+
// File: <home>/.config/rightkit/gate.json (home = the account's passwd home directory)
|
|
5
|
+
// Content: { "schema": "rightkit.gate-host-config/1", "snapshotRoot": "/absolute/normalised/durable/path" }
|
|
6
|
+
//
|
|
7
|
+
// Why a file and not a variable or a flag: two concurrent gate runs on one host could otherwise choose two roots for one repository,
|
|
8
|
+
// and "one snapshot per repository per host" (GC §1) would be false. So nothing in the process environment or on a command line
|
|
9
|
+
// selects the root, and nothing here reads one: not RIGHTKIT_SNAPSHOT_ROOT (removed as a source), not XDG_CONFIG_HOME, not HOME.
|
|
10
|
+
// The file's own location comes from the account database (`os.userInfo().homedir`), the one home directory an environment
|
|
11
|
+
// variable cannot move. The contract's named default (`$XDG_STATE_HOME/rightkit/snapshots`, else `~/.local/state/rightkit/snapshots`)
|
|
12
|
+
// applies only when no file exists.
|
|
13
|
+
//
|
|
14
|
+
// The file is outside every repository, snapshot and scratch directory by construction (a dot directory of the account's home).
|
|
15
|
+
// It must be what a persistent per-user setting is, or it is refused (fail closed, never ignored, because an ignored file would
|
|
16
|
+
// silently pick the default root): a regular file (a link is never followed: a dotfiles link into a repository would put the
|
|
17
|
+
// setting inside one), owned by this user, writable by no group or other principal, in a directory owned by this user that no group or
|
|
18
|
+
// other principal can write; valid JSON of exactly the schema above (an unknown key is a typo that would silently pick the default);
|
|
19
|
+
// and a `snapshotRoot` that is an absolute, normalised POSIX path (no `..`, no `.`, no doubled or trailing separator).
|
|
20
|
+
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
|
|
25
|
+
export const HOST_CONFIG_SCHEMA = "rightkit.gate-host-config/1";
|
|
26
|
+
export const HOST_CONFIG_DIR = path.posix.join(".config", "rightkit");
|
|
27
|
+
export const HOST_CONFIG_NAME = "gate.json";
|
|
28
|
+
|
|
29
|
+
export class HostConfigError extends Error {
|
|
30
|
+
constructor(message) { super(message); this.code = "host-config"; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The account's home directory from the account database, not from HOME (which any process can change); HOME only if there is no entry. */
|
|
34
|
+
export function accountHome() {
|
|
35
|
+
try { const h = os.userInfo().homedir; if (h) return h; } catch { /* no passwd entry (a container with an arbitrary uid) */ }
|
|
36
|
+
return os.homedir();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Where the gate's host configuration file lives for `home` (default: this account's). */
|
|
40
|
+
export function hostConfigFile(home = accountHome()) {
|
|
41
|
+
return path.posix.join(home, HOST_CONFIG_DIR, HOST_CONFIG_NAME);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const groupOrOtherWrites = (mode) => (Number(mode) & 0o022) !== 0;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The snapshot root the file at `file` names, or null when there is no such file. Throws HostConfigError when the file exists but
|
|
48
|
+
* is not an acceptable host configuration (see the header). `getuid` is injectable for the ownership rule; where the host has no
|
|
49
|
+
* uid (Windows) the ownership and mode rules do not apply, and the Windows root is the managed root's, never a file's.
|
|
50
|
+
*/
|
|
51
|
+
export function readSnapshotRootConfig(file, { getuid = process.getuid?.bind(process) } = {}) {
|
|
52
|
+
let st;
|
|
53
|
+
try { st = fs.lstatSync(file); } catch (e) { if (e.code === "ENOENT" || e.code === "ENOTDIR") return null; throw new HostConfigError(`gate host configuration ${file} cannot be inspected: ${e.code ?? e.message}`); }
|
|
54
|
+
const refuse = (why) => new HostConfigError(`gate host configuration ${file} ${why}`);
|
|
55
|
+
if (!st.isFile()) throw refuse("is not a regular file (a link is never followed: a setting reached through one could live inside a repository)");
|
|
56
|
+
if (getuid) {
|
|
57
|
+
const uid = getuid();
|
|
58
|
+
let dirSt;
|
|
59
|
+
try { dirSt = fs.lstatSync(path.dirname(file)); } catch (e) { throw refuse(`has a directory that cannot be inspected: ${e.code ?? e.message}`); }
|
|
60
|
+
if (st.uid !== uid) throw refuse(`is owned by uid ${st.uid}, not by this user (uid ${uid})`);
|
|
61
|
+
if (groupOrOtherWrites(st.mode)) throw refuse(`is writable by group or other (mode ${(Number(st.mode) & 0o777).toString(8)}); only this user may change where snapshots live`);
|
|
62
|
+
if (!dirSt.isDirectory() || dirSt.uid !== uid || groupOrOtherWrites(dirSt.mode)) throw refuse("is in a directory that is not a real directory owned by this user and closed to group and other writes");
|
|
63
|
+
}
|
|
64
|
+
let doc;
|
|
65
|
+
try { doc = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { throw refuse(`cannot be read as JSON: ${e.message}`); }
|
|
66
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw refuse("is not a JSON object");
|
|
67
|
+
if (doc.schema !== HOST_CONFIG_SCHEMA) throw refuse(`has schema ${JSON.stringify(doc.schema)}, not ${JSON.stringify(HOST_CONFIG_SCHEMA)}`);
|
|
68
|
+
const unknown = Object.keys(doc).filter((k) => k !== "schema" && k !== "snapshotRoot");
|
|
69
|
+
if (unknown.length) throw refuse(`has unknown key(s) ${unknown.map((k) => JSON.stringify(k)).join(", ")} (only "schema" and "snapshotRoot" exist; a misspelled key would silently select the default root)`);
|
|
70
|
+
if (doc.snapshotRoot === undefined) return null;
|
|
71
|
+
const root = doc.snapshotRoot;
|
|
72
|
+
if (typeof root !== "string" || !root || root.includes("\0")) throw refuse('has a "snapshotRoot" that is not a non-empty string');
|
|
73
|
+
if (!path.posix.isAbsolute(root)) throw refuse(`names the relative "snapshotRoot" ${JSON.stringify(root)}; it must be absolute`);
|
|
74
|
+
if (root === "/" || root.endsWith("/") || path.posix.normalize(root) !== root) throw refuse(`names the "snapshotRoot" ${JSON.stringify(root)}, which is not a normalised path below the filesystem root (no "..", ".", doubled or trailing separators)`);
|
|
75
|
+
return root;
|
|
76
|
+
}
|