@rightkit/release 0.6.8 → 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.
Files changed (52) hide show
  1. package/README.md +25 -9
  2. package/admission-files.mjs +1 -1
  3. package/app-check.mjs +94 -20
  4. package/assemble-app-command.mjs +151 -0
  5. package/assembled-app.mjs +445 -0
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/darwin/rightkit-swap.sign.json +6 -0
  8. package/bin/rightkit-gate.mjs +928 -82
  9. package/build-launch.mjs +88 -0
  10. package/build-ownership.mjs +1231 -0
  11. package/build-release.mjs +2 -1
  12. package/cargo-contract.mjs +3 -1
  13. package/cargo-target-shim.mjs +166 -0
  14. package/class-a-launch-guard.mjs +126 -0
  15. package/cli/right-release.mjs +3 -1
  16. package/codesign-shim.mjs +11 -1
  17. package/deterministic-scans.mjs +807 -56
  18. package/development-signing.mjs +4 -1
  19. package/development.mjs +110 -20
  20. package/door-policy.mjs +61 -6
  21. package/gate-executor.mjs +14 -2
  22. package/gate-stages.mjs +15 -5
  23. package/heavy-command.mjs +3 -2
  24. package/install-app.mjs +11 -2
  25. package/install-assembled-app.mjs +423 -0
  26. package/ios-dev.mjs +70 -3
  27. package/linux-artifacts.mjs +93 -0
  28. package/local-publish.mjs +161 -26
  29. package/managed-cargo.mjs +47 -5
  30. package/managed-root-guard.mjs +34 -0
  31. package/native-helpers.mjs +355 -0
  32. package/package-recipe-targets.mjs +52 -7
  33. package/package.json +4 -3
  34. package/pipeline-receipts.mjs +302 -302
  35. package/project-type.mjs +2 -2
  36. package/provenance-verify.mjs +115 -0
  37. package/publish-cargo.mjs +8 -6
  38. package/qa-admission-policy.json +10 -1
  39. package/recipe-build.mjs +73 -5
  40. package/release-config.mjs +1 -1
  41. package/release.mjs +15 -5
  42. package/rightkit-versions.json +13 -7
  43. package/snapshot-check.mjs +306 -0
  44. package/snapshot-git.mjs +166 -0
  45. package/snapshot-request.mjs +161 -0
  46. package/snapshot-root-config.mjs +76 -0
  47. package/snapshot.mjs +856 -0
  48. package/stage-commands.mjs +60 -0
  49. package/stage-sidecar.mjs +57 -7
  50. package/test-command.mjs +17 -4
  51. package/unit-gate.mjs +259 -0
  52. package/web-build.mjs +38 -6
package/snapshot.mjs ADDED
@@ -0,0 +1,856 @@
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
+ export function snapshotProblem({ dir, entries, checkout, platform = process.platform, originUrl }) {
368
+ const fold = foldFor(platform);
369
+ const byKey = new Map(entries.map((e) => [fold(e.path), e]));
370
+ const dirs = new Set();
371
+ 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("/"))); }
372
+ const deps = dependencyDirs(entries, platform);
373
+ let realDir; let realCheckout = checkout;
374
+ try { realDir = fs.realpathSync.native(dir); } catch (e) { return `${dir} cannot be resolved: ${e.message}`; }
375
+ try { realCheckout = fs.realpathSync.native(checkout); } catch { /* the path as given */ }
376
+ const linkProblem = (rel, p) => {
377
+ let real;
378
+ 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`; }
379
+ if (inside(real, realCheckout)) return `${rel} is a link resolving into the editable checkout (${real})`;
380
+ if (!inside(real, realDir)) return `${rel} is a link resolving outside the snapshot (${real})`;
381
+ return null;
382
+ };
383
+ // The committed text of every committed link, read once, lazily (a tree with no links reads nothing).
384
+ let linkTexts = null;
385
+ const committedLinkText = (entry) => {
386
+ if (!linkTexts) {
387
+ const links = entries.filter((x) => x.mode === LINK_MODE);
388
+ const blobs = links.length ? readBlobs(checkout, links.map((x) => x.oid)) : new Map();
389
+ linkTexts = new Map(links.map((x) => [x.path, blobs.get(x.oid).toString("utf8")]));
390
+ }
391
+ return linkTexts.get(entry.path);
392
+ };
393
+ const gitConfigProblem = (rel, p) => {
394
+ const names = fs.readdirSync(p);
395
+ 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)`;
396
+ const config = path.join(p, "config");
397
+ const s = lstat(config);
398
+ const origin = originUrl ?? originUrlOf(checkout);
399
+ if (!origin) return `${rel} is not in the commit's tree (the checkout has no origin, so the gate writes no .git/config)`;
400
+ 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)`;
401
+ return null;
402
+ };
403
+ const fileProblem = (r, p, entry) => {
404
+ const s = lstat(p);
405
+ if (!s) return `${r} vanished while it was checked`;
406
+ if (!s.isFile() && !s.isSymbolicLink()) return `${r} is not a regular file or a link`;
407
+ if (s.nlink > 1n) return `${r} has ${s.nlink} hard links: a checkout edit made through another name for it would reach the build`;
408
+ const committedLink = entry.mode === LINK_MODE;
409
+ 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`;
410
+ if (committedLink) {
411
+ const want = committedLinkText(entry);
412
+ const have = linkText(fs.readlinkSync(p));
413
+ if (have !== want) return `${r} is a link to ${JSON.stringify(have)}, but the commit's link is to ${JSON.stringify(want)}`;
414
+ } else if (platform !== "win32" && ((Number(s.mode) & 0o111) !== 0) !== (entry.mode === "100755")) {
415
+ return `${r} has mode ${(Number(s.mode) & 0o777).toString(8)}, but the commit holds ${entry.mode === "100755" ? "an executable" : "a non-executable"} file`;
416
+ }
417
+ return null;
418
+ };
419
+ const walk = (d, rel, inDeps) => {
420
+ const names = fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
421
+ for (const e of names) {
422
+ const r = rel ? `${rel}/${e.name}` : e.name;
423
+ const p = path.join(d, e.name);
424
+ const key = fold(r);
425
+ let found = null;
426
+ if (e.isSymbolicLink()) {
427
+ // 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.
428
+ found = linkProblem(r, p);
429
+ if (!found && !inDeps) found = !byKey.has(key) ? `${r} is not in the commit's tree (a link the commit does not have)` : fileProblem(r, p, byKey.get(key));
430
+ } else if (e.isDirectory()) {
431
+ if (inDeps) found = walk(p, r, true);
432
+ else if (deps.has(key)) found = walk(p, r, true);
433
+ else if (dirs.has(key)) found = walk(p, r, false);
434
+ else if (key === ".git") found = gitConfigProblem(r, p);
435
+ else found = `${r} is not in the commit's tree (a directory the commit does not have)`;
436
+ } else if (!inDeps) found = !byKey.has(key) ? `${r} is not in the commit's tree` : fileProblem(r, p, byKey.get(key));
437
+ if (found) return found;
438
+ }
439
+ return null;
440
+ };
441
+ return walk(dir, "", false);
442
+ }
443
+
444
+ function blobId(content, objectFormat) {
445
+ const h = crypto.createHash(objectFormat === "sha256" ? "sha256" : "sha1");
446
+ h.update(`blob ${content.length}\0`);
447
+ h.update(content);
448
+ return h.digest("hex");
449
+ }
450
+
451
+ /** Blob contents for `oids` via `git cat-file --batch`, in bounded chunks. */
452
+ function readBlobs(checkout, oids) {
453
+ const blobs = new Map();
454
+ const unique = [...new Set(oids)];
455
+ for (let i = 0; i < unique.length; i += 2000) {
456
+ const chunk = unique.slice(i, i + 2000);
457
+ const out = sealedGitOut(checkout, ["cat-file", "--batch"], { input: `${chunk.join("\n")}\n`, binary: true });
458
+ let at = 0;
459
+ for (const oid of chunk) {
460
+ const nl = out.indexOf(0x0a, at);
461
+ const header = out.subarray(at, nl).toString("utf8").split(" ");
462
+ if (header[0] !== oid || header[1] !== "blob") throw new SnapshotError("error", `git cat-file answered ${JSON.stringify(header.join(" "))} for ${oid}`);
463
+ const size = Number(header[2]);
464
+ blobs.set(oid, out.subarray(nl + 1, nl + 1 + size));
465
+ at = nl + 1 + size + 1;
466
+ }
467
+ }
468
+ return blobs;
469
+ }
470
+
471
+ const inside = (child, parent) => isInside(child, parent, path);
472
+ const lstat = (p) => { try { return fs.lstatSync(p, { bigint: true }); } catch (e) { if (e.code === "ENOENT" || e.code === "ENOTDIR") return null; throw e; } };
473
+ const statKey = (s) => `${s.size}:${s.mtimeNs}:${s.ctimeNs}:${s.ino}`;
474
+
475
+ /**
476
+ * Exclusive per-snapshot lock. A process holds it only by publishing `lockFile` exclusively, and the lock appears whole: its body
477
+ * is written to a private file first and hard-linked into place (`link` fails if the name exists), so no lock is ever observable
478
+ * empty or partial and every lock carries a random token. A lock whose holder process is gone on this host is stale; reclaiming
479
+ * it is serialised by a second exclusive file, `<lockFile>.reclaim`: the one reclaimer re-reads the lock under it, and removes it
480
+ * 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
481
+ * newer lock always differs by its token), then competes for the lock by the same exclusive publish as everyone else. A live
482
+ * lock is never moved or removed by anyone but its holder, so two processes never both hold it. Busy, never reclaimed: a live
483
+ * 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
484
+ * progress. A reclaimer that died mid-reclaim leaves the reclaim file and every later sync is busy until it is removed (fail
485
+ * closed); so does a dead holder whose pid was reused. `hooks` exist for the deterministic race fixture only.
486
+ */
487
+ export function acquireSnapshotLock(lockFile, hooks = {}) {
488
+ const body = JSON.stringify({ pid: process.pid, host: os.hostname(), startedAt: new Date().toISOString(), token: crypto.randomUUID() });
489
+ const held = () => { try { return fs.readFileSync(lockFile, "utf8") === body; } catch { return false; } };
490
+ const busy = (why) => new SnapshotError("busy", why);
491
+ const publish = (target, text) => {
492
+ const tmp = `${target}.${crypto.randomUUID()}.tmp`;
493
+ fs.writeFileSync(tmp, text, { flag: "wx", mode: 0o600 });
494
+ try { fs.linkSync(tmp, target); return true; }
495
+ catch (e) { if (e.code === "EEXIST") return false; throw new SnapshotError("error", `cannot create ${target}: ${e.message}`); }
496
+ finally { fs.rmSync(tmp, { force: true }); }
497
+ };
498
+ const create = () => {
499
+ if (!publish(lockFile, body)) return false;
500
+ if (!held()) throw busy(`snapshot lock ${lockFile} was replaced by another process`);
501
+ const release = () => { if (held()) fs.rmSync(lockFile, { force: true }); };
502
+ release.held = held;
503
+ return release;
504
+ };
505
+ 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}`); } };
506
+ const judgeStale = (observed) => {
507
+ let holder = null;
508
+ try { holder = JSON.parse(observed); } catch { holder = null; }
509
+ // Locks appear whole, so one that cannot be parsed was not written by this protocol: never reclaimed automatically.
510
+ 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`);
511
+ if (holder.host !== os.hostname()) throw busy(`snapshot is held by pid ${holder.pid} on ${holder.host} since ${holder.startedAt}`);
512
+ // Only a holder proven gone (ESRCH) is stale; any other answer, EPERM included, leaves the lock alone.
513
+ let probe = null;
514
+ try { process.kill(holder.pid, 0); } catch (k) { probe = k.code ?? String(k); }
515
+ if (probe === null) throw busy(`snapshot is held by pid ${holder.pid} since ${holder.startedAt}`);
516
+ if (probe !== "ESRCH") throw busy(`snapshot lock holder pid ${holder.pid} cannot be proven gone (${probe})`);
517
+ };
518
+ const first = create();
519
+ if (first) return first;
520
+ const observed = read();
521
+ if (observed === null) { const again = create(); if (again) return again; throw busy(`snapshot lock ${lockFile} was taken by another process`); }
522
+ judgeStale(observed);
523
+ hooks.afterStaleRead?.();
524
+ const reclaim = `${lockFile}.reclaim`;
525
+ if (!publish(reclaim, body)) throw busy(`snapshot lock ${lockFile} is being reclaimed by another process (remove ${reclaim} only if no sync is running)`);
526
+ try {
527
+ hooks.whileReclaiming?.();
528
+ if (read() !== observed) throw busy(`snapshot lock ${lockFile} changed before it could be reclaimed`);
529
+ fs.rmSync(lockFile, { force: true });
530
+ const mine = create();
531
+ if (!mine) throw busy(`snapshot lock ${lockFile} was taken by another process`);
532
+ return mine;
533
+ } finally {
534
+ fs.rmSync(reclaim, { force: true });
535
+ }
536
+ }
537
+
538
+ function readState(file) {
539
+ try { const s = JSON.parse(fs.readFileSync(file, "utf8")); return s?.schema === STATE_SCHEMA ? s : null; } catch { return null; }
540
+ }
541
+ function writeState(file, state) {
542
+ const tmp = `${file}.${process.pid}.tmp`;
543
+ fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
544
+ fs.renameSync(tmp, file);
545
+ }
546
+
547
+ /** A link's target as git records it: Windows reports `..\\x` for a link made from `../x`, so separators compare as `/` there. */
548
+ const linkText = (text) => (process.platform === "win32" ? text.replace(/\\/g, "/") : text);
549
+
550
+ /** Removes `target` without following links: a link or file is unlinked, a directory removed with its contents. */
551
+ function removeEntry(target) {
552
+ const s = lstat(target);
553
+ if (!s) return false;
554
+ if (s.isDirectory()) fs.rmSync(target, { recursive: true, force: true }); else fs.unlinkSync(target);
555
+ return true;
556
+ }
557
+
558
+ /** Every link under `dir`, never followed (a Windows junction is a link here too). */
559
+ function linksUnder(dir) {
560
+ const found = [];
561
+ const walk = (d) => {
562
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
563
+ const p = path.join(d, e.name);
564
+ if (e.isSymbolicLink()) found.push(p);
565
+ else if (e.isDirectory()) walk(p);
566
+ }
567
+ };
568
+ walk(dir);
569
+ return found;
570
+ }
571
+
572
+ /**
573
+ * On a case-insensitive host (Mac, Windows) a snapshot entry that exists under another case than the commit's spelling (a commit
574
+ * that renames a file or directory by case only, synced over its parent) is renamed to the exact spelling, keeping its bytes and
575
+ * mtime: the sealed identity runs with core.ignoreCase=false, so the on-disk spelling is part of the id (item 5 D3, probe P3d).
576
+ * `listing` caches directory listings for one sync. Returns how many components it renamed.
577
+ */
578
+ function exactCase(dir, rel, listing, platform = process.platform) {
579
+ if (platform !== "darwin" && platform !== "win32") return 0;
580
+ let at = dir; let fixed = 0;
581
+ for (const part of rel.split("/")) {
582
+ let names = listing.get(at);
583
+ if (!names) { try { names = new Set(fs.readdirSync(at)); } catch { return fixed; } listing.set(at, names); }
584
+ if (!names.has(part)) {
585
+ const variant = [...names].find((n) => n.toLowerCase() === part.toLowerCase());
586
+ if (variant === undefined) return fixed;
587
+ const tmp = path.join(at, `.rk-case-${crypto.randomUUID()}`);
588
+ fs.renameSync(path.join(at, variant), tmp);
589
+ fs.renameSync(tmp, path.join(at, part));
590
+ names.delete(variant); names.add(part);
591
+ fixed++;
592
+ }
593
+ at = path.join(at, part);
594
+ }
595
+ return fixed;
596
+ }
597
+
598
+ /** Makes every ancestor of `file` below `dir` a real directory: a file or link in the way is removed, never written through. */
599
+ function ensureParents(dir, file) {
600
+ let at = dir;
601
+ for (const part of path.relative(dir, path.dirname(file)).split(path.sep).filter(Boolean)) {
602
+ at = path.join(at, part);
603
+ const s = lstat(at);
604
+ if (s?.isDirectory()) continue;
605
+ if (s) removeEntry(at);
606
+ fs.mkdirSync(at, { mode: 0o755 });
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Syncs commit `rev` of the repository at `checkout` into its snapshot directory and verifies the result is exactly the commit's
612
+ * tree. Returns `{ schema, path, repoKey, commit, tree, written, removed, modesFixed, unchanged, linksRemoved, hardLinksReplaced, ms }`.
613
+ * Throws SnapshotError: "refused" before anything is written when the commit cannot be held exactly (a gitlink, an unsafe path, an
614
+ * unknown revision) or the root is not one a snapshot may live in (a missing or boot-volume managed root, an open-mode root);
615
+ * "busy" while another sync holds the snapshot; "mismatch" when the result is not the commit's tree; "error" when git or the
616
+ * filesystem fails. `managedRoot` (default: the host's, when `root` is or lies under the host's snapshot root) is the managed root
617
+ * that must already exist; `device` reads a path's device (tests inject it).
618
+ */
619
+ export function syncSnapshot({ checkout, rev = "HEAD", root = snapshotRoot(), managedRoot = hostManagedRootFor(root), device, now = () => Date.now() } = {}) {
620
+ const started = now();
621
+ const repo = repositoryOf(checkout);
622
+ const { commit, tree } = resolveCommit(repo.checkout, rev);
623
+ const entries = treeEntries(repo.checkout, commit);
624
+ refuseCaseCollisions(entries, commit);
625
+ // Before the first write: the managed root exists on the managed volume, and an existing root is private to this user.
626
+ guardRoot(root, managedRoot, { device });
627
+ // mkdir returns the first directory it created: a root that did not exist is born now, and build ownership keeps that birth (and
628
+ // the boot it happened in) as the baseline for processes it cannot read.
629
+ // A root made before birth records existed is emptied and born anew, only under this process's own free-host ownership, before
630
+ // this sync's first write (build-ownership.mjs migrateBirthlessRoot).
631
+ migrateBirthlessRoot(root);
632
+ if (fs.mkdirSync(root, { recursive: true, mode: 0o700 }) !== undefined) recordRootBirth(root);
633
+ const rootStat = lstat(root);
634
+ if (!rootStat?.isDirectory()) throw new SnapshotError("refused", `snapshot root ${root} is not a directory (a link is never followed)`);
635
+ const dir = path.join(root, repo.key);
636
+ if (inside(dir, repo.checkout) || inside(repo.checkout, dir)) throw new SnapshotError("refused", `snapshot ${dir} and checkout ${repo.checkout} overlap`);
637
+ const release = acquireSnapshotLock(`${dir}.lock`);
638
+ const assertLockHeld = () => { if (!release.held()) throw new SnapshotError("busy", `snapshot lock ${dir}.lock is no longer held by this sync`); };
639
+ try {
640
+ const existing = lstat(dir);
641
+ if (existing && !existing.isDirectory()) removeEntry(dir);
642
+ fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
643
+ const stateFile = `${dir}.state.json`;
644
+ const prior = readState(stateFile);
645
+ const wanted = new Map(entries.map((e) => [e.path, e]));
646
+ const cache = prior?.stat ?? {};
647
+ let removed = 0, written = 0, modesFixed = 0, unchanged = 0, linksRemoved = 0, hardLinksReplaced = 0, casesFixed = 0;
648
+ const caseListing = new Map();
649
+
650
+ // Where a path physically lands, following every link on the way: `path.resolve` collapses `..` lexically and would miss a
651
+ // chain such as `c/b -> ..` that the operating system follows (Oracle, step 1 review).
652
+ const realDir = fs.realpathSync.native(dir);
653
+ if (inside(realDir, repo.checkout) || inside(repo.checkout, realDir)) throw new SnapshotError("refused", `snapshot ${realDir} and checkout ${repo.checkout} overlap`);
654
+ const landsSafely = (p) => { let real; try { real = fs.realpathSync.native(p); } catch { return false; } return inside(real, realDir) && !inside(real, repo.checkout); };
655
+ const parentSafe = (target) => {
656
+ 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`);
657
+ };
658
+
659
+ const sweepLinks = (keep = new Set()) => {
660
+ for (const link of linksUnder(dir)) if (!keep.has(link) && !landsSafely(link)) { removeEntry(link); linksRemoved++; }
661
+ };
662
+
663
+ // 1. A link already here (planted, or from an earlier sync) stays only if it lands inside the snapshot and not in the checkout.
664
+ // This runs before anything is removed or written, so no later step can pass through such a link. Nothing is deleted by a
665
+ // path that crosses a link: stale entries go only in the final walk, which enters real directories alone (Oracle, re-review).
666
+ assertLockHeld();
667
+ sweepLinks();
668
+
669
+ // 3. Every tracked file: unchanged ones are left alone (same bytes & mode keep their mtime), the rest are written. Links are
670
+ // created only after every file, so creating one can never redirect a later write.
671
+ const need = [];
672
+ const links = [];
673
+ const nextStat = {};
674
+ for (const e of entries) {
675
+ const target = path.join(dir, ...e.path.split("/"));
676
+ if (e.mode === LINK_MODE) { links.push(e); continue; }
677
+ casesFixed += exactCase(dir, e.path, caseListing);
678
+ ensureParents(dir, target);
679
+ const s = lstat(target);
680
+ // A hard-linked file (another name for it outside this snapshot, perhaps a checkout file with equal bytes) is never kept, and
681
+ // never chmod-ed or written through: it is replaced by a fresh copy (temp + rename), which leaves the other name alone.
682
+ if (s?.isFile() && s.nlink > 1n) hardLinksReplaced++;
683
+ else if (s?.isFile()) {
684
+ const known = cache[e.path];
685
+ const same = known && known.oid === e.oid && known.stat === statKey(s) ? true : blobId(fs.readFileSync(target), repo.objectFormat) === e.oid;
686
+ if (same) {
687
+ const exec = (Number(s.mode) & 0o111) !== 0;
688
+ if (process.platform !== "win32" && exec !== (e.mode === "100755")) { fs.chmodSync(target, e.mode === "100755" ? 0o755 : 0o644); modesFixed++; }
689
+ else unchanged++;
690
+ nextStat[e.path] = { oid: e.oid, stat: statKey(lstat(target)) };
691
+ continue;
692
+ }
693
+ }
694
+ need.push(e);
695
+ }
696
+ // Writes land in this sync's own staging directory on the same volume, then rename into place: an interrupted sync never
697
+ // leaves a half-written or stray file inside the snapshot. Blobs are read and written a chunk at a time.
698
+ const stagingRoot = `${dir}.staging`;
699
+ // The snapshot lock is held, so any staging directory still here belongs to a sync that died: it is removed, never accumulated.
700
+ fs.rmSync(stagingRoot, { recursive: true, force: true });
701
+ const staging = path.join(stagingRoot, crypto.randomUUID());
702
+ fs.mkdirSync(staging, { recursive: true, mode: 0o700 });
703
+ try {
704
+ let serial = 0;
705
+ for (let i = 0; i < need.length; i += BLOB_CHUNK) {
706
+ const chunk = need.slice(i, i + BLOB_CHUNK);
707
+ const blobs = readBlobs(repo.checkout, chunk.map((e) => e.oid));
708
+ for (const e of chunk) {
709
+ assertLockHeld();
710
+ const target = path.join(dir, ...e.path.split("/"));
711
+ ensureParents(dir, target);
712
+ parentSafe(target);
713
+ const s = lstat(target);
714
+ if (s && !s.isFile()) removeEntry(target);
715
+ const tmp = path.join(staging, String(serial++));
716
+ fs.writeFileSync(tmp, blobs.get(e.oid), { mode: e.mode === "100755" ? 0o755 : 0o644 });
717
+ if (process.platform !== "win32") fs.chmodSync(tmp, e.mode === "100755" ? 0o755 : 0o644);
718
+ fs.renameSync(tmp, target);
719
+ nextStat[e.path] = { oid: e.oid, stat: statKey(lstat(target)) };
720
+ written++;
721
+ }
722
+ }
723
+ assertLockHeld();
724
+ const linkBlobs = readBlobs(repo.checkout, links.map((e) => e.oid));
725
+ for (const e of links) {
726
+ const target = path.join(dir, ...e.path.split("/"));
727
+ const to = linkBlobs.get(e.oid).toString("utf8");
728
+ 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`);
729
+ casesFixed += exactCase(dir, e.path, new Map());
730
+ ensureParents(dir, target);
731
+ parentSafe(target);
732
+ const s = lstat(target);
733
+ if (s?.isSymbolicLink() && linkText(fs.readlinkSync(target)) === to) { unchanged++; continue; }
734
+ if (s) removeEntry(target);
735
+ try { fs.symlinkSync(to, target); }
736
+ 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)" : ""}`); }
737
+ written++;
738
+ }
739
+ // Every committed link must land, physically, on something inside the snapshot that is not in the checkout. One that does
740
+ // not (an escape through a chain, a dangling or looping link) is removed before the refusal, so it is never left in place.
741
+ const unsafe = links.map((e) => e.path).filter((p) => !landsSafely(path.join(dir, ...p.split("/"))));
742
+ if (unsafe.length) {
743
+ for (const p of unsafe) removeEntry(path.join(dir, ...p.split("/")));
744
+ throw new SnapshotError("refused", `commit ${commit} has links that do not land inside the snapshot: ${unsafe.join(", ")}`);
745
+ }
746
+ // A planted link can resolve differently once the committed links exist: sweep the others again.
747
+ sweepLinks(new Set(links.map((e) => path.join(dir, ...e.path.split("/")))));
748
+ } finally {
749
+ fs.rmSync(staging, { recursive: true, force: true });
750
+ try { fs.rmdirSync(stagingRoot); } catch { /* another sync's staging, or not empty */ }
751
+ }
752
+
753
+ // 4. Everything the commit does not hold is removed: the snapshot is exactly the commit (§1).
754
+ assertLockHeld();
755
+ removed += pruneToTree(dir, entries);
756
+
757
+ assertLockHeld();
758
+ const extra = snapshotProblem({ dir, entries, checkout: repo.checkout });
759
+ if (extra) throw new SnapshotError("mismatch", `snapshot ${dir} is not exactly commit ${commit} after sync: ${extra}`);
760
+ const actual = snapshotTree({ checkout: repo.checkout, dir, baseTree: tree, repo });
761
+ if (actual !== tree) throw new SnapshotError("mismatch", `snapshot ${dir} is tree ${actual} after sync, not tree ${tree} of commit ${commit}`);
762
+ writeState(stateFile, { schema: STATE_SCHEMA, repo: repo.checkout, repoKey: repo.key, commit, tree, paths: [...wanted.keys()], stat: nextStat, syncedAt: new Date(now()).toISOString() });
763
+ return { schema: SNAPSHOT_SCHEMA, path: dir, repoKey: repo.key, commit, tree, written, removed, modesFixed, unchanged, linksRemoved, hardLinksReplaced, casesFixed, ms: now() - started };
764
+ } finally {
765
+ release();
766
+ }
767
+ }
768
+
769
+ /**
770
+ * Verifies, without changing anything, that the snapshot of `checkout` holds exactly the tree of `rev`: its seeded tree id, no path
771
+ * the commit lacks (ignored files included), nothing of another type, mode, link text or hard-link count than the commit's, no link
772
+ * resolving out of the snapshot or into the checkout, and, when `environment` is given, the installed dependency environment the
773
+ * gate recorded after installing. `environment` is `{ expected, compute }`: `expected` is the `env=<sha256>` that
774
+ * `rightkit-gate env-digest --snapshot` printed after the install, `compute({ checkout, dir, commit, tree })` returns the current
775
+ * digest (the caller supplies it: the digest is the gate's, bin/rightkit-gate.mjs classAEnvironmentDigest); a difference is a
776
+ * mismatch, so dependency bytes changed or planted mid-stage refuse at the end verify. Callers run this at the start and again at
777
+ * the end of a stage (the stage-level calls are item 7, 7.08). `managedRoot` and `device` as for syncSnapshot.
778
+ */
779
+ export function verifySnapshot({ checkout, rev = "HEAD", root = snapshotRoot(), dir, managedRoot = hostManagedRootFor(root), device, environment } = {}) {
780
+ const repo = repositoryOf(checkout);
781
+ const { commit, tree } = resolveCommit(repo.checkout, rev);
782
+ if (managedRoot) requireManagedRoot(managedRoot, { device });
783
+ const at = dir ?? path.join(root, repo.key);
784
+ // The root the snapshot sits directly in is private to this user, as at sync (a root another principal can use proves nothing).
785
+ if (path.resolve(path.dirname(at)) === path.resolve(root)) requirePrivateRoot(root, lstat(root));
786
+ if (!lstat(at)?.isDirectory()) throw new SnapshotError("mismatch", `no snapshot directory at ${at}`);
787
+ // The seeded tree id cannot see an ignored file or follow a link, so the directory itself is walked first (E6, §1 exact snapshot).
788
+ const extra = snapshotProblem({ dir: at, entries: treeEntries(repo.checkout, commit), checkout: repo.checkout });
789
+ if (extra) throw new SnapshotError("mismatch", `the snapshot at ${at} is not exactly commit ${commit}: ${extra}`);
790
+ const actual = snapshotTree({ checkout: repo.checkout, dir: at, baseTree: tree, repo });
791
+ if (actual !== tree) throw new SnapshotError("mismatch", `the snapshot at ${at} is tree ${actual}, not tree ${tree} of commit ${commit}`);
792
+ if (environment !== undefined) {
793
+ 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> }");
794
+ let now;
795
+ try { now = environment.compute({ checkout: repo.checkout, dir: at, commit, tree }); }
796
+ catch (e) { throw new SnapshotError("error", `the environment digest of the snapshot at ${at} cannot be computed: ${e.message}`); }
797
+ if (now !== environment.expected) throw new SnapshotError("mismatch", `the installed environment of the snapshot at ${at} changed: env=${now}, recorded env=${environment.expected}`);
798
+ }
799
+ return { schema: SNAPSHOT_SCHEMA, path: at, repoKey: repo.key, commit, tree, ...(environment !== undefined ? { env: environment.expected } : {}) };
800
+ }
801
+
802
+ /**
803
+ * `rightkit-gate snapshot [--commit <rev>] [--verify [--env <sha256>]] [--json]`: sync (or with --verify only check) the commit's
804
+ * snapshot. `--env <sha256>` (verify only) also requires the installed dependency environment to still be the recorded
805
+ * `rightkit-gate env-digest --snapshot` digest; the gate supplies `deps.environmentDigest({ checkout, dir, commit, tree })`.
806
+ * Exit 0 the snapshot is the commit's tree and the build ownership taken for it was released, 1 mismatch / refused (unknown
807
+ * revision, a missing managed root, an open-mode root, a gitlink ...) / busy or the ownership record was kept (a survivor may still
808
+ * read the snapshot), 2 could not run. `o.home`, `o.configFile`, `o.managedRoot`, `o.device` are test injections.
809
+ */
810
+ export function snapshotCommand(o, args, deps = {}) {
811
+ const allowed = new Set(["--commit", "--verify", "--json", "--env"]);
812
+ for (let i = 0; i < args.length; i++) {
813
+ 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; }
814
+ if (args[i] === "--commit") { if (!args[i + 1] || args[i + 1].startsWith("--")) { o.err("rightkit-gate: snapshot --commit needs a revision"); return 2; } i++; }
815
+ 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++; }
816
+ }
817
+ const verify = args.includes("--verify");
818
+ const envAt = args.indexOf("--env");
819
+ if (envAt >= 0 && !verify) { o.err("rightkit-gate: snapshot --env only checks a snapshot: it needs --verify"); return 2; }
820
+ if (envAt >= 0 && typeof deps.environmentDigest !== "function") { o.err("rightkit-gate: snapshot --env cannot compute the environment digest in this process"); return 2; }
821
+ const i = args.indexOf("--commit");
822
+ const rev = i >= 0 ? args[i + 1] : "HEAD";
823
+ let ownership = null;
824
+ let code = 2;
825
+ try {
826
+ const platform = o.platform ?? process.platform;
827
+ const root = o.snapshotRoot ?? snapshotRoot({ platform, env: o.env, ...(o.home ? { home: o.home } : {}), ...(o.configFile ? { configFile: o.configFile } : {}) });
828
+ const managedRoot = o.managedRoot !== undefined ? o.managedRoot : hostManagedRootFor(root, { platform });
829
+ // The managed root exists (the gate never creates it) and the root is private, BEFORE build ownership is taken: taking it
830
+ // creates directories of its own, and must never be what brings a missing managed root into being.
831
+ guardRoot(root, managedRoot, { device: o.device });
832
+ // A sync replaces snapshot bytes, so it runs only under the host's build ownership, taken before the first write (joined when
833
+ // this gate already holds it); a verify only reads.
834
+ if (!verify) {
835
+ const repo = repositoryOf(o.cwd);
836
+ 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 });
837
+ }
838
+ const r = verify ? verifySnapshot({ checkout: o.cwd, rev, root, managedRoot, device: o.device, ...(envAt >= 0 ? { environment: { expected: args[envAt + 1], compute: deps.environmentDigest } } : {}) })
839
+ : syncSnapshot({ checkout: o.cwd, rev, root, managedRoot, device: o.device });
840
+ if (ownership) r.ownership = { handle: ownership.handle, joined: ownership.joined, reclaimed: ownership.reclaimed };
841
+ if (args.includes("--json")) o.out(JSON.stringify(r));
842
+ 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"}`);
843
+ code = 0;
844
+ } catch (e) {
845
+ o.err(`rightkit-gate: snapshot ${e.code ?? "error"}${e.reason ? ` (${e.reason})` : ""}: ${e.message}`);
846
+ code = (e instanceof SnapshotError && e.code !== "error") || e instanceof OwnershipBusy ? 1 : 2;
847
+ } finally {
848
+ const r = ownership?.release();
849
+ // 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.
850
+ if (r?.released === false && !ownership.joined) {
851
+ o.err(`rightkit-gate: snapshot failed: build ownership ${ownership.handle} kept: ${r.why}`);
852
+ if (code === 0) code = 1;
853
+ }
854
+ }
855
+ return code;
856
+ }