@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.mjs
ADDED
|
@@ -0,0 +1,969 @@
|
|
|
1
|
+
// The gate-owned commit snapshot (docs/GATE-CONTRACT.md §1 "Commit-only build" and "Broker interface"; rightkit#1254, #1255).
|
|
2
|
+
//
|
|
3
|
+
// A build compiles a selected COMMIT, never the editable checkout. The gate keeps one persistent source directory per repository
|
|
4
|
+
// under the managed root and syncs the commit into it by content: only changed bytes are written, an unchanged file keeps its
|
|
5
|
+
// mtime (so Cargo's fingerprints stay warm), files the commit no longer has are removed, tracked file types & modes are kept, and
|
|
6
|
+
// no link resolves into the checkout. Outputs & caches stay where they already are; only the source is separate.
|
|
7
|
+
//
|
|
8
|
+
// The snapshot's identity is the contract's §1 method, seeded per rightkit#1255 so it agrees with the broker's `snapshot_tree_of`:
|
|
9
|
+
// a temporary index (in a private temp directory, never in the checkout's `.git`) starts as the commit's tree (`read-tree <sha>^{tree}`), every file the
|
|
10
|
+
// snapshot holds is staged from it (`--work-tree=<snapshot> add -A`) against the checkout's object database, then `write-tree`.
|
|
11
|
+
// A committed file an ignore rule also matches stays in the id; untracked files an ignore rule excludes (build outputs) do not.
|
|
12
|
+
// The checkout's own index, HEAD, refs, worktrees, stash and files are never touched; only objects are written.
|
|
13
|
+
//
|
|
14
|
+
// Roots: Mac & Dell `<managed root>/snapshots/<repo-key>` with the managed root the host's fixed one (managed-roots.mjs legacyRoot:
|
|
15
|
+
// `/Volumes/D/.rightkit-managed`, `D:\.rightkit-managed`), never an environment variable; Linux the `snapshotRoot` the gate's per-user
|
|
16
|
+
// host configuration file names (snapshot-root-config.mjs: `~/.config/rightkit/gate.json`), else `$XDG_STATE_HOME/rightkit/snapshots`,
|
|
17
|
+
// else `~/.local/state/rightkit/snapshots`; no process environment variable or flag selects a root (RIGHTKIT_SNAPSHOT_ROOT is not
|
|
18
|
+
// read), because two concurrent runs could otherwise pick two roots for one repository. `repo-key` = first 16 hex of sha256(canonical
|
|
19
|
+
// repository path), the key `checked-trees` uses. Scratch & temp are never the root (they are disposable): a resolved root under a
|
|
20
|
+
// disk-policy scratch root or a temp directory is refused on every host. In-process callers inject a root as a parameter
|
|
21
|
+
// (`snapshotRoot` of `snapshotCommand`'s and the gate's `o`).
|
|
22
|
+
//
|
|
23
|
+
// The gate never creates the managed root (Mac & Dell): it creates `snapshots` and `snapshots/<repo-key>` beneath one that exists,
|
|
24
|
+
// and on the Mac that managed root must be a different device from `/`, so an unmounted volume can never yield a boot-volume
|
|
25
|
+
// substitute (requireManagedRoot). A snapshot root is private to this user: created 0700, and an existing POSIX root that grants
|
|
26
|
+
// group or other any access, or that another user owns, is refused (the premise under which build ownership may skip another user's
|
|
27
|
+
// unreadable processes, build-ownership.mjs).
|
|
28
|
+
//
|
|
29
|
+
// Exclusion: one sync per snapshot at a time (`<root>/<repo-key>.lock`, re-checked before every write and before the verdict).
|
|
30
|
+
// It narrows, but cannot close, the window in which two processes both replace one stale lock; host-wide build ownership held
|
|
31
|
+
// from before the sync through the stage (joint plan step 4) is the exclusion builds rely on, and the broker re-verifies the tree.
|
|
32
|
+
|
|
33
|
+
import crypto from "node:crypto";
|
|
34
|
+
import fs from "node:fs";
|
|
35
|
+
import os from "node:os";
|
|
36
|
+
import path from "node:path";
|
|
37
|
+
import { spawnSync } from "./background-process.mjs";
|
|
38
|
+
import { readDiskPolicy, scratchRoots } from "./disk-policy.mjs";
|
|
39
|
+
import { resolveManagedRoots } from "./managed-roots.mjs";
|
|
40
|
+
import { HostConfigError, hostConfigFile, readSnapshotRootConfig } from "./snapshot-root-config.mjs";
|
|
41
|
+
import { discoveryGit, discoveryRun, sealedRepoInfo, sealedRun, withSealedGit } from "./snapshot-git.mjs";
|
|
42
|
+
import { acquireBuildOwnership, migrateBirthlessRoot, OwnershipBusy, ownershipFile, recordRootBirth } from "./build-ownership.mjs";
|
|
43
|
+
// The error class and the managed-root guard live in managed-root-guard.mjs, which build-ownership.mjs imports too (decision D4 rule 1).
|
|
44
|
+
import { SnapshotError, requireManagedRoot } from "./managed-root-guard.mjs";
|
|
45
|
+
|
|
46
|
+
export { SnapshotError, requireManagedRoot };
|
|
47
|
+
|
|
48
|
+
export const SNAPSHOT_SCHEMA = "rightkit.snapshot/1";
|
|
49
|
+
const STATE_SCHEMA = "rightkit.snapshot-state/1";
|
|
50
|
+
const FILE_MODES = new Set(["100644", "100755"]);
|
|
51
|
+
const LINK_MODE = "120000";
|
|
52
|
+
// Every git call that reads objects or computes identity goes through the sealed runner (snapshot-git.mjs, item 5 D3): the
|
|
53
|
+
// checkout's configuration, attributes, hooks, filters and replace refs cannot reach it.
|
|
54
|
+
const MAX_BUFFER = 1 << 30;
|
|
55
|
+
const BLOB_CHUNK = 500;
|
|
56
|
+
|
|
57
|
+
/** First 16 hex digits of sha256 over the canonical repository path: the key RustRight's `checked-trees` & journals use. */
|
|
58
|
+
export function repoKey(canonicalRepoPath) {
|
|
59
|
+
return crypto.createHash("sha256").update(String(canonicalRepoPath)).digest("hex").slice(0, 16);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The repository path the key is taken over, spelled as RustRight's `fs::canonicalize` spells it, so the snapshot's key equals
|
|
64
|
+
* the broker's `checked-trees` key on every host: unchanged on Mac & Linux, the verbatim `\\?\` form on Windows
|
|
65
|
+
* (Dell 2026-10-29: `checked-trees/226c20f30c489153` = sha256 of `\\?\D:\Claude\rightkit`).
|
|
66
|
+
*/
|
|
67
|
+
export function keyPath(canonicalRepoPath, platform = process.platform) {
|
|
68
|
+
const p = String(canonicalRepoPath);
|
|
69
|
+
if (platform !== "win32" || p.startsWith("\\\\?\\")) return p;
|
|
70
|
+
return p.startsWith("\\\\") ? `\\\\?\\UNC\\${p.slice(2)}` : `\\\\?\\${p}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether `child` is `parent` or lies under it, by `paths`' relative path: outside only when it is exactly `..`, starts with `..` + the
|
|
74
|
+
* separator, or is absolute (a sibling named `..x` is inside). */
|
|
75
|
+
export function isInside(child, parent, paths = path) {
|
|
76
|
+
const rel = paths.relative(parent, child);
|
|
77
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${paths.sep}`) && !paths.isAbsolute(rel));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `p` with every link on its existing part resolved (the rest as written), spelled for `paths` (path.posix or path.win32). */
|
|
81
|
+
function canonicalPath(p, paths) {
|
|
82
|
+
// A path spelled for another platform than this host's (a simulated host) names nothing on this disk: it stays as written.
|
|
83
|
+
if (paths !== (process.platform === "win32" ? path.win32 : path.posix)) return paths.resolve(p);
|
|
84
|
+
const rest = [];
|
|
85
|
+
let at = paths.resolve(p);
|
|
86
|
+
for (;;) {
|
|
87
|
+
try { return paths.join(fs.realpathSync.native(at), ...rest.reverse()); }
|
|
88
|
+
catch {
|
|
89
|
+
const up = paths.dirname(at);
|
|
90
|
+
if (up === at) return paths.resolve(p);
|
|
91
|
+
rest.push(paths.basename(at));
|
|
92
|
+
at = up;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Throws SnapshotError("error") when `root` lies under a disk-policy scratch root or a temp directory: a snapshot is durable. */
|
|
98
|
+
function refuseDisposableRoot(root, source, { platform, env }) {
|
|
99
|
+
const paths = platform === "win32" ? path.win32 : path.posix;
|
|
100
|
+
const within = (child, parent) => isInside(child, parent, paths);
|
|
101
|
+
let scratch;
|
|
102
|
+
try {
|
|
103
|
+
const policy = readDiskPolicy();
|
|
104
|
+
scratch = ["darwin", "win32"].flatMap((p) => scratchRoots(policy, { platform: p, env, home: os.homedir() }));
|
|
105
|
+
} catch (e) { throw new SnapshotError("error", `cannot read the disk policy that names the scratch roots, so snapshot root ${root} (from ${source}) cannot be checked: ${e.message}`); }
|
|
106
|
+
const temps = [os.tmpdir(), "/tmp", "/var/tmp", "/private/tmp", "/var/folders", "/private/var/folders", env.TMPDIR, env.TEMP, env.TMP];
|
|
107
|
+
const real = canonicalPath(root, paths);
|
|
108
|
+
const candidates = [...scratch.map((d) => ["scratch root", d]), ...temps.filter((d) => typeof d === "string" && d).map((d) => ["temp directory", d])];
|
|
109
|
+
for (const [kind, dir] of candidates) {
|
|
110
|
+
const canon = canonicalPath(dir, paths);
|
|
111
|
+
if (paths.dirname(canon) === canon || paths.dirname(paths.resolve(dir)) === paths.resolve(dir)) continue; // a filesystem root is no scratch or temp directory (a TMPDIR of "/" must not refuse everything)
|
|
112
|
+
if (within(real, canon) || within(paths.resolve(root), paths.resolve(dir))) throw new SnapshotError("error", `snapshot root ${root} (from ${source}) is under the ${kind} ${dir}: a snapshot is durable, and scratch & temp are disposable (docs/GATE-CONTRACT.md §1)`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The directory every snapshot of this host lives under. Mac & Dell: `<managed root>/snapshots` with the host's fixed managed root,
|
|
118
|
+
* never an environment variable (RIGHTKIT_DATA_ROOT & friends move build outputs, not snapshots). Linux: the `snapshotRoot` of the
|
|
119
|
+
* gate's per-user host configuration file (`configFile`, default snapshot-root-config.mjs hostConfigFile()), else
|
|
120
|
+
* `$XDG_STATE_HOME/rightkit/snapshots` (the contract's named default, read only when no file names a root), else
|
|
121
|
+
* `<home>/.local/state/rightkit/snapshots`. RIGHTKIT_SNAPSHOT_ROOT and every other variable are not consulted: a variable could
|
|
122
|
+
* select two roots for one repository in two concurrent runs. The result is refused when it lies under scratch or temp, on every
|
|
123
|
+
* host. Callers that need another root (tests) pass it as a parameter.
|
|
124
|
+
*/
|
|
125
|
+
export function snapshotRoot({ platform = process.platform, env = process.env, home = os.homedir(), configFile } = {}) {
|
|
126
|
+
const paths = platform === "win32" ? path.win32 : path.posix;
|
|
127
|
+
let root; let source;
|
|
128
|
+
if (platform === "darwin" || platform === "win32") {
|
|
129
|
+
const base = resolveManagedRoots({ env: {}, platform, home }).dataRoot;
|
|
130
|
+
root = paths.join(base, "snapshots");
|
|
131
|
+
source = `the host's managed root ${base}`;
|
|
132
|
+
} else {
|
|
133
|
+
const file = configFile ?? hostConfigFile();
|
|
134
|
+
let configured = null;
|
|
135
|
+
try { configured = readSnapshotRootConfig(file); }
|
|
136
|
+
catch (e) { if (e instanceof HostConfigError) throw new SnapshotError("error", e.message); throw e; }
|
|
137
|
+
if (configured) {
|
|
138
|
+
root = configured;
|
|
139
|
+
source = `the gate host configuration ${file}`;
|
|
140
|
+
} else {
|
|
141
|
+
const fromXdg = env.XDG_STATE_HOME && paths.isAbsolute(env.XDG_STATE_HOME);
|
|
142
|
+
root = paths.join(fromXdg ? env.XDG_STATE_HOME : paths.join(home, ".local", "state"), "rightkit", "snapshots");
|
|
143
|
+
source = fromXdg ? "XDG_STATE_HOME" : `the home directory ${home}`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
refuseDisposableRoot(root, source, { platform, env });
|
|
147
|
+
return root;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const foldHost = (platform) => (platform === "darwin" || platform === "win32" ? (n) => n.toLowerCase() : (n) => n);
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The host's managed root when `root` is, or lies under, the host's real snapshot root location (Mac & Dell), else null. Keyed on the
|
|
154
|
+
* path, not on who calls: the door passes the real root explicitly, and it must be guarded as well as the default. Compared folded
|
|
155
|
+
* on Mac and Windows (case-insensitive volumes), so a spelling `/volumes/d/...` is the same root.
|
|
156
|
+
*/
|
|
157
|
+
export function hostManagedRootFor(root, { platform = process.platform, home = os.homedir() } = {}) {
|
|
158
|
+
if (platform !== "darwin" && platform !== "win32") return null;
|
|
159
|
+
const paths = platform === "win32" ? path.win32 : path.posix;
|
|
160
|
+
const managed = resolveManagedRoots({ env: {}, platform, home }).dataRoot;
|
|
161
|
+
const fold = foldHost(platform);
|
|
162
|
+
return isInside(fold(paths.resolve(root)), fold(paths.resolve(managed)), paths) ? managed : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A snapshot root is private to this user (row 5.22): on POSIX an existing root that grants group or other any access refuses, and
|
|
168
|
+
* so does one another user owns. `st` is the lstat of the root (a link or non-directory is the caller's refusal).
|
|
169
|
+
*/
|
|
170
|
+
function requirePrivateRoot(root, st, { platform = process.platform, getuid = process.getuid?.bind(process) } = {}) {
|
|
171
|
+
if (platform === "win32" || !st?.isDirectory()) return;
|
|
172
|
+
const mode = Number(st.mode) & 0o777;
|
|
173
|
+
if ((mode & 0o077) !== 0) throw new SnapshotError("refused", `snapshot root ${root} is mode ${mode.toString(8).padStart(4, "0")}: it grants group or other access, and a snapshot root is private to this user (chmod 700 it, or remove it and let the gate create it)`, "root-open-mode");
|
|
174
|
+
if (getuid && Number(st.uid) !== getuid()) throw new SnapshotError("refused", `snapshot root ${root} is owned by uid ${st.uid}, not by this user (uid ${getuid()})`, "root-not-owned");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Everything about the root that must hold before the first write: the managed root exists and is on the managed volume, the
|
|
179
|
+
* directory the root will be created in exists (so the creation makes exactly the root, never a managed root), and an existing root
|
|
180
|
+
* is a real directory this user alone can use.
|
|
181
|
+
*/
|
|
182
|
+
function guardRoot(root, managedRoot, { device } = {}) {
|
|
183
|
+
if (managedRoot) {
|
|
184
|
+
requireManagedRoot(managedRoot, { device });
|
|
185
|
+
const parent = lstat(path.dirname(root));
|
|
186
|
+
if (!parent?.isDirectory()) throw new SnapshotError("refused", `${path.dirname(root)}, where snapshot root ${root} would be created, does not exist; the gate creates only the root and the snapshots beneath it`, "root-parent-missing");
|
|
187
|
+
}
|
|
188
|
+
const st = lstat(root);
|
|
189
|
+
if (st && !st.isDirectory()) throw new SnapshotError("refused", `snapshot root ${root} is not a directory (a link is never followed)`);
|
|
190
|
+
requirePrivateRoot(root, st);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A git command that finds the checkout's repository from `cwd` (discovery only: sealed environment, no replace refs); a failure is a SnapshotError("error"). */
|
|
194
|
+
function runGit(cwd, args) {
|
|
195
|
+
try { return discoveryGit(cwd, args); } catch (e) { throw new SnapshotError("error", e.message); }
|
|
196
|
+
}
|
|
197
|
+
/** A sealed object read against `checkout`'s object database (ls-tree, cat-file): see snapshot-git.mjs. */
|
|
198
|
+
function sealedGitOut(checkout, args, options) {
|
|
199
|
+
const r = sealedRun(checkout, args, options);
|
|
200
|
+
if (r.error || r.status !== 0) throw new SnapshotError("error", `git ${args.join(" ")}: ${r.error ? r.error.message : String(r.stderr ?? "").trim() || `exit ${r.status}`}`);
|
|
201
|
+
return r.stdout;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The repository a checkout belongs to: canonical top level, absolute git directory and object format. */
|
|
205
|
+
export function repositoryOf(checkout) {
|
|
206
|
+
const top = runGit(checkout, ["rev-parse", "--show-toplevel"]).trim();
|
|
207
|
+
const canonical = fs.realpathSync.native(top);
|
|
208
|
+
const gitDir = runGit(canonical, ["rev-parse", "--absolute-git-dir"]).trim();
|
|
209
|
+
const objectFormat = runGit(canonical, ["rev-parse", "--show-object-format"]).trim() || "sha1";
|
|
210
|
+
const { commonDir } = sealedRepoInfo(canonical);
|
|
211
|
+
return { checkout: canonical, gitDir, commonDir, objectFormat, key: repoKey(keyPath(canonical)) };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* `{ commit, tree }` of a revision. A revision that is not a commit is refused (named "unknown-revision"), never guessed at, and
|
|
216
|
+
* before anything is written (GATE-CONTRACT §1 "a revision that is not a commit is an error"); git failing to run is an error.
|
|
217
|
+
*/
|
|
218
|
+
export function resolveCommit(checkout, rev = "HEAD") {
|
|
219
|
+
const name = String(rev);
|
|
220
|
+
if (!name || name.startsWith("-") || name.includes("\0")) throw new SnapshotError("refused", `revision ${JSON.stringify(name)} is not a commit`, "unknown-revision");
|
|
221
|
+
// The one call that reads the checkout's refs: under the sealed environment and GIT_NO_REPLACE_OBJECTS (a replace ref must not change what <sha>^{tree} means).
|
|
222
|
+
const r = discoveryRun(checkout, ["rev-parse", "--verify", "--quiet", `${name}^{commit}`]);
|
|
223
|
+
if (r.error) throw new SnapshotError("error", `git rev-parse ${name}: ${r.error.message}`);
|
|
224
|
+
// `rev-parse --verify` exits 1 when the name does not resolve to a commit (unknown, or an object of another type, which it reports
|
|
225
|
+
// on stderr); git itself failing (not a repository, a damaged object database) exits 128 and is an error.
|
|
226
|
+
if (r.status === 1) throw new SnapshotError("refused", `revision ${JSON.stringify(name)} is not a commit in ${checkout}`, "unknown-revision");
|
|
227
|
+
if (r.status !== 0) throw new SnapshotError("error", `git rev-parse ${name}: ${String(r.stderr ?? "").trim() || `exit ${r.status}`}`);
|
|
228
|
+
const commit = r.stdout.trim();
|
|
229
|
+
const tree = runGit(checkout, ["rev-parse", "--verify", "--quiet", `${commit}^{tree}`]).trim();
|
|
230
|
+
return { commit, tree };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The tree id of `dir` by the contract's §1 method, seeded from `baseTree` (rightkit#1255). Reads nothing of the checkout but its
|
|
235
|
+
* object database and ignore rules; writes only objects (into the checkout's object database) and a temporary index kept outside the
|
|
236
|
+
* checkout's `.git`, always removed.
|
|
237
|
+
*/
|
|
238
|
+
export function snapshotTree({ checkout, dir, baseTree, repo = repositoryOf(checkout) }) {
|
|
239
|
+
// The sealed runner (snapshot-git.mjs): a private git directory T holds the index and receives every object written; the
|
|
240
|
+
// checkout's git directory is not written at all (row 5.13), and its configuration, attributes, hooks and filters cannot reach the id.
|
|
241
|
+
return withSealedGit({ commonDir: repo.commonDir, objectFormat: repo.objectFormat, snapshot: dir }, (git) => {
|
|
242
|
+
try {
|
|
243
|
+
git.ok(["read-tree", baseTree]);
|
|
244
|
+
git.ok(["add", "-A"]);
|
|
245
|
+
const id = git.ok(["write-tree"]).trim();
|
|
246
|
+
if (!/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(id)) throw new SnapshotError("error", `git write-tree printed ${JSON.stringify(id)}`);
|
|
247
|
+
return id;
|
|
248
|
+
} catch (e) {
|
|
249
|
+
throw e instanceof SnapshotError ? e : new SnapshotError("error", e.message);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Removes every entry under `dir` the commit does not hold: untracked files whether an ignore rule matches them or not, leftover
|
|
256
|
+
* directories, links that are not committed. §1 makes the snapshot exactly the commit; a stale ignored file is unattested build
|
|
257
|
+
* input even though the seeded tree id cannot see it. Never follows a link (only real directories are entered); names compare
|
|
258
|
+
* case-insensitively where the host filesystem does (Mac, Windows; colliding names were refused before this runs).
|
|
259
|
+
*/
|
|
260
|
+
function pruneToTree(dir, entries, platform = process.platform) {
|
|
261
|
+
const fold = platform === "darwin" || platform === "win32" ? (n) => n.toLowerCase() : (n) => n;
|
|
262
|
+
const files = new Set(entries.map((e) => fold(e.path)));
|
|
263
|
+
const dirs = new Set();
|
|
264
|
+
for (const e of entries) { const parts = e.path.split("/"); for (let i = 1; i < parts.length; i++) dirs.add(fold(parts.slice(0, i).join("/"))); }
|
|
265
|
+
let removed = 0;
|
|
266
|
+
const walk = (d, rel) => {
|
|
267
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
268
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
269
|
+
const p = path.join(d, e.name);
|
|
270
|
+
if (e.isDirectory()) { if (dirs.has(fold(r))) walk(p, r); else { fs.rmSync(p, { recursive: true, force: true }); removed++; } }
|
|
271
|
+
else if (!files.has(fold(r))) { fs.unlinkSync(p); removed++; }
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
walk(dir, "");
|
|
275
|
+
return removed;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Every entry of the commit's tree: `{ mode, oid, path }`, path in git's `/` form. Gitlinks & unsafe paths refuse. */
|
|
279
|
+
export function treeEntries(checkout, commit) {
|
|
280
|
+
const out = sealedGitOut(checkout, ["ls-tree", "-r", "-z", "--full-tree", commit]);
|
|
281
|
+
const entries = [];
|
|
282
|
+
for (const record of out.split("\0").filter(Boolean)) {
|
|
283
|
+
const tab = record.indexOf("\t");
|
|
284
|
+
const [mode, type, oid] = record.slice(0, tab).split(" ");
|
|
285
|
+
const file = record.slice(tab + 1);
|
|
286
|
+
const parts = file.split("/");
|
|
287
|
+
if (!file || file.startsWith("/") || parts.some((p) => !p || p === "." || p === ".." || p.toLowerCase() === ".git") || /[\\:]/.test(file) && process.platform === "win32") {
|
|
288
|
+
throw new SnapshotError("refused", `commit ${commit} has a path a snapshot cannot hold exactly: ${JSON.stringify(file)}`);
|
|
289
|
+
}
|
|
290
|
+
if (type === "commit" || mode === "160000") throw new SnapshotError("refused", `commit ${commit} has a gitlink at ${file}: a snapshot materialises files, not nested repositories (an exact snapshot of it cannot be made from the object database); nothing was written`, "gitlink");
|
|
291
|
+
if (type !== "blob" || !(FILE_MODES.has(mode) || mode === LINK_MODE)) throw new SnapshotError("refused", `commit ${commit} has ${type} ${mode} at ${file}`);
|
|
292
|
+
entries.push({ mode, oid, path: file });
|
|
293
|
+
}
|
|
294
|
+
return entries;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** On Mac & Windows (case-insensitive by default) two tree paths, or a path and a directory, that differ only in case cannot both
|
|
298
|
+
* exist: one would replace the other, and a link could redirect a write. Refused before anything is written. */
|
|
299
|
+
function refuseCaseCollisions(entries, commit, platform = process.platform) {
|
|
300
|
+
if (platform !== "darwin" && platform !== "win32") return;
|
|
301
|
+
const seen = new Map();
|
|
302
|
+
for (const e of entries) {
|
|
303
|
+
const parts = e.path.split("/");
|
|
304
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
305
|
+
const p = parts.slice(0, i).join("/");
|
|
306
|
+
const k = p.toLowerCase();
|
|
307
|
+
const other = seen.get(k);
|
|
308
|
+
if (other !== undefined && other !== p) throw new SnapshotError("refused", `commit ${commit} has ${JSON.stringify(other)} and ${JSON.stringify(p)}, which differ only in case`);
|
|
309
|
+
seen.set(k, p);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The ONLY entries a snapshot may hold beyond its commit's tree (GATE-CONTRACT §1 "exact snapshot"; E6 "plant a stale file, a link").
|
|
316
|
+
* Build outputs are never among them: the snapshot holds no target/, the executor writes to the checkout's own output directory.
|
|
317
|
+
* Two narrow, named allowances remain, each a thing the gate itself puts there:
|
|
318
|
+
* 1. pnpm dependency directories: a `node_modules` directly inside a directory that holds a committed `package.json` and sits at or
|
|
319
|
+
* under a directory holding a committed `pnpm-lock.yaml` (snapshot-check.mjs installSnapshotDependencies installs only where a
|
|
320
|
+
* lockfile exists, into each root and its workspace members). Nothing else ignored by git is allowed, and a link inside such a
|
|
321
|
+
* directory is still held to the no-escape rule.
|
|
322
|
+
* 2. the config-only `.git/config` of `[remote "origin"]\n\turl = <url>\n` that `check --snapshot` writes for rustright's grant
|
|
323
|
+
* binding (rightkit#1215) and removes when the check ends: `.git` holding exactly that one file and nothing else, and the file
|
|
324
|
+
* byte-equal to what the gate writes for THIS checkout's origin (gateGitConfigBody over `git config --get remote.origin.url`).
|
|
325
|
+
* Any other URL (`ext::sh -c id` was the r2#5 probe), any other section, any other byte is an extra. A checkout with no origin
|
|
326
|
+
* gets no `.git` at all.
|
|
327
|
+
* Nothing else is an extra the gate may leave, and the dependency bytes of allowance 1 are bound by the environment digest
|
|
328
|
+
* (`rightkit-gate env-digest --snapshot`, which `verifySnapshot` takes as `environment`), so a changed or planted byte under a
|
|
329
|
+
* `node_modules` is refused at the end verify even though this walk lets the directory be.
|
|
330
|
+
*/
|
|
331
|
+
const foldFor = (platform) => (platform === "darwin" || platform === "win32" ? (n) => n.toLowerCase() : (n) => n);
|
|
332
|
+
|
|
333
|
+
/** The bytes the gate writes to the snapshot's `.git/config` for an origin URL (bin/rightkit-gate.mjs writes exactly this). */
|
|
334
|
+
export const gateGitConfigBody = (originUrl) => `[remote "origin"]\n\turl = ${originUrl}\n`;
|
|
335
|
+
|
|
336
|
+
/** The checkout's `remote.origin.url` as the gate reads it (`git config --get remote.origin.url`, trimmed), "" when it has none. */
|
|
337
|
+
export function originUrlOf(checkout) {
|
|
338
|
+
const r = spawnSync("git", ["config", "--get", "remote.origin.url"], { cwd: checkout, encoding: "utf8", maxBuffer: 1 << 20 });
|
|
339
|
+
return r.error || r.status !== 0 ? "" : String(r.stdout ?? "").trim();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Folded relative paths of the `node_modules` directories allowance 1 admits, derived from the commit's own entries. */
|
|
343
|
+
export function dependencyDirs(entries, platform = process.platform) {
|
|
344
|
+
const fold = foldFor(platform);
|
|
345
|
+
const dirOf = (p) => { const i = p.lastIndexOf("/"); return i < 0 ? "" : p.slice(0, i); };
|
|
346
|
+
const holding = (name) => entries.filter((e) => e.path === name || e.path.endsWith(`/${name}`)).map((e) => dirOf(e.path)).filter((d) => !d.split("/").includes("node_modules"));
|
|
347
|
+
const lockRoots = holding("pnpm-lock.yaml");
|
|
348
|
+
const out = new Set();
|
|
349
|
+
for (const member of holding("package.json")) {
|
|
350
|
+
if (lockRoots.some((r) => r === "" || member === r || member.startsWith(`${r}/`))) out.add(fold(member ? `${member}/node_modules` : "node_modules"));
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* What stops `dir` being exactly the commit's tree that the seeded tree id cannot see (it ignores what an ignore rule matches,
|
|
357
|
+
* follows no link, and under `core.symlinks=false` or without exec bits cannot tell a link from a file holding its text): the first
|
|
358
|
+
* path under `dir` that is not in `entries` (ignored or not, empty directories included) and not one of the two allowances above;
|
|
359
|
+
* the first link (committed or not) that does not resolve, physically, to something inside `dir` and outside `checkout`; the first
|
|
360
|
+
* entry whose TYPE is not the commit's (a file where the commit has a link or the reverse, from `lstat`, independent of git's
|
|
361
|
+
* configuration), whose link text is not the committed text, or, on POSIX, whose exec bit is not the commit's; and the first
|
|
362
|
+
* non-directory outside the dependency directories with more than one hard link (a checkout file hard-linked into the snapshot
|
|
363
|
+
* carries every later checkout edit into the build, with bytes that may still equal the commit's). Hard links inside the
|
|
364
|
+
* dependency directories are pnpm's own store links; their bytes are bound by the environment digest instead. Never follows a link
|
|
365
|
+
* to enter it. Returns a message naming the path, or null.
|
|
366
|
+
*
|
|
367
|
+
* The stage-output ledger (item 5 row 5.09d; signed/item7-sage-decisions-2.md Q1). Given `ledger` and/or `admitNew`, the walk does not
|
|
368
|
+
* refuse the first path the commit lacks: it collects every such path as a candidate entry and admits it only when ALL hold:
|
|
369
|
+
* - it is untracked (not in `entries`) and ignored by the COMMIT'S OWN ignore rules, judged by the neutral oracle (neutralIgnored:
|
|
370
|
+
* `git check-ignore --no-index` against an empty bare GIT_DIR with core.excludesFile at the null device, so neither the checkout's
|
|
371
|
+
* info/exclude nor any global excludes file widens what is ignored; probe P4);
|
|
372
|
+
* - it is a regular file or a directory, never a link of any kind, and a file has nlink 1;
|
|
373
|
+
* - no path segment is `target`, `.right-release`, `.git` or `node_modules` (outside the pnpm roots, which are not candidates),
|
|
374
|
+
* and it is not named `.gitignore` (an untracked ignore file would make the oracle ignore whatever it names).
|
|
375
|
+
* `ledger` (an array of `{ path, type, size, sha256, execBit }` recorded by an earlier verify) must still match exactly, byte for byte,
|
|
376
|
+
* and none of its entries may be missing (an absent directory that only parents ledger files is implied by them). `admitNew` (end of a
|
|
377
|
+
* stage only) additionally admits candidates the ledger does not hold. Without `admitNew`, a candidate outside the ledger is refused,
|
|
378
|
+
* so the start of a stage stays as strict as the sync. In this mode the result is `{ problem, admitted }`, `admitted` being every
|
|
379
|
+
* admitted entry (ledger and new), sorted by path; otherwise it is the message or null as before.
|
|
380
|
+
*/
|
|
381
|
+
export function snapshotProblem(options) {
|
|
382
|
+
const r = inspectSnapshot(options);
|
|
383
|
+
return options.ledger !== undefined || options.admitNew ? r : r.problem;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const RESERVED_SEGMENTS = new Set(["target", ".right-release", ".git", "node_modules"]);
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Which of `paths` (relative, `/`-separated, existing under `dir`) the snapshot's own ignore rules ignore, and nothing else: the
|
|
390
|
+
* sealed runner (snapshot-git.mjs) gives an empty info/exclude and an empty private file for core.excludesFile (never a null device,
|
|
391
|
+
* so Windows needs no `NUL`), and `--no-index` so only the work tree's `.gitignore` files count. Returns a Set of the ignored paths as given.
|
|
392
|
+
*/
|
|
393
|
+
export function neutralIgnored(dir, paths) {
|
|
394
|
+
if (!paths.length) return new Set();
|
|
395
|
+
return withSealedGit({ snapshot: dir }, (git) => {
|
|
396
|
+
const r = git.run(["check-ignore", "--no-index", "-z", "--stdin"], { input: `${paths.join("\0")}\0` });
|
|
397
|
+
if (r.error) throw new SnapshotError("error", `git check-ignore: ${r.error.message}`);
|
|
398
|
+
// Exit 0: some path is ignored; 1: none is; anything else is git failing, which must never read as "not ignored" or "ignored".
|
|
399
|
+
if (r.status !== 0 && r.status !== 1) throw new SnapshotError("error", `git check-ignore: ${String(r.stderr).trim() || `exit ${r.status}`}`);
|
|
400
|
+
return new Set(String(r.stdout ?? "").split("\0").filter(Boolean));
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** sha256 of a file, read in chunks (a staged sidecar can be large). */
|
|
405
|
+
function sha256File(file) {
|
|
406
|
+
const h = crypto.createHash("sha256");
|
|
407
|
+
const fd = fs.openSync(file, "r");
|
|
408
|
+
try {
|
|
409
|
+
const buf = Buffer.allocUnsafe(1 << 20);
|
|
410
|
+
for (let n; (n = fs.readSync(fd, buf, 0, buf.length, null)) > 0;) h.update(buf.subarray(0, n));
|
|
411
|
+
} finally { fs.closeSync(fd); }
|
|
412
|
+
return h.digest("hex");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function inspectSnapshot({ dir, entries, checkout, platform = process.platform, originUrl, ledger, admitNew = false }) {
|
|
416
|
+
const problem = (message) => ({ problem: message, admitted: [] });
|
|
417
|
+
const ledgerMode = ledger !== undefined || admitNew === true;
|
|
418
|
+
const fold = foldFor(platform);
|
|
419
|
+
const candidates = [];
|
|
420
|
+
const byKey = new Map(entries.map((e) => [fold(e.path), e]));
|
|
421
|
+
const dirs = new Set();
|
|
422
|
+
for (const e of entries) { const parts = e.path.split("/"); for (let i = 1; i < parts.length; i++) dirs.add(fold(parts.slice(0, i).join("/"))); }
|
|
423
|
+
const deps = dependencyDirs(entries, platform);
|
|
424
|
+
let realDir; let realCheckout = checkout;
|
|
425
|
+
try { realDir = fs.realpathSync.native(dir); } catch (e) { return problem(`${dir} cannot be resolved: ${e.message}`); }
|
|
426
|
+
try { realCheckout = fs.realpathSync.native(checkout); } catch { /* the path as given */ }
|
|
427
|
+
const linkProblem = (rel, p) => {
|
|
428
|
+
let real;
|
|
429
|
+
try { real = fs.realpathSync.native(p); } catch { return `${rel} is a link that does not resolve (dangling or looping); it cannot be proven to stay inside the snapshot`; }
|
|
430
|
+
if (inside(real, realCheckout)) return `${rel} is a link resolving into the editable checkout (${real})`;
|
|
431
|
+
if (!inside(real, realDir)) return `${rel} is a link resolving outside the snapshot (${real})`;
|
|
432
|
+
return null;
|
|
433
|
+
};
|
|
434
|
+
// The committed text of every committed link, read once, lazily (a tree with no links reads nothing).
|
|
435
|
+
let linkTexts = null;
|
|
436
|
+
const committedLinkText = (entry) => {
|
|
437
|
+
if (!linkTexts) {
|
|
438
|
+
const links = entries.filter((x) => x.mode === LINK_MODE);
|
|
439
|
+
const blobs = links.length ? readBlobs(checkout, links.map((x) => x.oid)) : new Map();
|
|
440
|
+
linkTexts = new Map(links.map((x) => [x.path, blobs.get(x.oid).toString("utf8")]));
|
|
441
|
+
}
|
|
442
|
+
return linkTexts.get(entry.path);
|
|
443
|
+
};
|
|
444
|
+
const gitConfigProblem = (rel, p) => {
|
|
445
|
+
const names = fs.readdirSync(p);
|
|
446
|
+
if (names.length !== 1 || names[0] !== "config") return `${rel} is not in the commit's tree (a .git directory may hold only the gate's config-only origin file)`;
|
|
447
|
+
const config = path.join(p, "config");
|
|
448
|
+
const s = lstat(config);
|
|
449
|
+
const origin = originUrl ?? originUrlOf(checkout);
|
|
450
|
+
if (!origin) return `${rel} is not in the commit's tree (the checkout has no origin, so the gate writes no .git/config)`;
|
|
451
|
+
if (!s?.isFile() || fs.readFileSync(config, "utf8") !== gateGitConfigBody(origin)) return `${rel}/config is not in the commit's tree (it is not byte-equal to the gate's config-only file for this checkout's origin)`;
|
|
452
|
+
return null;
|
|
453
|
+
};
|
|
454
|
+
const fileProblem = (r, p, entry) => {
|
|
455
|
+
const s = lstat(p);
|
|
456
|
+
if (!s) return `${r} vanished while it was checked`;
|
|
457
|
+
if (!s.isFile() && !s.isSymbolicLink()) return `${r} is not a regular file or a link`;
|
|
458
|
+
if (s.nlink > 1n) return `${r} has ${s.nlink} hard links: a checkout edit made through another name for it would reach the build`;
|
|
459
|
+
const committedLink = entry.mode === LINK_MODE;
|
|
460
|
+
if (s.isSymbolicLink() !== committedLink) return committedLink ? `${r} is a plain file, but the commit holds a link there (a file holding the link's text is not the link)` : `${r} is a link, but the commit holds a file there`;
|
|
461
|
+
if (committedLink) {
|
|
462
|
+
const want = committedLinkText(entry);
|
|
463
|
+
const have = linkText(fs.readlinkSync(p));
|
|
464
|
+
if (have !== want) return `${r} is a link to ${JSON.stringify(have)}, but the commit's link is to ${JSON.stringify(want)}`;
|
|
465
|
+
} else if (platform !== "win32" && ((Number(s.mode) & 0o111) !== 0) !== (entry.mode === "100755")) {
|
|
466
|
+
return `${r} has mode ${(Number(s.mode) & 0o777).toString(8)}, but the commit holds ${entry.mode === "100755" ? "an executable" : "a non-executable"} file`;
|
|
467
|
+
}
|
|
468
|
+
return null;
|
|
469
|
+
};
|
|
470
|
+
// Ledger mode: a path the commit lacks is a candidate. Everything about it that does not need git is decided here; whether the
|
|
471
|
+
// commit's own rules ignore it is decided for all candidates at once afterwards (neutralIgnored).
|
|
472
|
+
const reserved = (rel) => {
|
|
473
|
+
const parts = rel.split("/").map(fold);
|
|
474
|
+
const bad = parts.find((x) => RESERVED_SEGMENTS.has(x));
|
|
475
|
+
if (bad !== undefined) return `${rel} is under ${JSON.stringify(bad)}, a reserved path no stage output may occupy (target/ is the checkout's output directory, .right-release holds right-release's, .git is the gate's config-only file, node_modules is allowed only as the pnpm roots' installed dependencies)`;
|
|
476
|
+
if (fold(parts.at(-1)) === ".gitignore") return `${rel} is an ignore file the commit does not have: an untracked .gitignore would decide what counts as ignored`;
|
|
477
|
+
return null;
|
|
478
|
+
};
|
|
479
|
+
const candidate = (p, rel) => {
|
|
480
|
+
const bad = reserved(rel);
|
|
481
|
+
if (bad) return bad;
|
|
482
|
+
const s = lstat(p);
|
|
483
|
+
if (!s) return `${rel} vanished while it was checked`;
|
|
484
|
+
if (s.isSymbolicLink()) return `${rel} is a link: a link is never admitted to the stage-output ledger`;
|
|
485
|
+
if (!s.isFile()) return `${rel} is not a regular file or a directory`;
|
|
486
|
+
if (s.nlink > 1n) return `${rel} has ${s.nlink} hard links: a checkout edit made through another name for it would reach the build`;
|
|
487
|
+
candidates.push({ path: rel, type: "file", size: Number(s.size), sha256: sha256File(p), execBit: platform !== "win32" && (Number(s.mode) & 0o111) !== 0 });
|
|
488
|
+
return null;
|
|
489
|
+
};
|
|
490
|
+
const candidateDir = (p, rel) => {
|
|
491
|
+
const bad = reserved(rel);
|
|
492
|
+
if (bad) return bad;
|
|
493
|
+
candidates.push({ path: rel, type: "dir", size: 0, sha256: null, execBit: false });
|
|
494
|
+
for (const e of fs.readdirSync(p, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
495
|
+
const r = `${rel}/${e.name}`;
|
|
496
|
+
const q = path.join(p, e.name);
|
|
497
|
+
const found = e.isSymbolicLink() ? `${r} is a link: a link is never admitted to the stage-output ledger` : e.isDirectory() ? candidateDir(q, r) : candidate(q, r);
|
|
498
|
+
if (found) return found;
|
|
499
|
+
}
|
|
500
|
+
return null;
|
|
501
|
+
};
|
|
502
|
+
const walk = (d, rel, inDeps) => {
|
|
503
|
+
const names = fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
504
|
+
for (const e of names) {
|
|
505
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
506
|
+
const p = path.join(d, e.name);
|
|
507
|
+
const key = fold(r);
|
|
508
|
+
let found = null;
|
|
509
|
+
if (e.isSymbolicLink()) {
|
|
510
|
+
// The landing is named first when it is the worse news (into the checkout, out of the snapshot); a link the commit lacks is refused either way.
|
|
511
|
+
found = linkProblem(r, p);
|
|
512
|
+
if (!found && !inDeps) found = !byKey.has(key) ? (ledgerMode ? `${r} is a link the commit does not have: a link is never admitted to the stage-output ledger` : `${r} is not in the commit's tree (a link the commit does not have)`) : fileProblem(r, p, byKey.get(key));
|
|
513
|
+
} else if (e.isDirectory()) {
|
|
514
|
+
if (inDeps) found = walk(p, r, true);
|
|
515
|
+
else if (deps.has(key)) found = walk(p, r, true);
|
|
516
|
+
else if (dirs.has(key)) found = walk(p, r, false);
|
|
517
|
+
else if (key === ".git") found = gitConfigProblem(r, p);
|
|
518
|
+
else found = ledgerMode ? candidateDir(p, r) : `${r} is not in the commit's tree (a directory the commit does not have)`;
|
|
519
|
+
} else if (!inDeps) found = !byKey.has(key) ? (ledgerMode ? candidate(p, r) : `${r} is not in the commit's tree`) : fileProblem(r, p, byKey.get(key));
|
|
520
|
+
if (found) return found;
|
|
521
|
+
}
|
|
522
|
+
return null;
|
|
523
|
+
};
|
|
524
|
+
const found = walk(dir, "", false);
|
|
525
|
+
if (found || !ledgerMode) return { problem: found, admitted: [] };
|
|
526
|
+
|
|
527
|
+
// The ledger's entries, keyed like the walk's, must all be here and byte-identical; anything else the walk met is new.
|
|
528
|
+
const held = new Map((ledger ?? []).map((e) => [fold(e.path), e]));
|
|
529
|
+
const seen = new Map(candidates.map((c) => [fold(c.path), c]));
|
|
530
|
+
for (const [key, want] of held) {
|
|
531
|
+
const have = seen.get(key);
|
|
532
|
+
if (!have) return problem(`${want.path} is a ledger entry (recorded at the end of an earlier stage) and is missing`);
|
|
533
|
+
if (have.type !== want.type || have.size !== want.size || have.sha256 !== want.sha256 || have.execBit !== want.execBit) {
|
|
534
|
+
return problem(`${want.path} is a ledger entry and changed since it was recorded (${want.type} ${want.size} bytes sha256 ${want.sha256}${want.execBit ? " executable" : ""} was recorded; ${have.type} ${have.size} bytes sha256 ${have.sha256}${have.execBit ? " executable" : ""} is here)`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// A directory that only parents ledger entries is implied by them; any other candidate must be admitted as new.
|
|
538
|
+
const parents = new Set();
|
|
539
|
+
for (const e of held.values()) { const parts = e.path.split("/"); for (let i = 1; i < parts.length; i++) parents.add(fold(parts.slice(0, i).join("/"))); }
|
|
540
|
+
for (const c of candidates) {
|
|
541
|
+
const key = fold(c.path);
|
|
542
|
+
if (!held.has(key) && !(c.type === "dir" && parents.has(key)) && !admitNew) return problem(`${c.path} is not in the commit's tree and not in the stage-output ledger (new stage outputs are admitted only at the end of a stage)`);
|
|
543
|
+
}
|
|
544
|
+
// Every candidate, ledger or new, must be ignored by the commit's own rules, as the neutral oracle judges them.
|
|
545
|
+
const ignored = neutralIgnored(dir, candidates.map((c) => c.path));
|
|
546
|
+
for (const c of candidates) if (!ignored.has(c.path)) return problem(`${c.path} is not in the commit's tree and is not ignored by the commit's own ignore rules (the checkout's info/exclude and global excludes do not count)`);
|
|
547
|
+
return { problem: null, admitted: [...candidates].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function blobId(content, objectFormat) {
|
|
551
|
+
const h = crypto.createHash(objectFormat === "sha256" ? "sha256" : "sha1");
|
|
552
|
+
h.update(`blob ${content.length}\0`);
|
|
553
|
+
h.update(content);
|
|
554
|
+
return h.digest("hex");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Blob contents for `oids` via `git cat-file --batch`, in bounded chunks. */
|
|
558
|
+
function readBlobs(checkout, oids) {
|
|
559
|
+
const blobs = new Map();
|
|
560
|
+
const unique = [...new Set(oids)];
|
|
561
|
+
for (let i = 0; i < unique.length; i += 2000) {
|
|
562
|
+
const chunk = unique.slice(i, i + 2000);
|
|
563
|
+
const out = sealedGitOut(checkout, ["cat-file", "--batch"], { input: `${chunk.join("\n")}\n`, binary: true });
|
|
564
|
+
let at = 0;
|
|
565
|
+
for (const oid of chunk) {
|
|
566
|
+
const nl = out.indexOf(0x0a, at);
|
|
567
|
+
const header = out.subarray(at, nl).toString("utf8").split(" ");
|
|
568
|
+
if (header[0] !== oid || header[1] !== "blob") throw new SnapshotError("error", `git cat-file answered ${JSON.stringify(header.join(" "))} for ${oid}`);
|
|
569
|
+
const size = Number(header[2]);
|
|
570
|
+
blobs.set(oid, out.subarray(nl + 1, nl + 1 + size));
|
|
571
|
+
at = nl + 1 + size + 1;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return blobs;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const inside = (child, parent) => isInside(child, parent, path);
|
|
578
|
+
const lstat = (p) => { try { return fs.lstatSync(p, { bigint: true }); } catch (e) { if (e.code === "ENOENT" || e.code === "ENOTDIR") return null; throw e; } };
|
|
579
|
+
const statKey = (s) => `${s.size}:${s.mtimeNs}:${s.ctimeNs}:${s.ino}`;
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Exclusive per-snapshot lock. A process holds it only by publishing `lockFile` exclusively, and the lock appears whole: its body
|
|
583
|
+
* is written to a private file first and hard-linked into place (`link` fails if the name exists), so no lock is ever observable
|
|
584
|
+
* empty or partial and every lock carries a random token. A lock whose holder process is gone on this host is stale; reclaiming
|
|
585
|
+
* it is serialised by a second exclusive file, `<lockFile>.reclaim`: the one reclaimer re-reads the lock under it, and removes it
|
|
586
|
+
* only if it is still byte-for-byte the stale lock it judged (a dead holder cannot release, no other reclaimer can act, and a
|
|
587
|
+
* newer lock always differs by its token), then competes for the lock by the same exclusive publish as everyone else. A live
|
|
588
|
+
* lock is never moved or removed by anyone but its holder, so two processes never both hold it. Busy, never reclaimed: a live
|
|
589
|
+
* holder, another host's lock, a lock that cannot be parsed (left by a writer of another kind or a damaged disk), a reclaim in
|
|
590
|
+
* progress. A reclaimer that died mid-reclaim leaves the reclaim file and every later sync is busy until it is removed (fail
|
|
591
|
+
* closed); so does a dead holder whose pid was reused. `hooks` exist for the deterministic race fixture only.
|
|
592
|
+
*/
|
|
593
|
+
export function acquireSnapshotLock(lockFile, hooks = {}) {
|
|
594
|
+
const body = JSON.stringify({ pid: process.pid, host: os.hostname(), startedAt: new Date().toISOString(), token: crypto.randomUUID() });
|
|
595
|
+
const held = () => { try { return fs.readFileSync(lockFile, "utf8") === body; } catch { return false; } };
|
|
596
|
+
const busy = (why) => new SnapshotError("busy", why);
|
|
597
|
+
const publish = (target, text) => {
|
|
598
|
+
const tmp = `${target}.${crypto.randomUUID()}.tmp`;
|
|
599
|
+
fs.writeFileSync(tmp, text, { flag: "wx", mode: 0o600 });
|
|
600
|
+
try { fs.linkSync(tmp, target); return true; }
|
|
601
|
+
catch (e) { if (e.code === "EEXIST") return false; throw new SnapshotError("error", `cannot create ${target}: ${e.message}`); }
|
|
602
|
+
finally { fs.rmSync(tmp, { force: true }); }
|
|
603
|
+
};
|
|
604
|
+
const create = () => {
|
|
605
|
+
if (!publish(lockFile, body)) return false;
|
|
606
|
+
if (!held()) throw busy(`snapshot lock ${lockFile} was replaced by another process`);
|
|
607
|
+
const release = () => { if (held()) fs.rmSync(lockFile, { force: true }); };
|
|
608
|
+
release.held = held;
|
|
609
|
+
return release;
|
|
610
|
+
};
|
|
611
|
+
const read = () => { try { return fs.readFileSync(lockFile, "utf8"); } catch (e) { if (e.code === "ENOENT") return null; throw new SnapshotError("error", `cannot read ${lockFile}: ${e.message}`); } };
|
|
612
|
+
const judgeStale = (observed) => {
|
|
613
|
+
let holder = null;
|
|
614
|
+
try { holder = JSON.parse(observed); } catch { holder = null; }
|
|
615
|
+
// Locks appear whole, so one that cannot be parsed was not written by this protocol: never reclaimed automatically.
|
|
616
|
+
if (!holder || !Number.isInteger(holder.pid) || holder.pid <= 0 || holder.pid > 0x7fffffff || typeof holder.host !== "string" || !holder.host) throw busy(`snapshot lock ${lockFile} cannot be read as a lock; remove it only if no sync is running`);
|
|
617
|
+
if (holder.host !== os.hostname()) throw busy(`snapshot is held by pid ${holder.pid} on ${holder.host} since ${holder.startedAt}`);
|
|
618
|
+
// Only a holder proven gone (ESRCH) is stale; any other answer, EPERM included, leaves the lock alone.
|
|
619
|
+
let probe = null;
|
|
620
|
+
try { process.kill(holder.pid, 0); } catch (k) { probe = k.code ?? String(k); }
|
|
621
|
+
if (probe === null) throw busy(`snapshot is held by pid ${holder.pid} since ${holder.startedAt}`);
|
|
622
|
+
if (probe !== "ESRCH") throw busy(`snapshot lock holder pid ${holder.pid} cannot be proven gone (${probe})`);
|
|
623
|
+
};
|
|
624
|
+
const first = create();
|
|
625
|
+
if (first) return first;
|
|
626
|
+
const observed = read();
|
|
627
|
+
if (observed === null) { const again = create(); if (again) return again; throw busy(`snapshot lock ${lockFile} was taken by another process`); }
|
|
628
|
+
judgeStale(observed);
|
|
629
|
+
hooks.afterStaleRead?.();
|
|
630
|
+
const reclaim = `${lockFile}.reclaim`;
|
|
631
|
+
if (!publish(reclaim, body)) throw busy(`snapshot lock ${lockFile} is being reclaimed by another process (remove ${reclaim} only if no sync is running)`);
|
|
632
|
+
try {
|
|
633
|
+
hooks.whileReclaiming?.();
|
|
634
|
+
if (read() !== observed) throw busy(`snapshot lock ${lockFile} changed before it could be reclaimed`);
|
|
635
|
+
fs.rmSync(lockFile, { force: true });
|
|
636
|
+
const mine = create();
|
|
637
|
+
if (!mine) throw busy(`snapshot lock ${lockFile} was taken by another process`);
|
|
638
|
+
return mine;
|
|
639
|
+
} finally {
|
|
640
|
+
fs.rmSync(reclaim, { force: true });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function readState(file) {
|
|
645
|
+
try { const s = JSON.parse(fs.readFileSync(file, "utf8")); return s?.schema === STATE_SCHEMA ? s : null; } catch { return null; }
|
|
646
|
+
}
|
|
647
|
+
function writeState(file, state) {
|
|
648
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
649
|
+
fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
|
|
650
|
+
fs.renameSync(tmp, file);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** A link's target as git records it: Windows reports `..\\x` for a link made from `../x`, so separators compare as `/` there. */
|
|
654
|
+
const linkText = (text) => (process.platform === "win32" ? text.replace(/\\/g, "/") : text);
|
|
655
|
+
|
|
656
|
+
/** Removes `target` without following links: a link or file is unlinked, a directory removed with its contents. */
|
|
657
|
+
function removeEntry(target) {
|
|
658
|
+
const s = lstat(target);
|
|
659
|
+
if (!s) return false;
|
|
660
|
+
if (s.isDirectory()) fs.rmSync(target, { recursive: true, force: true }); else fs.unlinkSync(target);
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** Every link under `dir`, never followed (a Windows junction is a link here too). */
|
|
665
|
+
function linksUnder(dir) {
|
|
666
|
+
const found = [];
|
|
667
|
+
const walk = (d) => {
|
|
668
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
669
|
+
const p = path.join(d, e.name);
|
|
670
|
+
if (e.isSymbolicLink()) found.push(p);
|
|
671
|
+
else if (e.isDirectory()) walk(p);
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
walk(dir);
|
|
675
|
+
return found;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* On a case-insensitive host (Mac, Windows) a snapshot entry that exists under another case than the commit's spelling (a commit
|
|
680
|
+
* that renames a file or directory by case only, synced over its parent) is renamed to the exact spelling, keeping its bytes and
|
|
681
|
+
* mtime: the sealed identity runs with core.ignoreCase=false, so the on-disk spelling is part of the id (item 5 D3, probe P3d).
|
|
682
|
+
* `listing` caches directory listings for one sync. Returns how many components it renamed.
|
|
683
|
+
*/
|
|
684
|
+
function exactCase(dir, rel, listing, platform = process.platform) {
|
|
685
|
+
if (platform !== "darwin" && platform !== "win32") return 0;
|
|
686
|
+
let at = dir; let fixed = 0;
|
|
687
|
+
for (const part of rel.split("/")) {
|
|
688
|
+
let names = listing.get(at);
|
|
689
|
+
if (!names) { try { names = new Set(fs.readdirSync(at)); } catch { return fixed; } listing.set(at, names); }
|
|
690
|
+
if (!names.has(part)) {
|
|
691
|
+
const variant = [...names].find((n) => n.toLowerCase() === part.toLowerCase());
|
|
692
|
+
if (variant === undefined) return fixed;
|
|
693
|
+
const tmp = path.join(at, `.rk-case-${crypto.randomUUID()}`);
|
|
694
|
+
fs.renameSync(path.join(at, variant), tmp);
|
|
695
|
+
fs.renameSync(tmp, path.join(at, part));
|
|
696
|
+
names.delete(variant); names.add(part);
|
|
697
|
+
fixed++;
|
|
698
|
+
}
|
|
699
|
+
at = path.join(at, part);
|
|
700
|
+
}
|
|
701
|
+
return fixed;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** Makes every ancestor of `file` below `dir` a real directory: a file or link in the way is removed, never written through. */
|
|
705
|
+
function ensureParents(dir, file) {
|
|
706
|
+
let at = dir;
|
|
707
|
+
for (const part of path.relative(dir, path.dirname(file)).split(path.sep).filter(Boolean)) {
|
|
708
|
+
at = path.join(at, part);
|
|
709
|
+
const s = lstat(at);
|
|
710
|
+
if (s?.isDirectory()) continue;
|
|
711
|
+
if (s) removeEntry(at);
|
|
712
|
+
fs.mkdirSync(at, { mode: 0o755 });
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Syncs commit `rev` of the repository at `checkout` into its snapshot directory and verifies the result is exactly the commit's
|
|
718
|
+
* tree. Returns `{ schema, path, repoKey, commit, tree, written, removed, modesFixed, unchanged, linksRemoved, hardLinksReplaced, ms }`.
|
|
719
|
+
* Throws SnapshotError: "refused" before anything is written when the commit cannot be held exactly (a gitlink, an unsafe path, an
|
|
720
|
+
* unknown revision) or the root is not one a snapshot may live in (a missing or boot-volume managed root, an open-mode root);
|
|
721
|
+
* "busy" while another sync holds the snapshot; "mismatch" when the result is not the commit's tree; "error" when git or the
|
|
722
|
+
* filesystem fails. `managedRoot` (default: the host's, when `root` is or lies under the host's snapshot root) is the managed root
|
|
723
|
+
* that must already exist; `device` reads a path's device (tests inject it).
|
|
724
|
+
*/
|
|
725
|
+
export function syncSnapshot({ checkout, rev = "HEAD", root = snapshotRoot(), managedRoot = hostManagedRootFor(root), device, now = () => Date.now() } = {}) {
|
|
726
|
+
const started = now();
|
|
727
|
+
const repo = repositoryOf(checkout);
|
|
728
|
+
const { commit, tree } = resolveCommit(repo.checkout, rev);
|
|
729
|
+
const entries = treeEntries(repo.checkout, commit);
|
|
730
|
+
refuseCaseCollisions(entries, commit);
|
|
731
|
+
// Before the first write: the managed root exists on the managed volume, and an existing root is private to this user.
|
|
732
|
+
guardRoot(root, managedRoot, { device });
|
|
733
|
+
// mkdir returns the first directory it created: a root that did not exist is born now, and build ownership keeps that birth (and
|
|
734
|
+
// the boot it happened in) as the baseline for processes it cannot read.
|
|
735
|
+
// A root made before birth records existed is emptied and born anew, only under this process's own free-host ownership, before
|
|
736
|
+
// this sync's first write (build-ownership.mjs migrateBirthlessRoot).
|
|
737
|
+
migrateBirthlessRoot(root);
|
|
738
|
+
if (fs.mkdirSync(root, { recursive: true, mode: 0o700 }) !== undefined) recordRootBirth(root);
|
|
739
|
+
const rootStat = lstat(root);
|
|
740
|
+
if (!rootStat?.isDirectory()) throw new SnapshotError("refused", `snapshot root ${root} is not a directory (a link is never followed)`);
|
|
741
|
+
const dir = path.join(root, repo.key);
|
|
742
|
+
if (inside(dir, repo.checkout) || inside(repo.checkout, dir)) throw new SnapshotError("refused", `snapshot ${dir} and checkout ${repo.checkout} overlap`);
|
|
743
|
+
const release = acquireSnapshotLock(`${dir}.lock`);
|
|
744
|
+
const assertLockHeld = () => { if (!release.held()) throw new SnapshotError("busy", `snapshot lock ${dir}.lock is no longer held by this sync`); };
|
|
745
|
+
try {
|
|
746
|
+
const existing = lstat(dir);
|
|
747
|
+
if (existing && !existing.isDirectory()) removeEntry(dir);
|
|
748
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
|
|
749
|
+
const stateFile = `${dir}.state.json`;
|
|
750
|
+
const prior = readState(stateFile);
|
|
751
|
+
const wanted = new Map(entries.map((e) => [e.path, e]));
|
|
752
|
+
const cache = prior?.stat ?? {};
|
|
753
|
+
let removed = 0, written = 0, modesFixed = 0, unchanged = 0, linksRemoved = 0, hardLinksReplaced = 0, casesFixed = 0;
|
|
754
|
+
const caseListing = new Map();
|
|
755
|
+
|
|
756
|
+
// Where a path physically lands, following every link on the way: `path.resolve` collapses `..` lexically and would miss a
|
|
757
|
+
// chain such as `c/b -> ..` that the operating system follows (Oracle, step 1 review).
|
|
758
|
+
const realDir = fs.realpathSync.native(dir);
|
|
759
|
+
if (inside(realDir, repo.checkout) || inside(repo.checkout, realDir)) throw new SnapshotError("refused", `snapshot ${realDir} and checkout ${repo.checkout} overlap`);
|
|
760
|
+
const landsSafely = (p) => { let real; try { real = fs.realpathSync.native(p); } catch { return false; } return inside(real, realDir) && !inside(real, repo.checkout); };
|
|
761
|
+
const parentSafe = (target) => {
|
|
762
|
+
if (!landsSafely(path.dirname(target))) throw new SnapshotError("refused", `${path.relative(dir, target)}: its directory resolves out of the snapshot; nothing is written through it`);
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
const sweepLinks = (keep = new Set()) => {
|
|
766
|
+
for (const link of linksUnder(dir)) if (!keep.has(link) && !landsSafely(link)) { removeEntry(link); linksRemoved++; }
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
// 1. A link already here (planted, or from an earlier sync) stays only if it lands inside the snapshot and not in the checkout.
|
|
770
|
+
// This runs before anything is removed or written, so no later step can pass through such a link. Nothing is deleted by a
|
|
771
|
+
// path that crosses a link: stale entries go only in the final walk, which enters real directories alone (Oracle, re-review).
|
|
772
|
+
assertLockHeld();
|
|
773
|
+
sweepLinks();
|
|
774
|
+
|
|
775
|
+
// 3. Every tracked file: unchanged ones are left alone (same bytes & mode keep their mtime), the rest are written. Links are
|
|
776
|
+
// created only after every file, so creating one can never redirect a later write.
|
|
777
|
+
const need = [];
|
|
778
|
+
const links = [];
|
|
779
|
+
const nextStat = {};
|
|
780
|
+
for (const e of entries) {
|
|
781
|
+
const target = path.join(dir, ...e.path.split("/"));
|
|
782
|
+
if (e.mode === LINK_MODE) { links.push(e); continue; }
|
|
783
|
+
casesFixed += exactCase(dir, e.path, caseListing);
|
|
784
|
+
ensureParents(dir, target);
|
|
785
|
+
const s = lstat(target);
|
|
786
|
+
// A hard-linked file (another name for it outside this snapshot, perhaps a checkout file with equal bytes) is never kept, and
|
|
787
|
+
// never chmod-ed or written through: it is replaced by a fresh copy (temp + rename), which leaves the other name alone.
|
|
788
|
+
if (s?.isFile() && s.nlink > 1n) hardLinksReplaced++;
|
|
789
|
+
else if (s?.isFile()) {
|
|
790
|
+
const known = cache[e.path];
|
|
791
|
+
const same = known && known.oid === e.oid && known.stat === statKey(s) ? true : blobId(fs.readFileSync(target), repo.objectFormat) === e.oid;
|
|
792
|
+
if (same) {
|
|
793
|
+
const exec = (Number(s.mode) & 0o111) !== 0;
|
|
794
|
+
if (process.platform !== "win32" && exec !== (e.mode === "100755")) { fs.chmodSync(target, e.mode === "100755" ? 0o755 : 0o644); modesFixed++; }
|
|
795
|
+
else unchanged++;
|
|
796
|
+
nextStat[e.path] = { oid: e.oid, stat: statKey(lstat(target)) };
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
need.push(e);
|
|
801
|
+
}
|
|
802
|
+
// Writes land in this sync's own staging directory on the same volume, then rename into place: an interrupted sync never
|
|
803
|
+
// leaves a half-written or stray file inside the snapshot. Blobs are read and written a chunk at a time.
|
|
804
|
+
const stagingRoot = `${dir}.staging`;
|
|
805
|
+
// The snapshot lock is held, so any staging directory still here belongs to a sync that died: it is removed, never accumulated.
|
|
806
|
+
fs.rmSync(stagingRoot, { recursive: true, force: true });
|
|
807
|
+
const staging = path.join(stagingRoot, crypto.randomUUID());
|
|
808
|
+
fs.mkdirSync(staging, { recursive: true, mode: 0o700 });
|
|
809
|
+
try {
|
|
810
|
+
let serial = 0;
|
|
811
|
+
for (let i = 0; i < need.length; i += BLOB_CHUNK) {
|
|
812
|
+
const chunk = need.slice(i, i + BLOB_CHUNK);
|
|
813
|
+
const blobs = readBlobs(repo.checkout, chunk.map((e) => e.oid));
|
|
814
|
+
for (const e of chunk) {
|
|
815
|
+
assertLockHeld();
|
|
816
|
+
const target = path.join(dir, ...e.path.split("/"));
|
|
817
|
+
ensureParents(dir, target);
|
|
818
|
+
parentSafe(target);
|
|
819
|
+
const s = lstat(target);
|
|
820
|
+
if (s && !s.isFile()) removeEntry(target);
|
|
821
|
+
const tmp = path.join(staging, String(serial++));
|
|
822
|
+
fs.writeFileSync(tmp, blobs.get(e.oid), { mode: e.mode === "100755" ? 0o755 : 0o644 });
|
|
823
|
+
if (process.platform !== "win32") fs.chmodSync(tmp, e.mode === "100755" ? 0o755 : 0o644);
|
|
824
|
+
fs.renameSync(tmp, target);
|
|
825
|
+
nextStat[e.path] = { oid: e.oid, stat: statKey(lstat(target)) };
|
|
826
|
+
written++;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
assertLockHeld();
|
|
830
|
+
const linkBlobs = readBlobs(repo.checkout, links.map((e) => e.oid));
|
|
831
|
+
for (const e of links) {
|
|
832
|
+
const target = path.join(dir, ...e.path.split("/"));
|
|
833
|
+
const to = linkBlobs.get(e.oid).toString("utf8");
|
|
834
|
+
if (path.isAbsolute(to) || path.win32.isAbsolute(to)) throw new SnapshotError("refused", `commit ${commit} links ${e.path} -> ${to}: an absolute link cannot stay inside the snapshot`);
|
|
835
|
+
casesFixed += exactCase(dir, e.path, new Map());
|
|
836
|
+
ensureParents(dir, target);
|
|
837
|
+
parentSafe(target);
|
|
838
|
+
const s = lstat(target);
|
|
839
|
+
if (s?.isSymbolicLink() && linkText(fs.readlinkSync(target)) === to) { unchanged++; continue; }
|
|
840
|
+
if (s) removeEntry(target);
|
|
841
|
+
try { fs.symlinkSync(to, target); }
|
|
842
|
+
catch (err) { throw new SnapshotError("error", `cannot create the link ${e.path} -> ${to}: ${err.code ?? err.message}${process.platform === "win32" ? " (Windows creates symbolic links only with Developer Mode or elevation; a commit holding a link cannot be synced without it)" : ""}`); }
|
|
843
|
+
written++;
|
|
844
|
+
}
|
|
845
|
+
// Every committed link must land, physically, on something inside the snapshot that is not in the checkout. One that does
|
|
846
|
+
// not (an escape through a chain, a dangling or looping link) is removed before the refusal, so it is never left in place.
|
|
847
|
+
const unsafe = links.map((e) => e.path).filter((p) => !landsSafely(path.join(dir, ...p.split("/"))));
|
|
848
|
+
if (unsafe.length) {
|
|
849
|
+
for (const p of unsafe) removeEntry(path.join(dir, ...p.split("/")));
|
|
850
|
+
throw new SnapshotError("refused", `commit ${commit} has links that do not land inside the snapshot: ${unsafe.join(", ")}`);
|
|
851
|
+
}
|
|
852
|
+
// A planted link can resolve differently once the committed links exist: sweep the others again.
|
|
853
|
+
sweepLinks(new Set(links.map((e) => path.join(dir, ...e.path.split("/")))));
|
|
854
|
+
} finally {
|
|
855
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
856
|
+
try { fs.rmdirSync(stagingRoot); } catch { /* another sync's staging, or not empty */ }
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// 4. Everything the commit does not hold is removed: the snapshot is exactly the commit (§1).
|
|
860
|
+
assertLockHeld();
|
|
861
|
+
removed += pruneToTree(dir, entries);
|
|
862
|
+
|
|
863
|
+
assertLockHeld();
|
|
864
|
+
const extra = snapshotProblem({ dir, entries, checkout: repo.checkout });
|
|
865
|
+
if (extra) throw new SnapshotError("mismatch", `snapshot ${dir} is not exactly commit ${commit} after sync: ${extra}`);
|
|
866
|
+
const actual = snapshotTree({ checkout: repo.checkout, dir, baseTree: tree, repo });
|
|
867
|
+
if (actual !== tree) throw new SnapshotError("mismatch", `snapshot ${dir} is tree ${actual} after sync, not tree ${tree} of commit ${commit}`);
|
|
868
|
+
writeState(stateFile, { schema: STATE_SCHEMA, repo: repo.checkout, repoKey: repo.key, commit, tree, paths: [...wanted.keys()], stat: nextStat, syncedAt: new Date(now()).toISOString() });
|
|
869
|
+
return { schema: SNAPSHOT_SCHEMA, path: dir, repoKey: repo.key, commit, tree, written, removed, modesFixed, unchanged, linksRemoved, hardLinksReplaced, casesFixed, ms: now() - started };
|
|
870
|
+
} finally {
|
|
871
|
+
release();
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Verifies, without changing anything, that the snapshot of `checkout` holds exactly the tree of `rev`: its seeded tree id, no path
|
|
877
|
+
* the commit lacks (ignored files included), nothing of another type, mode, link text or hard-link count than the commit's, no link
|
|
878
|
+
* resolving out of the snapshot or into the checkout, and, when `environment` is given, the installed dependency environment the
|
|
879
|
+
* gate recorded after installing. `environment` is `{ expected, compute }`: `expected` is the `env=<sha256>` that
|
|
880
|
+
* `rightkit-gate env-digest --snapshot` printed after the install, `compute({ checkout, dir, commit, tree })` returns the current
|
|
881
|
+
* digest (the caller supplies it: the digest is the gate's, bin/rightkit-gate.mjs classAEnvironmentDigest); a difference is a
|
|
882
|
+
* mismatch, so dependency bytes changed or planted mid-stage refuse at the end verify. Callers run this at the start and again at
|
|
883
|
+
* the end of a stage (the stage-level calls are item 7, 7.08). `managedRoot` and `device` as for syncSnapshot.
|
|
884
|
+
*
|
|
885
|
+
* Stage-output ledger (row 5.09d, snapshotProblem): `ledger` is the array of entries an earlier verify returned (the start of a stage
|
|
886
|
+
* passes the whole ledger and no `admitNew`: nothing new, nothing changed, nothing missing); `admitNew: true` (the END of a stage
|
|
887
|
+
* only) also admits the ignored, untracked, regular-file-or-directory outputs the stage's children wrote. Either option makes the
|
|
888
|
+
* result carry `ledger: [{ path, type, size, sha256, execBit }]`, every admitted entry. The tree id is still the commit's tree:
|
|
889
|
+
* an ignored output does not change it. With neither option nothing beyond the commit (and 5.09's allowances) is accepted.
|
|
890
|
+
*/
|
|
891
|
+
export function verifySnapshot({ checkout, rev = "HEAD", root = snapshotRoot(), dir, managedRoot = hostManagedRootFor(root), device, environment, ledger, admitNew } = {}) {
|
|
892
|
+
const repo = repositoryOf(checkout);
|
|
893
|
+
const { commit, tree } = resolveCommit(repo.checkout, rev);
|
|
894
|
+
if (managedRoot) requireManagedRoot(managedRoot, { device });
|
|
895
|
+
const at = dir ?? path.join(root, repo.key);
|
|
896
|
+
// The root the snapshot sits directly in is private to this user, as at sync (a root another principal can use proves nothing).
|
|
897
|
+
if (path.resolve(path.dirname(at)) === path.resolve(root)) requirePrivateRoot(root, lstat(root));
|
|
898
|
+
if (!lstat(at)?.isDirectory()) throw new SnapshotError("mismatch", `no snapshot directory at ${at}`);
|
|
899
|
+
// The seeded tree id cannot see an ignored file or follow a link, so the directory itself is walked first (E6, §1 exact snapshot).
|
|
900
|
+
const inspected = snapshotProblem({ dir: at, entries: treeEntries(repo.checkout, commit), checkout: repo.checkout, ledger, admitNew });
|
|
901
|
+
const extra = ledger !== undefined || admitNew ? inspected.problem : inspected;
|
|
902
|
+
if (extra) throw new SnapshotError("mismatch", `the snapshot at ${at} is not exactly commit ${commit}${ledger !== undefined || admitNew ? " plus its stage-output ledger" : ""}: ${extra}`);
|
|
903
|
+
const actual = snapshotTree({ checkout: repo.checkout, dir: at, baseTree: tree, repo });
|
|
904
|
+
if (actual !== tree) throw new SnapshotError("mismatch", `the snapshot at ${at} is tree ${actual}, not tree ${tree} of commit ${commit}`);
|
|
905
|
+
if (environment !== undefined) {
|
|
906
|
+
if (!environment || !/^[0-9a-f]{64}$/.test(String(environment.expected ?? "")) || typeof environment.compute !== "function") throw new SnapshotError("error", "verify with an environment needs { expected: <64 hex env digest>, compute: <function> }");
|
|
907
|
+
let now;
|
|
908
|
+
try { now = environment.compute({ checkout: repo.checkout, dir: at, commit, tree }); }
|
|
909
|
+
catch (e) { throw new SnapshotError("error", `the environment digest of the snapshot at ${at} cannot be computed: ${e.message}`); }
|
|
910
|
+
if (now !== environment.expected) throw new SnapshotError("mismatch", `the installed environment of the snapshot at ${at} changed: env=${now}, recorded env=${environment.expected}`);
|
|
911
|
+
}
|
|
912
|
+
return { schema: SNAPSHOT_SCHEMA, path: at, repoKey: repo.key, commit, tree, ...(environment !== undefined ? { env: environment.expected } : {}), ...(ledger !== undefined || admitNew ? { ledger: inspected.admitted } : {}) };
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* `rightkit-gate snapshot [--commit <rev>] [--verify [--env <sha256>]] [--json]`: sync (or with --verify only check) the commit's
|
|
917
|
+
* snapshot. `--env <sha256>` (verify only) also requires the installed dependency environment to still be the recorded
|
|
918
|
+
* `rightkit-gate env-digest --snapshot` digest; the gate supplies `deps.environmentDigest({ checkout, dir, commit, tree })`.
|
|
919
|
+
* Exit 0 the snapshot is the commit's tree and the build ownership taken for it was released, 1 mismatch / refused (unknown
|
|
920
|
+
* revision, a missing managed root, an open-mode root, a gitlink ...) / busy or the ownership record was kept (a survivor may still
|
|
921
|
+
* read the snapshot), 2 could not run. `o.home`, `o.configFile`, `o.managedRoot`, `o.device` are test injections.
|
|
922
|
+
*/
|
|
923
|
+
export function snapshotCommand(o, args, deps = {}) {
|
|
924
|
+
const allowed = new Set(["--commit", "--verify", "--json", "--env"]);
|
|
925
|
+
for (let i = 0; i < args.length; i++) {
|
|
926
|
+
if (!allowed.has(args[i])) { o.err(`rightkit-gate: snapshot takes only --commit <rev>, --verify, --env <sha256> and --json, not ${JSON.stringify(args[i])}`); return 2; }
|
|
927
|
+
if (args[i] === "--commit") { if (!args[i + 1] || args[i + 1].startsWith("--")) { o.err("rightkit-gate: snapshot --commit needs a revision"); return 2; } i++; }
|
|
928
|
+
if (args[i] === "--env") { if (!/^[0-9a-f]{64}$/.test(args[i + 1] ?? "")) { o.err("rightkit-gate: snapshot --env needs the 64-hex digest `rightkit-gate env-digest --snapshot` printed"); return 2; } i++; }
|
|
929
|
+
}
|
|
930
|
+
const verify = args.includes("--verify");
|
|
931
|
+
const envAt = args.indexOf("--env");
|
|
932
|
+
if (envAt >= 0 && !verify) { o.err("rightkit-gate: snapshot --env only checks a snapshot: it needs --verify"); return 2; }
|
|
933
|
+
if (envAt >= 0 && typeof deps.environmentDigest !== "function") { o.err("rightkit-gate: snapshot --env cannot compute the environment digest in this process"); return 2; }
|
|
934
|
+
const i = args.indexOf("--commit");
|
|
935
|
+
const rev = i >= 0 ? args[i + 1] : "HEAD";
|
|
936
|
+
let ownership = null;
|
|
937
|
+
let code = 2;
|
|
938
|
+
try {
|
|
939
|
+
const platform = o.platform ?? process.platform;
|
|
940
|
+
const root = o.snapshotRoot ?? snapshotRoot({ platform, env: o.env, ...(o.home ? { home: o.home } : {}), ...(o.configFile ? { configFile: o.configFile } : {}) });
|
|
941
|
+
const managedRoot = o.managedRoot !== undefined ? o.managedRoot : hostManagedRootFor(root, { platform });
|
|
942
|
+
// The managed root exists (the gate never creates it) and the root is private, BEFORE build ownership is taken: taking it
|
|
943
|
+
// creates directories of its own, and must never be what brings a missing managed root into being.
|
|
944
|
+
guardRoot(root, managedRoot, { device: o.device });
|
|
945
|
+
// A sync replaces snapshot bytes, so it runs only under the host's build ownership, taken before the first write (joined when
|
|
946
|
+
// this gate already holds it); a verify only reads.
|
|
947
|
+
if (!verify) {
|
|
948
|
+
const repo = repositoryOf(o.cwd);
|
|
949
|
+
ownership = (o.acquireOwnership ?? acquireBuildOwnership)({ file: ownershipFile({ platform: o.platform, beside: o.snapshotRoot }), stage: "snapshot", snapshot: path.join(root, repo.key), commit: resolveCommit(repo.checkout, rev).commit, platform: o.platform, env: o.env });
|
|
950
|
+
}
|
|
951
|
+
const r = verify ? verifySnapshot({ checkout: o.cwd, rev, root, managedRoot, device: o.device, ...(envAt >= 0 ? { environment: { expected: args[envAt + 1], compute: deps.environmentDigest } } : {}) })
|
|
952
|
+
: syncSnapshot({ checkout: o.cwd, rev, root, managedRoot, device: o.device });
|
|
953
|
+
if (ownership) r.ownership = { handle: ownership.handle, joined: ownership.joined, reclaimed: ownership.reclaimed };
|
|
954
|
+
if (args.includes("--json")) o.out(JSON.stringify(r));
|
|
955
|
+
else o.out(`rightkit-gate: snapshot ${r.path} commit=${r.commit} tree=${r.tree}${"written" in r ? ` written=${r.written} removed=${r.removed} modes=${r.modesFixed} links=${r.linksRemoved} unchanged=${r.unchanged} ms=${r.ms}` : " verified"}`);
|
|
956
|
+
code = 0;
|
|
957
|
+
} catch (e) {
|
|
958
|
+
o.err(`rightkit-gate: snapshot ${e.code ?? "error"}${e.reason ? ` (${e.reason})` : ""}: ${e.message}`);
|
|
959
|
+
code = (e instanceof SnapshotError && e.code !== "error") || e instanceof OwnershipBusy ? 1 : 2;
|
|
960
|
+
} finally {
|
|
961
|
+
const r = ownership?.release();
|
|
962
|
+
// A kept record means a survivor may still read the snapshot (or the host is not released): never a clean exit for a caller to script on.
|
|
963
|
+
if (r?.released === false && !ownership.joined) {
|
|
964
|
+
o.err(`rightkit-gate: snapshot failed: build ownership ${ownership.handle} kept: ${r.why}`);
|
|
965
|
+
if (code === 0) code = 1;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return code;
|
|
969
|
+
}
|