@rightkit/release 0.6.7 → 0.6.10
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 +35 -10
- package/admission-files.mjs +1 -1
- package/app-check.mjs +94 -20
- 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 +957 -83
- package/build-launch.mjs +88 -0
- package/build-ownership.mjs +1231 -0
- package/build-release.mjs +2 -1
- package/cargo-contract.mjs +3 -1
- package/cargo-target-shim.mjs +166 -0
- package/class-a-launch-guard.mjs +126 -0
- package/cli/right-release.mjs +3 -1
- package/codesign-shim.mjs +11 -1
- package/deterministic-scans.mjs +807 -56
- package/development-signing.mjs +4 -1
- package/development.mjs +110 -20
- package/door-policy.mjs +61 -6
- package/door.mjs +23 -0
- package/gate-executor.mjs +14 -2
- package/gate-stages.mjs +15 -5
- package/heavy-command.mjs +3 -2
- package/install-app.mjs +11 -2
- package/install-assembled-app.mjs +423 -0
- package/ios-dev.mjs +70 -3
- package/linux-artifacts.mjs +93 -0
- package/local-publish.mjs +161 -26
- package/managed-cargo.mjs +47 -5
- package/managed-root-guard.mjs +34 -0
- package/native-helpers.mjs +355 -0
- package/package-recipe-targets.mjs +52 -7
- package/package.json +5 -3
- package/pipeline-receipts.mjs +302 -302
- package/project-type.mjs +2 -2
- package/provenance-verify.mjs +115 -0
- package/publish-cargo.mjs +8 -6
- package/qa-admission-policy.json +10 -1
- package/recipe-build.mjs +73 -5
- package/release-config.mjs +1 -1
- package/release.mjs +15 -5
- package/rightkit-versions.json +13 -7
- package/snapshot-check.mjs +306 -0
- package/snapshot-git.mjs +166 -0
- package/snapshot-request.mjs +161 -0
- package/snapshot-root-config.mjs +76 -0
- package/snapshot.mjs +856 -0
- package/stage-commands.mjs +60 -0
- package/stage-sidecar.mjs +57 -7
- package/test-command.mjs +17 -4
- package/unit-gate.mjs +259 -0
- package/web-build.mjs +38 -6
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
|
|
39
|
+
export class SnapshotRequestError extends Error {
|
|
40
|
+
constructor(code, message) { super(message); this.name = "SnapshotRequestError"; this.code = code; }
|
|
41
|
+
}
|
|
42
|
+
const fail = (code, message) => { throw new SnapshotRequestError(code, message); };
|
|
43
|
+
const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
|
|
44
|
+
const objectId = (s) => typeof s === "string" && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(s);
|
|
45
|
+
|
|
46
|
+
/** A fresh attestation nonce: 64 lowercase hex characters. */
|
|
47
|
+
export const freshNonce = () => crypto.randomBytes(32).toString("hex");
|
|
48
|
+
|
|
49
|
+
/** sha256 of the canonical JSON of the cargo argv (without `cargo`), as the client computes it. */
|
|
50
|
+
export const argvSha256 = (argv) => sha256(canonicalJson([...argv]));
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The managed Rust variables the client will report as observedEnv, from the environment the gate runs it with. RustRight's
|
|
54
|
+
* capture_managed_rust_environment (shims.rs) matches the name case-insensitively on every platform and keeps the key exactly as
|
|
55
|
+
* spelled (`Cargo_Home` stays `Cargo_Home`), so the digest binds that spelling. On Windows the child sees only one of several
|
|
56
|
+
* names that differ by case: Node passes the first in sorted order (child_process "env" on Windows), so the rest are not observed.
|
|
57
|
+
*/
|
|
58
|
+
export function observedEnvOf(env, platform = process.platform) {
|
|
59
|
+
const out = {};
|
|
60
|
+
const entries = Object.entries(env ?? {});
|
|
61
|
+
if (platform === "win32") entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
for (const [key, value] of entries) {
|
|
64
|
+
const upper = key.toUpperCase();
|
|
65
|
+
if (platform === "win32") { if (seen.has(upper)) continue; seen.add(upper); }
|
|
66
|
+
if (MANAGED_RUST_ENVIRONMENT_KEYS.includes(upper) && typeof value === "string") out[key] = value;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Writes the carrier for one cargo request and returns `{ file, sha256, descriptor, carrier, env }`; `env` is what that one
|
|
73
|
+
* invocation adds (carrier descriptor, nonce, request-id file). Refuses before writing anything the reader would refuse.
|
|
74
|
+
*/
|
|
75
|
+
export function writeSnapshotRequest({ controlRoot, commit, snapshot, declaredInputs = [], handle, primaryCwd, argv, nonce = freshNonce(), requestIdFile, uuid = crypto.randomUUID }) {
|
|
76
|
+
if (!objectId(commit?.sha) || !objectId(commit?.tree)) fail("invalid", "commit sha and tree must be full lowercase object ids");
|
|
77
|
+
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");
|
|
78
|
+
if (typeof handle !== "string" || !handle.trim()) fail("invalid", "the build-ownership handle is missing");
|
|
79
|
+
if (!primaryCwd || !path.isAbsolute(primaryCwd)) fail("invalid", "primaryCwd must be the cargo request's absolute working directory");
|
|
80
|
+
if (!Array.isArray(argv) || !argv.every((a) => typeof a === "string")) fail("invalid", "argv must be the cargo arguments (without cargo)");
|
|
81
|
+
if (typeof nonce !== "string" || !/^[0-9a-f]{64,128}$/.test(nonce)) fail("invalid", "the nonce must be 64 to 128 lowercase hex characters");
|
|
82
|
+
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");
|
|
83
|
+
if (!controlRoot || !path.isAbsolute(controlRoot)) fail("invalid", "the control root must be absolute");
|
|
84
|
+
const dir = path.join(controlRoot, "snapshot-requests");
|
|
85
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
86
|
+
const meta = fs.lstatSync(dir);
|
|
87
|
+
if (meta.isSymbolicLink() || !meta.isDirectory()) fail("invalid", `${dir} is not a real directory`);
|
|
88
|
+
const carrier = {
|
|
89
|
+
schema: SNAPSHOT_REQUEST_SCHEMA,
|
|
90
|
+
commit: { sha: commit.sha, tree: commit.tree },
|
|
91
|
+
snapshot: { path: snapshot.path, expectedTree: snapshot.expectedTree },
|
|
92
|
+
declaredInputs: { inputs: [...declaredInputs] },
|
|
93
|
+
ownership: { handle },
|
|
94
|
+
primaryCwd,
|
|
95
|
+
argvSha256: argvSha256(argv),
|
|
96
|
+
nonce,
|
|
97
|
+
};
|
|
98
|
+
const bytes = Buffer.from(JSON.stringify(carrier), "utf8");
|
|
99
|
+
if (bytes.length > CARRIER_MAX_BYTES) fail("too-large", `the snapshot request is ${bytes.length} bytes, more than ${CARRIER_MAX_BYTES}`);
|
|
100
|
+
const file = path.join(dir, `${uuid()}.json`);
|
|
101
|
+
fs.writeFileSync(file, bytes, { flag: "wx", mode: 0o600 });
|
|
102
|
+
const digest = sha256(bytes);
|
|
103
|
+
const descriptor = JSON.stringify({ path: file, sha256: digest });
|
|
104
|
+
if (Buffer.byteLength(descriptor) > DESCRIPTOR_MAX_BYTES) { fs.rmSync(file, { force: true }); fail("too-large", "the snapshot request descriptor exceeds 4096 bytes"); }
|
|
105
|
+
const env = { [SNAPSHOT_REQUEST_ENV]: descriptor, [ATTEST_NONCE_ENV]: nonce, ...(requestIdFile ? { [REQUEST_ID_FILE_ENV]: requestIdFile } : {}) };
|
|
106
|
+
return { file, sha256: digest, descriptor, carrier, env };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The request core the broker digests for a snapshot cargo request: the base core plus commit, snapshot, declaredInputs and ownership. */
|
|
110
|
+
export function snapshotRequestCore({ argv, workspace, observedEnv = {}, carrier }) {
|
|
111
|
+
return {
|
|
112
|
+
adapter: null, env: {}, inputManifest: null, nonce: carrier.nonce, observedEnv, outputs: [], requestedArgv: [...argv], shim: "cargo", workspace,
|
|
113
|
+
commit: carrier.commit, snapshot: carrier.snapshot, declaredInputs: carrier.declaredInputs, ownership: carrier.ownership,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
export const requestDigestOf = (core) => sha256(canonicalJson(core));
|
|
117
|
+
|
|
118
|
+
/** The request reference the client writes at submit (RIGHTKIT_REQUEST_ID_FILE), or a throw. */
|
|
119
|
+
export function readRequestRef(file) {
|
|
120
|
+
let ref;
|
|
121
|
+
try { ref = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { fail("request", `no request reference at ${file} (${e.message})`); }
|
|
122
|
+
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}`);
|
|
123
|
+
return ref;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const same = (a, b) => canonicalJson(a ?? null) === canonicalJson(b ?? null);
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Verifies the receipt of one snapshot cargo request. `pins` come from this host's enrollment, never from the receipt.
|
|
130
|
+
* Returns `{ payload, evidence }`; throws ProvenanceError (signature, pins, job, nonce, request) or SnapshotRequestError (evidence).
|
|
131
|
+
*/
|
|
132
|
+
export function verifySnapshotReceipt(receipt, { pins, jobId, carrier, argv, workspace, observedEnv = {}, canonicalPath = (p) => fs.realpathSync.native(p) }) {
|
|
133
|
+
const requestDigest = requestDigestOf(snapshotRequestCore({ argv, workspace, observedEnv, carrier }));
|
|
134
|
+
const payload = verifyProvenance(receipt, pins, { jobId, nonce: carrier.nonce, requestDigest });
|
|
135
|
+
// The signed outcome, not the client's exit code: a passing row needs the broker to have signed SUCCEEDED with exit 0 and no
|
|
136
|
+
// signal (service.rs provenance_payload `result`). An authentic FAILED, or a payload without a result, is not a success.
|
|
137
|
+
const result = payload.result;
|
|
138
|
+
if (!result || typeof result !== "object") fail("result", "the signed payload carries no result: the broker did not sign this request's outcome");
|
|
139
|
+
if (result.status !== "SUCCEEDED" || result.exitCode !== 0 || (result.signal !== undefined && result.signal !== null)) {
|
|
140
|
+
fail("result", `the broker signed ${result.status ?? "no status"} (exit ${result.exitCode ?? "none"}${result.signal ? `, signal ${result.signal}` : ""}), not SUCCEEDED with exit 0`);
|
|
141
|
+
}
|
|
142
|
+
const evidence = payload.snapshotEvidence;
|
|
143
|
+
if (!evidence || typeof evidence !== "object") fail("evidence", "the signed payload carries no snapshotEvidence: the broker did not build this as a snapshot request");
|
|
144
|
+
for (const name of SNAPSHOT_EVIDENCE_FIELDS) {
|
|
145
|
+
if (evidence[name] === undefined || !same(receipt[name], evidence[name])) fail("evidence", `receipt ${name} differs from the signed snapshot evidence`);
|
|
146
|
+
}
|
|
147
|
+
if (!same(evidence.commit, carrier.commit)) fail("evidence", "the signed commit is not the one this request carried");
|
|
148
|
+
if (evidence.ownership?.handle !== carrier.ownership.handle) fail("evidence", "the signed ownership handle is not this gate's");
|
|
149
|
+
if (evidence.requestId !== jobId) fail("evidence", "the signed request id is not this job");
|
|
150
|
+
const snap = evidence.snapshot ?? {};
|
|
151
|
+
let samePath = false;
|
|
152
|
+
try { samePath = canonicalPath(snap.path) === canonicalPath(carrier.snapshot.path); } catch { samePath = false; }
|
|
153
|
+
if (!samePath) fail("evidence", `the signed snapshot ${snap.path} is not ${carrier.snapshot.path}`);
|
|
154
|
+
if (snap.treeAtStart !== carrier.snapshot.expectedTree || snap.treeAtExit !== carrier.snapshot.expectedTree) {
|
|
155
|
+
fail("evidence", `the snapshot was tree ${snap.treeAtStart} at START and ${snap.treeAtExit} at exit, not ${carrier.snapshot.expectedTree} throughout`);
|
|
156
|
+
}
|
|
157
|
+
if (typeof evidence.outputDir !== "string" || !path.isAbsolute(evidence.outputDir)) fail("evidence", "the signed evidence names no absolute output directory");
|
|
158
|
+
return { payload, evidence };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
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
|
+
}
|