@papillon6814/copi 0.1.0 → 0.2.0

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 (53) hide show
  1. package/LICENSE.unicode +39 -0
  2. package/README.md +57 -5
  3. package/dist/native/rename-exclusive-arm64 +0 -0
  4. package/dist/native/rename-exclusive-arm64.manifest.json +12 -0
  5. package/dist/src/apply.d.ts +9 -0
  6. package/dist/src/apply.js +110 -9
  7. package/dist/src/catalog.d.ts +21 -4
  8. package/dist/src/catalog.js +265 -133
  9. package/dist/src/cli.d.ts +2 -1
  10. package/dist/src/cli.js +73 -3
  11. package/dist/src/groups.js +8 -3
  12. package/dist/src/inspect.d.ts +3 -1
  13. package/dist/src/inspect.js +16 -6
  14. package/dist/src/instruction-reference.d.ts +6 -0
  15. package/dist/src/instruction-reference.js +21 -0
  16. package/dist/src/manage.d.ts +2 -0
  17. package/dist/src/manage.js +329 -0
  18. package/dist/src/migrate.d.ts +25 -0
  19. package/dist/src/migrate.js +171 -0
  20. package/dist/src/migration-backup.d.ts +22 -0
  21. package/dist/src/migration-backup.js +403 -0
  22. package/dist/src/migration-inventory.d.ts +9 -0
  23. package/dist/src/migration-inventory.js +237 -0
  24. package/dist/src/migration-resolution.d.ts +28 -0
  25. package/dist/src/migration-resolution.js +1618 -0
  26. package/dist/src/migration.d.ts +49 -0
  27. package/dist/src/migration.js +1004 -0
  28. package/dist/src/names.d.ts +11 -0
  29. package/dist/src/names.js +40 -0
  30. package/dist/src/native-placement.d.ts +16 -0
  31. package/dist/src/native-placement.js +557 -0
  32. package/dist/src/refresh.d.ts +20 -0
  33. package/dist/src/refresh.js +143 -0
  34. package/dist/src/resource-mutations.d.ts +45 -0
  35. package/dist/src/resource-mutations.js +225 -0
  36. package/dist/src/source-content.d.ts +4 -0
  37. package/dist/src/source-content.js +208 -0
  38. package/dist/src/storage-journal.d.ts +111 -0
  39. package/dist/src/storage-journal.js +613 -0
  40. package/dist/src/storage-root-lease.d.ts +7 -0
  41. package/dist/src/storage-root-lease.js +92 -0
  42. package/dist/src/storage-transaction.d.ts +100 -0
  43. package/dist/src/storage-transaction.js +3951 -0
  44. package/dist/src/terminal-editor.d.ts +8 -0
  45. package/dist/src/terminal-editor.js +391 -0
  46. package/dist/src/types.d.ts +18 -2
  47. package/dist/src/unicode-case-folding-data.d.ts +1 -0
  48. package/dist/src/unicode-case-folding-data.js +1596 -0
  49. package/dist/src/unicode-case-folding.d.ts +2 -0
  50. package/dist/src/unicode-case-folding.js +9 -0
  51. package/dist/src/wizard.d.ts +11 -3
  52. package/dist/src/wizard.js +274 -18
  53. package/package.json +5 -3
@@ -0,0 +1,3951 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { chmod, link, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, rmdir, unlink } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { types as utilTypes } from "node:util";
7
+ import { setTimeout as delay } from "node:timers/promises";
8
+ import { assertModernCatalog, parseStoredCatalog } from "./catalog.js";
9
+ import { readSourceContent } from "./source-content.js";
10
+ import { canonicalStorageJournalJson, parseMigrationStartInventory, parseStorageJournal, validateLegacyMigrationJournalTargets, validateSourcesJournalTargets } from "./storage-journal.js";
11
+ import { isManagedMigrationRootName, listManagedMigrationRootEntries, projectMigrationInventory, scanMigrationInventory } from "./migration-inventory.js";
12
+ import { acquireRootLease } from "./storage-root-lease.js";
13
+ import { resumeCompletedMigrationResolution } from "./migration-resolution.js";
14
+ import { buildMigrationBackupManifest, parseMigrationBackupManifest, verifyMigrationBackup } from "./migration-backup.js";
15
+ import { preflightNativeSourcePlacement, renameExclusiveReviewedSource } from "./native-placement.js";
16
+ import { inspectFixedResource } from "./inspect.js";
17
+ import { assertNoNameConflicts, findNameConflicts, nameKey } from "./names.js";
18
+ import { assertPreparedMigrationRequest } from "./migration.js";
19
+ const lockName = ".copi-transaction";
20
+ const privateNames = new Set([".git", ".cache", ".npm", "auth", "node_modules", "cache", "caches", "history", "sessions", "auth.json", "mcp-auth.json", "oauth.json"]);
21
+ const itemPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
22
+ const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
23
+ const isNativeProxy = utilTypes.isProxy;
24
+ function snapshotOwnDataRecord(value, expectedKeys) {
25
+ if (typeof value !== "object" || value === null || isNativeProxy(value) || Array.isArray(value))
26
+ return undefined;
27
+ try {
28
+ const keys = Reflect.ownKeys(value);
29
+ if (keys.length !== expectedKeys.length || keys.some((key) => typeof key !== "string" || !expectedKeys.includes(key)))
30
+ return undefined;
31
+ const snapshot = Object.create(null);
32
+ for (const key of expectedKeys) {
33
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
34
+ if (!descriptor || !Object.hasOwn(descriptor, "value") || descriptor.enumerable !== true)
35
+ return undefined;
36
+ snapshot[key] = descriptor.value;
37
+ }
38
+ return snapshot;
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ }
44
+ function snapshotDenseOwnDataArray(value) {
45
+ if (isNativeProxy(value) || !Array.isArray(value))
46
+ return undefined;
47
+ try {
48
+ const keys = Reflect.ownKeys(value);
49
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length");
50
+ if (!lengthDescriptor || !Object.hasOwn(lengthDescriptor, "value") || lengthDescriptor.enumerable !== false ||
51
+ typeof lengthDescriptor.value !== "number" || !Number.isSafeInteger(lengthDescriptor.value) || lengthDescriptor.value < 0 ||
52
+ keys.length !== lengthDescriptor.value + 1)
53
+ return undefined;
54
+ const length = lengthDescriptor.value;
55
+ const allowed = new Set(["length"]);
56
+ for (const key of keys) {
57
+ if (typeof key !== "string" || (key !== "length" && (!Number.isSafeInteger(Number(key)) || Number(key) < 0 ||
58
+ Number(key) >= length || String(Number(key)) !== key)))
59
+ return undefined;
60
+ allowed.add(key);
61
+ }
62
+ const result = [];
63
+ for (let index = 0; index < length; index += 1) {
64
+ const key = String(index);
65
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
66
+ if (!allowed.has(key) || !descriptor || !Object.hasOwn(descriptor, "value") || descriptor.enumerable !== true)
67
+ return undefined;
68
+ result.push(descriptor.value);
69
+ }
70
+ return result;
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ }
76
+ const legacyMigrationReviews = new WeakMap();
77
+ const legacyMigrationPlanReviews = new WeakSet();
78
+ const reviews = new WeakMap();
79
+ // Only run/recoverDead issue leases. Public constructor calls cannot register themselves.
80
+ const sessionLeases = new WeakMap();
81
+ export function safeStoragePath(value) {
82
+ return typeof value === "string" && value.length > 0 && !value.includes("\\") && !value.includes("\0") && !path.posix.isAbsolute(value) && value.split("/").every((part) => part !== "" && part !== "." && part !== "..");
83
+ }
84
+ export function privateStoragePath(value) {
85
+ return value.split("/").some((part) => privateNames.has(part.toLowerCase()) || part.toLowerCase().startsWith(".env"));
86
+ }
87
+ function replacementSignature(replacements) {
88
+ return JSON.stringify(replacements.map((replacement) => ({ itemId: replacement.itemId, files: replacement.files.map((file) => ({ path: file.path, mode: file.mode, sha256: hash(file.bytes) })) })));
89
+ }
90
+ /** @internal Opaque review for callers of the generalized transaction adapter. Fixed bytes, no approval bypass flags. */
91
+ export function prepareStorageReview(catalog, replacements) {
92
+ const fixed = Buffer.from(catalog);
93
+ const parsed = parseStoredCatalog(JSON.parse(fixed.toString("utf8")));
94
+ const copies = replacements.map((replacement) => ({ itemId: replacement.itemId, files: replacement.files.map((file) => ({ path: file.path, mode: file.mode, bytes: Buffer.from(file.bytes) })) }));
95
+ if (new Set(copies.map((replacement) => replacement.itemId)).size !== copies.length)
96
+ throw new Error("Duplicate snapshot replacement.");
97
+ const sensitivePaths = [];
98
+ const warnings = [];
99
+ for (const replacement of copies) {
100
+ const item = parsed.items.find((item) => item.id === replacement.itemId);
101
+ if (!item || !itemPattern.test(replacement.itemId))
102
+ throw new Error("Snapshot replacement must refer to an item in the new catalog.");
103
+ const descriptors = replacement.files.map((file) => ({ path: file.path, mode: file.mode, sha256: hash(file.bytes) }));
104
+ if (JSON.stringify(descriptors) !== JSON.stringify(item.files))
105
+ throw new Error("Snapshot bytes, paths, modes and catalog checksums do not match.");
106
+ if (replacement.files.length === 0 || replacement.files.some((file) => !safeStoragePath(file.path) || privateStoragePath(file.path)))
107
+ throw new Error("Unsafe or private/excluded snapshot path in storage replacement.");
108
+ const inspection = inspectFixedResource({ candidate: { ...item, sourceRoot: path.dirname(item.sourcePath) }, files: replacement.files, excludedPaths: [] });
109
+ sensitivePaths.push(...inspection.sensitivePaths.map((filePath) => `${replacement.itemId}/${filePath}`));
110
+ warnings.push(...inspection.warnings.map((warning) => warning.path ? { ...warning, path: `${replacement.itemId}/${warning.path}` } : { ...warning }));
111
+ }
112
+ const review = Object.freeze({ reviewId: randomUUID() });
113
+ reviews.set(review, { catalog: fixed, replacements: copies, sensitivePaths: [...new Set(sensitivePaths)].sort(), warnings });
114
+ return review;
115
+ }
116
+ export function readStorageReview(review) {
117
+ const state = reviews.get(review);
118
+ if (!state)
119
+ throw new Error("Invalid prepared storage review token.");
120
+ return { sensitivePaths: [...state.sensitivePaths], warnings: structuredClone(state.warnings) };
121
+ }
122
+ /** @internal Returns a detached copy for the caller's private diff state, never a public review view. */
123
+ export function readStorageBeforeReviewFiles(review, itemId) {
124
+ const state = reviews.get(review);
125
+ const before = state?.before;
126
+ if (!state || !before || before.used)
127
+ throw new Error("Storage before-review files are unavailable.");
128
+ const item = before.items.find((entry) => entry.itemId === itemId);
129
+ if (!item)
130
+ throw new Error("Storage before-review does not contain this source item.");
131
+ return item.files.map((file) => ({ path: file.path, mode: file.mode, bytes: Buffer.from(file.bytes) }));
132
+ }
133
+ function omittedSourcePaths(state, previous, next) {
134
+ const replacementIds = new Set(state.replacements.map((replacement) => replacement.itemId));
135
+ const nextById = new Map(next.items.map((item) => [item.id, item]));
136
+ const omitted = new Set();
137
+ for (const oldItem of previous.items) {
138
+ if (!replacementIds.has(oldItem.id))
139
+ continue;
140
+ const nextItem = nextById.get(oldItem.id);
141
+ const nextPaths = new Set(nextItem?.files.map((file) => file.path) ?? []);
142
+ for (const file of oldItem.files)
143
+ if (!nextPaths.has(file.path))
144
+ omitted.add(`${oldItem.id}/${file.path}`);
145
+ for (const item of state.before?.items ?? []) {
146
+ if (item.itemId !== oldItem.id)
147
+ continue;
148
+ for (const file of item.files)
149
+ if (!nextPaths.has(file.path))
150
+ omitted.add(`${oldItem.id}/${file.path}`);
151
+ }
152
+ }
153
+ return [...omitted].sort();
154
+ }
155
+ function sameBeforeFiles(left, right) {
156
+ return left.length === right.length && left.every((file, index) => {
157
+ const other = right[index];
158
+ return other !== undefined && file.path === other.path && file.mode === other.mode && file.dev === other.dev &&
159
+ file.ino === other.ino && file.bytes.equals(other.bytes);
160
+ });
161
+ }
162
+ function withinPath(root, target) {
163
+ const relative = path.relative(root, target);
164
+ return relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
165
+ }
166
+ async function captureSourcesBefore(root, item) {
167
+ if (item.content.kind === "tree") {
168
+ const target = await checkedSourceTreeTarget(root, item.content.path);
169
+ if (!target)
170
+ throw new Error("Registered sources tree disappeared before the before-review capture.");
171
+ await assertNoExcludedTreeNodes(target);
172
+ await assertNoEmptySourceDirectories(target);
173
+ }
174
+ const visible = await readSourceContent(root, item);
175
+ const canonicalRoot = await realpath(root);
176
+ const files = [];
177
+ for (const entry of visible) {
178
+ const target = item.content.kind === "file"
179
+ ? path.join(root, ...item.content.path.split("/"))
180
+ : path.join(root, ...item.content.path.split("/"), ...entry.path.split("/"));
181
+ const canonical = await realpath(target);
182
+ if (!withinPath(canonicalRoot, canonical))
183
+ throw new Error("Before-review source resolves outside the library.");
184
+ const before = await lstat(target, { bigint: true });
185
+ if (!before.isFile() || before.isSymbolicLink())
186
+ throw new Error("Before-review source is not a regular file.");
187
+ const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
188
+ try {
189
+ const opened = await handle.stat({ bigint: true });
190
+ const sameOpenedFile = (left, right) => left.dev === right.dev && left.ino === right.ino &&
191
+ left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs && left.mode === right.mode;
192
+ if (!opened.isFile() || !sameOpenedFile(before, opened))
193
+ throw new Error("Before-review source changed while being opened.");
194
+ const bytes = await handle.readFile();
195
+ const [afterHandle, after, canonicalAfter] = await Promise.all([handle.stat({ bigint: true }), lstat(target, { bigint: true }), realpath(target)]);
196
+ if (!after.isFile() || after.isSymbolicLink() || canonicalAfter !== canonical || !sameOpenedFile(opened, afterHandle) ||
197
+ !sameOpenedFile(opened, after) || hash(bytes) !== hash(entry.bytes)) {
198
+ throw new Error("Before-review source changed while being captured.");
199
+ }
200
+ files.push({ path: entry.path, bytes: Buffer.from(bytes), mode: Number(opened.mode & 511n), dev: opened.dev.toString(), ino: opened.ino.toString() });
201
+ }
202
+ finally {
203
+ await handle.close();
204
+ }
205
+ }
206
+ files.sort((left, right) => left.path.localeCompare(right.path));
207
+ return { itemId: item.id, files };
208
+ }
209
+ async function assertBeforeReviewCurrent(root, expected, before) {
210
+ if (before.root !== root || JSON.stringify(before.expected) !== JSON.stringify(expected))
211
+ throw new Error("Before-review token belongs to another root or expected revision.");
212
+ const previous = expected.catalog === null ? { version: 2, items: [], groups: [] } : parseStoredCatalog(JSON.parse(Buffer.from(expected.catalog, "base64").toString("utf8")));
213
+ if (previous.version !== 2)
214
+ throw new Error("Before-review requires a v2 sources catalog.");
215
+ for (const item of before.items) {
216
+ const oldItem = previous.items.find((entry) => entry.id === item.itemId);
217
+ if (!oldItem)
218
+ throw new Error("Before-review token refers to a foreign source item.");
219
+ const current = await captureSourcesBefore(root, oldItem);
220
+ if (!sameBeforeFiles(item.files, current.files))
221
+ throw new Error("Before-reviewed source bytes, mode, identity or paths changed; refusing deletion.");
222
+ }
223
+ }
224
+ function approvedStorage(catalog, replacements, authorization) {
225
+ const review = authorization?.review ?? prepareStorageReview(catalog, replacements);
226
+ const state = reviews.get(review);
227
+ if (!state || !state.catalog.equals(catalog) || replacementSignature(state.replacements) !== replacementSignature(replacements))
228
+ throw new Error("Storage review does not match these fixed bytes or catalog.");
229
+ // Reinspect the private fixed bytes at commit, never a caller-supplied warning/path view.
230
+ const checked = reviews.get(prepareStorageReview(state.catalog, state.replacements));
231
+ if (!authorization) {
232
+ if (checked.sensitivePaths.length > 0)
233
+ throw new Error("A prepared storage inspection and individual secret approval are required.");
234
+ return state;
235
+ }
236
+ const approved = new Set(authorization.approval.approvedSensitivePaths);
237
+ if (authorization.approval.confirmed !== true || authorization.approval.reviewId !== review.reviewId || approved.size !== authorization.approval.approvedSensitivePaths.length || approved.size !== checked.sensitivePaths.length || checked.sensitivePaths.some((filePath) => !approved.has(filePath)))
238
+ throw new Error("Storage approval must match the prepared review and every sensitive file.");
239
+ return state;
240
+ }
241
+ async function statMaybe(target) {
242
+ return lstat(target).catch((error) => { if (error.code === "ENOENT")
243
+ return undefined; throw error; });
244
+ }
245
+ async function checkedFile(target) {
246
+ const stat = await statMaybe(target);
247
+ if (!stat)
248
+ return undefined;
249
+ if (!stat.isFile() || stat.isSymbolicLink())
250
+ throw new Error(`Unsafe symbolic link or non-file in copi storage: ${target}`);
251
+ return readFile(target);
252
+ }
253
+ async function checkedDirectory(target) {
254
+ const stat = await statMaybe(target);
255
+ if (!stat)
256
+ return false;
257
+ if (!stat.isDirectory() || stat.isSymbolicLink())
258
+ throw new Error(`Unsafe symbolic link or non-directory in copi storage: ${target}`);
259
+ return true;
260
+ }
261
+ async function tree(target, replacements, includeIdentity = false, omittedPaths = new Set()) {
262
+ if (!await statMaybe(target))
263
+ return null;
264
+ const entries = [];
265
+ async function visit(current, relative) {
266
+ const stat = await lstat(current);
267
+ const identity = includeIdentity ? { dev: stat.dev, ino: stat.ino } : {};
268
+ if (stat.isSymbolicLink())
269
+ entries.push({ path: relative, kind: "link", value: await readlink(current), mode: stat.mode & 0o777, ...identity });
270
+ else if (stat.isDirectory()) {
271
+ entries.push({ path: relative, kind: "directory", value: "", mode: stat.mode & 0o777, ...identity });
272
+ const names = await readdir(current);
273
+ const prefix = relative === "." ? "" : `${relative}/`;
274
+ const mappedChildren = [...(replacements?.keys() ?? [])].flatMap((mapped) => {
275
+ const remainder = relative === "." ? mapped : path.posix.relative(relative, mapped);
276
+ if (!remainder || remainder === ".." || remainder.startsWith("../") || path.posix.isAbsolute(remainder))
277
+ return [];
278
+ return [remainder.split("/")[0]];
279
+ });
280
+ for (const entry of [...new Set([...names, ...mappedChildren])].sort()) {
281
+ const childRelative = `${prefix}${entry}`;
282
+ if (omittedPaths.has(childRelative))
283
+ continue;
284
+ const replacement = replacements?.get(childRelative);
285
+ await visit(replacement ?? path.join(current, entry), childRelative);
286
+ }
287
+ }
288
+ else if (stat.isFile())
289
+ entries.push({ path: relative, kind: "file", value: hash(await readFile(current)), mode: stat.mode & 0o777, ...identity });
290
+ else
291
+ throw new Error("Unsupported special file in copi storage.");
292
+ }
293
+ await visit(target, ".");
294
+ return hash(JSON.stringify(entries));
295
+ }
296
+ async function assertSafeCatalogOnlySourcesTree(target) {
297
+ const rootStat = await statMaybe(target);
298
+ if (!rootStat)
299
+ return;
300
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
301
+ throw new Error("Catalog-only transaction found an unsafe sources root; preserving it.");
302
+ async function visit(directory) {
303
+ const before = await lstat(directory);
304
+ if (!before.isDirectory() || before.isSymbolicLink())
305
+ throw new Error("Catalog-only transaction found an unsafe sources directory; preserving it.");
306
+ const names = (await readdir(directory)).sort();
307
+ for (const name of names) {
308
+ const child = path.join(directory, name);
309
+ const stat = await lstat(child);
310
+ if (stat.isSymbolicLink())
311
+ throw new Error("Catalog-only transaction found a symbolic link in sources; preserving it.");
312
+ if (stat.isDirectory())
313
+ await visit(child);
314
+ else if (!stat.isFile())
315
+ throw new Error("Catalog-only transaction found a special sources node; preserving it.");
316
+ }
317
+ const after = await lstat(directory);
318
+ if (!after.isDirectory() || after.isSymbolicLink() || !sameDirectory({ dev: before.dev, ino: before.ino }, { dev: after.dev, ino: after.ino }) ||
319
+ JSON.stringify((await readdir(directory)).sort()) !== JSON.stringify(names)) {
320
+ throw new Error("Catalog-only sources tree changed while it was checked; preserving it.");
321
+ }
322
+ }
323
+ await visit(target);
324
+ }
325
+ async function catalogOnlySourcesTree(root) {
326
+ const target = path.join(root, "sources");
327
+ await assertSafeCatalogOnlySourcesTree(target);
328
+ const current = await tree(target, undefined, true) ?? "absent";
329
+ await assertSafeCatalogOnlySourcesTree(target);
330
+ const after = await tree(target, undefined, true) ?? "absent";
331
+ if (after !== current)
332
+ throw new Error("Catalog-only sources tree changed while it was checked; preserving it.");
333
+ return current;
334
+ }
335
+ function validateMutationNames(previous, next, replacements) {
336
+ const conflicts = findNameConflicts(previous);
337
+ if (conflicts.length === 0) {
338
+ assertNoNameConflicts(next);
339
+ return;
340
+ }
341
+ if (replacements.length > 0)
342
+ throw new Error("Duplicate names must be resolved before content changes.");
343
+ const restored = structuredClone(next);
344
+ for (const namespace of ["item", "group"]) {
345
+ const before = namespace === "item" ? previous.items : previous.groups;
346
+ const after = namespace === "item" ? restored.items : restored.groups;
347
+ if (before.length !== after.length)
348
+ throw new Error("Only conflicting names may change until duplicate names are resolved.");
349
+ for (const [index, entry] of after.entries()) {
350
+ const old = before[index];
351
+ if (!old || old.id !== entry.id)
352
+ throw new Error("Conflict resolution must preserve catalog identities and order.");
353
+ if (old.name !== entry.name) {
354
+ if (!conflicts.some((conflict) => conflict.namespace === namespace && conflict.ids.includes(entry.id)))
355
+ throw new Error("Only conflicting names may change until duplicate names are resolved.");
356
+ const all = namespace === "item" ? next.items : next.groups;
357
+ if (all.some((other) => other.id !== entry.id && nameKey(other.name) === nameKey(entry.name)))
358
+ throw new Error("Duplicate name in conflict resolution.");
359
+ }
360
+ entry.name = old.name;
361
+ }
362
+ }
363
+ if (JSON.stringify(restored) !== JSON.stringify(previous))
364
+ throw new Error("Conflict resolution must preserve all metadata, files and group references.");
365
+ }
366
+ async function checkedSourceFileTarget(root, target, allowMissing = false) {
367
+ if (!safeStoragePath(target) || !target.startsWith("sources/") || privateStoragePath(target))
368
+ throw new Error("Unsafe registered sources file target.");
369
+ const segments = target.split("/");
370
+ let current = root;
371
+ for (const segment of segments.slice(0, -1)) {
372
+ current = path.join(current, segment);
373
+ const stat = await statMaybe(current);
374
+ if (!stat || !stat.isDirectory() || stat.isSymbolicLink())
375
+ throw new Error("Registered sources file has a missing, unsafe, or symbolic-link parent directory.");
376
+ }
377
+ const absolute = path.join(current, segments.at(-1));
378
+ const stat = await statMaybe(absolute);
379
+ if (!stat && allowMissing)
380
+ return undefined;
381
+ if (!stat || !stat.isFile() || stat.isSymbolicLink())
382
+ throw new Error("Registered sources content must be a regular non-symlink file.");
383
+ return absolute;
384
+ }
385
+ async function checkedSourceTreeTarget(root, target, allowMissing = false) {
386
+ if (!safeStoragePath(target) || !target.startsWith("sources/") || privateStoragePath(target))
387
+ throw new Error("Unsafe registered sources tree target.");
388
+ const segments = target.split("/");
389
+ let current = root;
390
+ for (const segment of segments.slice(0, -1)) {
391
+ current = path.join(current, segment);
392
+ const stat = await statMaybe(current);
393
+ if (!stat || !stat.isDirectory() || stat.isSymbolicLink())
394
+ throw new Error("Registered sources tree has a missing, unsafe, or symbolic-link parent directory.");
395
+ }
396
+ const absolute = path.join(current, segments.at(-1));
397
+ const stat = await statMaybe(absolute);
398
+ if (!stat && allowMissing)
399
+ return undefined;
400
+ if (!stat || !stat.isDirectory() || stat.isSymbolicLink())
401
+ throw new Error("Registered sources content root must be a regular non-symlink directory.");
402
+ return absolute;
403
+ }
404
+ async function renameNewSourcesStageExclusive(libraryDir, stagePath, targetPath) {
405
+ const [stage, parent] = await Promise.all([
406
+ lstat(stagePath, { bigint: true }),
407
+ lstat(path.dirname(targetPath), { bigint: true }),
408
+ ]);
409
+ if (!stage.isDirectory() || stage.isSymbolicLink() || !parent.isDirectory() || parent.isSymbolicLink()) {
410
+ throw new Error("New source placement requires a checked private stage and target parent directory.");
411
+ }
412
+ await renameExclusiveReviewedSource({
413
+ libraryDir,
414
+ stagePath,
415
+ targetPath,
416
+ expectedStage: { dev: stage.dev.toString(), ino: stage.ino.toString() },
417
+ expectedParent: { dev: parent.dev.toString(), ino: parent.ino.toString() },
418
+ });
419
+ }
420
+ async function assertNoExcludedTreeNodes(root) {
421
+ async function visit(directory, relative) {
422
+ const rootStat = await lstat(directory);
423
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
424
+ throw new Error("Sources tree contains an unsafe directory.");
425
+ for (const name of await readdir(directory)) {
426
+ const childRelative = relative ? `${relative}/${name}` : name;
427
+ if (privateStoragePath(childRelative))
428
+ throw new Error("Excluded/private files in a registered sources tree require preservation; refusing tree replacement.");
429
+ const child = path.join(directory, name);
430
+ const stat = await lstat(child);
431
+ if (stat.isSymbolicLink())
432
+ throw new Error("Sources tree contains an unsafe symbolic link; refusing replacement.");
433
+ if (stat.isDirectory())
434
+ await visit(child, childRelative);
435
+ else if (!stat.isFile())
436
+ throw new Error("Sources tree contains an unsupported special file; refusing replacement.");
437
+ }
438
+ }
439
+ await visit(root, "");
440
+ }
441
+ async function assertNoEmptySourceDirectories(root) {
442
+ async function visit(directory) {
443
+ let containsFile = false;
444
+ for (const name of await readdir(directory)) {
445
+ const child = path.join(directory, name);
446
+ const stat = await lstat(child);
447
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
448
+ if (!await visit(child))
449
+ throw new Error("Empty sources directories are not represented in file review; refusing replacement.");
450
+ containsFile = true;
451
+ }
452
+ else if (stat.isFile() && !stat.isSymbolicLink())
453
+ containsFile = true;
454
+ else
455
+ throw new Error("Sources tree contains an unsupported node; refusing replacement.");
456
+ }
457
+ return containsFile;
458
+ }
459
+ await visit(root);
460
+ }
461
+ async function assertNewSourcesTargetVacant(root, item) {
462
+ const sourcesRoot = path.join(root, "sources");
463
+ const sourcesStat = await statMaybe(sourcesRoot);
464
+ if (!sourcesStat)
465
+ return;
466
+ if (!sourcesStat.isDirectory() || sourcesStat.isSymbolicLink())
467
+ throw new Error("Unsafe or symbolic-link sources root for a new content target.");
468
+ const itemRoot = path.join(sourcesRoot, item.id);
469
+ const itemStat = await statMaybe(itemRoot);
470
+ if (itemStat?.isSymbolicLink())
471
+ throw new Error("A new content target has a symbolic-link parent; refusing to follow it.");
472
+ if (itemStat)
473
+ throw new Error("New registered content target already exists; refusing to adopt or overwrite it.");
474
+ }
475
+ function validateNewItemDescriptors(item, replacement) {
476
+ if (replacement.files.length === 0 || replacement.files.some((file) => !safeStoragePath(file.path) || privateStoragePath(file.path)) ||
477
+ new Set(replacement.files.map((file) => file.path)).size !== replacement.files.length ||
478
+ replacement.files.some((file) => replacement.files.some((other) => other.path !== file.path && other.path.startsWith(`${file.path}/`))) ||
479
+ JSON.stringify(replacement.files.map(({ path: filePath, mode, bytes }) => ({ path: filePath, mode, sha256: hash(bytes) }))) !== JSON.stringify(item.files)) {
480
+ throw new Error("A new sources item must contain only unique, safe reviewed files matching its catalog descriptors.");
481
+ }
482
+ if (item.content.kind === "file") {
483
+ const file = replacement.files[0];
484
+ const parts = item.content.path.split("/");
485
+ if (item.files.length !== 1 || replacement.files.length !== 1 || !file || parts.length !== 3 || parts[0] !== "sources" ||
486
+ parts[1] !== item.id || parts[2] !== path.posix.basename(file.path) || file.path !== parts[2]) {
487
+ throw new Error("A new sources file target must be sources/<item UUID>/<reviewed basename>.");
488
+ }
489
+ }
490
+ else if (item.content.path !== `sources/${item.id}` || item.kind !== "skill" || !replacement.files.some((file) => file.path === "SKILL.md")) {
491
+ throw new Error("A new sources tree target must be sources/<item UUID> and include its root SKILL.md.");
492
+ }
493
+ }
494
+ async function validateSourcesTransition(root, previous, next, replacements, before) {
495
+ validateMutationNames(previous, next, replacements);
496
+ if (next.items.length < previous.items.length || next.groups.length !== previous.groups.length) {
497
+ throw new Error("A sources transaction cannot remove items or create groups.");
498
+ }
499
+ const additions = next.items.slice(previous.items.length);
500
+ const addedIds = new Set(additions.map((item) => item.id));
501
+ for (const [index, oldItem] of previous.items.entries()) {
502
+ const nextItem = next.items[index];
503
+ if (!nextItem || nextItem.id !== oldItem.id)
504
+ throw new Error("Sources transaction must preserve existing catalog item order and identity.");
505
+ if (additions.length > 0 && JSON.stringify(nextItem) !== JSON.stringify(oldItem)) {
506
+ throw new Error("Adding a new source item cannot also modify existing item metadata or content.");
507
+ }
508
+ }
509
+ for (const [index, oldGroup] of previous.groups.entries()) {
510
+ const nextGroup = next.groups[index];
511
+ if (!nextGroup || nextGroup.id !== oldGroup.id || nextGroup.name !== oldGroup.name ||
512
+ JSON.stringify(nextGroup.itemIds.slice(0, oldGroup.itemIds.length)) !== JSON.stringify(oldGroup.itemIds) ||
513
+ nextGroup.itemIds.slice(oldGroup.itemIds.length).some((id) => !addedIds.has(id))) {
514
+ throw new Error("A new source item may only be appended to existing group relations without changing existing membership.");
515
+ }
516
+ }
517
+ const replacementById = new Map();
518
+ for (const replacement of replacements) {
519
+ if (!itemPattern.test(replacement.itemId) || replacementById.has(replacement.itemId))
520
+ throw new Error("Duplicate or invalid sources replacement item.");
521
+ replacementById.set(replacement.itemId, replacement);
522
+ }
523
+ const operations = [];
524
+ for (const [index, oldItem] of previous.items.entries()) {
525
+ const nextItem = next.items[index];
526
+ const replacement = replacementById.get(oldItem.id);
527
+ if (!replacement) {
528
+ if (JSON.stringify(nextItem) !== JSON.stringify(oldItem))
529
+ throw new Error("Unreplaced catalog items must remain byte-for-byte unchanged.");
530
+ continue;
531
+ }
532
+ if (additions.length > 0)
533
+ throw new Error("New-item content transactions cannot replace existing source targets.");
534
+ if (oldItem.content.kind !== nextItem.content.kind || oldItem.content.path !== nextItem.content.path) {
535
+ throw new Error("A sources replacement must keep its registered content kind and original root.");
536
+ }
537
+ const target = nextItem.content.path;
538
+ if (!safeStoragePath(target) || !target.startsWith("sources/") || privateStoragePath(target))
539
+ throw new Error("Unsafe registered sources content target.");
540
+ const expectedItem = { ...oldItem, source: nextItem.source, scope: nextItem.scope, sourcePath: nextItem.sourcePath, files: nextItem.files };
541
+ if (JSON.stringify(expectedItem) !== JSON.stringify(nextItem))
542
+ throw new Error("A sources content replacement may update only its source metadata and reviewed file descriptors.");
543
+ if (nextItem.content.kind === "file") {
544
+ const basename = path.posix.basename(target);
545
+ const oldFile = oldItem.files[0];
546
+ const nextFile = nextItem.files[0];
547
+ const file = replacement.files[0];
548
+ if (replacement.files.length !== 1 || oldItem.files.length !== 1 || nextItem.files.length !== 1 || !oldFile || !nextFile || !file ||
549
+ file.path !== basename || nextFile.path !== basename || oldFile.path !== basename ||
550
+ file.mode !== oldFile.mode || nextFile.mode !== oldFile.mode || nextFile.sha256 !== hash(file.bytes) || privateStoragePath(file.path)) {
551
+ throw new Error("A sources file replacement must contain exactly the registered basename and preserve its approved mode and catalog checksum.");
552
+ }
553
+ const currentFiles = await readSourceContent(root, oldItem);
554
+ if (currentFiles.length !== 1 || currentFiles[0].path !== basename)
555
+ throw new Error("Registered sources file did not resolve to exactly its catalog basename.");
556
+ await checkedSourceFileTarget(root, target);
557
+ operations.push({ itemId: oldItem.id, target, kind: "file", oldTree: null, newTree: "0".repeat(64) });
558
+ continue;
559
+ }
560
+ if (replacement.files.length === 0 || replacement.files.some((file) => !safeStoragePath(file.path) || privateStoragePath(file.path)) ||
561
+ new Set(replacement.files.map((file) => file.path)).size !== replacement.files.length ||
562
+ replacement.files.some((file) => replacement.files.some((other) => other.path !== file.path && other.path.startsWith(`${file.path}/`))) ||
563
+ JSON.stringify(replacement.files.map(({ path: filePath, mode, bytes }) => ({ path: filePath, mode, sha256: hash(bytes) }))) !== JSON.stringify(nextItem.files)) {
564
+ throw new Error("A sources tree replacement must contain only unique, safe reviewed files matching its catalog descriptors.");
565
+ }
566
+ if (nextItem.kind !== "skill" || !replacement.files.some((file) => file.path === "SKILL.md")) {
567
+ throw new Error("A registered skill tree replacement must include its root SKILL.md.");
568
+ }
569
+ const targetPath = await checkedSourceTreeTarget(root, target);
570
+ if (!targetPath)
571
+ throw new Error("Registered sources tree root disappeared before staging.");
572
+ await assertNoExcludedTreeNodes(targetPath);
573
+ await assertNoEmptySourceDirectories(targetPath);
574
+ const currentFiles = await readSourceContent(root, oldItem);
575
+ if (!currentFiles.some((file) => file.path === "SKILL.md"))
576
+ throw new Error("Registered skill tree is missing its required root SKILL.md.");
577
+ const previouslyRegistered = new Set(oldItem.files.map((file) => file.path));
578
+ const explicitlyReviewed = new Set(nextItem.files.map((file) => file.path));
579
+ const capturedBeforePaths = new Set(before?.items.find((entry) => entry.itemId === oldItem.id)?.files.map((file) => file.path) ?? []);
580
+ if (currentFiles.some((file) => !previouslyRegistered.has(file.path) && !explicitlyReviewed.has(file.path) && !capturedBeforePaths.has(file.path))) {
581
+ throw new Error("Registered sources tree contains an unreviewed ordinary child; explicitly review it before replacement.");
582
+ }
583
+ operations.push({ itemId: oldItem.id, target, kind: "tree", oldTree: null, newTree: "0".repeat(64) });
584
+ }
585
+ for (const item of additions) {
586
+ const replacement = replacementById.get(item.id);
587
+ if (!replacement || !itemPattern.test(item.id))
588
+ throw new Error("Every new v2 item requires one reviewed storage replacement with a UUID.");
589
+ validateNewItemDescriptors(item, replacement);
590
+ await assertNewSourcesTargetVacant(root, item);
591
+ operations.push({ itemId: item.id, target: item.content.path, kind: item.content.kind, oldTree: null, newTree: "0".repeat(64) });
592
+ }
593
+ if (replacementById.size !== operations.length)
594
+ throw new Error("A sources replacement refers to an unknown catalog item.");
595
+ return operations;
596
+ }
597
+ function sameJsonShape(value, canonical) {
598
+ if (Array.isArray(canonical)) {
599
+ return Array.isArray(value) && value.length === canonical.length && canonical.every((entry, index) => sameJsonShape(value[index], entry));
600
+ }
601
+ if (canonical !== null && typeof canonical === "object") {
602
+ if (value === null || typeof value !== "object" || Array.isArray(value))
603
+ return false;
604
+ const valueRecord = value;
605
+ const canonicalRecord = canonical;
606
+ const keys = Object.keys(canonicalRecord);
607
+ return Object.keys(valueRecord).length === keys.length &&
608
+ keys.every((key) => Object.hasOwn(valueRecord, key) && sameJsonShape(valueRecord[key], canonicalRecord[key]));
609
+ }
610
+ return Object.is(value, canonical);
611
+ }
612
+ function assertCanonicalStoredCatalog(raw, canonical) {
613
+ if (!sameJsonShape(raw, canonical)) {
614
+ throw new Error("Catalog contains unknown or non-canonical stored fields; preserving its original bytes.");
615
+ }
616
+ }
617
+ function validateCatalogOnlyTransition(previous, next) {
618
+ assertNoNameConflicts(next);
619
+ if (next.items.length !== previous.items.length)
620
+ throw new Error("A catalog-only transaction cannot add, remove, or reorder source items.");
621
+ for (const [index, oldItem] of previous.items.entries()) {
622
+ const nextItem = next.items[index];
623
+ if (!nextItem || nextItem.id !== oldItem.id)
624
+ throw new Error("A catalog-only transaction must preserve item order and identity.");
625
+ if (oldItem.destination.kind === "global-only" && nextItem.destination.kind === "project") {
626
+ throw new Error("A reference-only/global-only resource cannot become project-applicable.");
627
+ }
628
+ if (nextItem.destination.kind === "project" && privateStoragePath(nextItem.destination.path)) {
629
+ throw new Error("A catalog-only transaction cannot set a private or excluded destination.");
630
+ }
631
+ const permitted = { ...oldItem, name: nextItem.name, destination: nextItem.destination };
632
+ if (JSON.stringify(permitted) !== JSON.stringify(nextItem)) {
633
+ throw new Error("A catalog-only transaction may change only item names and legal destinations; source metadata, descriptors, and content are fixed.");
634
+ }
635
+ }
636
+ if (next.groups.length < previous.groups.length)
637
+ throw new Error("A catalog-only transaction cannot remove groups.");
638
+ for (const [index, oldGroup] of previous.groups.entries()) {
639
+ const nextGroup = next.groups[index];
640
+ if (!nextGroup || nextGroup.id !== oldGroup.id)
641
+ throw new Error("A catalog-only transaction must preserve existing group order and identity.");
642
+ }
643
+ }
644
+ function validateSourcesCatalogTransition(previous, next, operations) {
645
+ const previousItems = previous?.items ?? [];
646
+ const previousGroups = previous?.groups ?? [];
647
+ if (next.items.length < previousItems.length || next.groups.length !== previousGroups.length)
648
+ throw new Error("Sources journal changed catalog membership outside a new-item transaction.");
649
+ const additions = next.items.slice(previousItems.length);
650
+ const addedIds = new Set(additions.map((item) => item.id));
651
+ for (const [index, oldGroup] of previousGroups.entries()) {
652
+ const nextGroup = next.groups[index];
653
+ if (!nextGroup || nextGroup.id !== oldGroup.id || nextGroup.name !== oldGroup.name ||
654
+ JSON.stringify(nextGroup.itemIds.slice(0, oldGroup.itemIds.length)) !== JSON.stringify(oldGroup.itemIds) ||
655
+ nextGroup.itemIds.slice(oldGroup.itemIds.length).some((id) => !addedIds.has(id))) {
656
+ throw new Error("Sources journal changed an existing group relation beyond adding a new item.");
657
+ }
658
+ }
659
+ const byId = new Map(operations.map((operation) => [operation.itemId, operation]));
660
+ for (const [index, oldItem] of previousItems.entries()) {
661
+ const nextItem = next.items[index];
662
+ if (!nextItem || nextItem.id !== oldItem.id)
663
+ throw new Error("Sources journal catalogs changed existing item order or identity.");
664
+ const operation = byId.get(oldItem.id);
665
+ if (!operation) {
666
+ if (JSON.stringify(nextItem) !== JSON.stringify(oldItem))
667
+ throw new Error("Sources journal contains an unrecorded catalog item change.");
668
+ continue;
669
+ }
670
+ if (additions.length > 0)
671
+ throw new Error("New-item journal cannot also replace existing source content.");
672
+ if (oldItem.content.kind !== operation.kind || nextItem.content.kind !== operation.kind ||
673
+ oldItem.content.path !== operation.target || nextItem.content.path !== operation.target ||
674
+ JSON.stringify({ ...oldItem, source: nextItem.source, scope: nextItem.scope, sourcePath: nextItem.sourcePath, files: nextItem.files }) !== JSON.stringify(nextItem)) {
675
+ throw new Error("Sources journal operation does not match a source-metadata and descriptor update to its registered content root.");
676
+ }
677
+ if (operation.kind === "file") {
678
+ const oldFile = oldItem.files[0];
679
+ const nextFile = nextItem.files[0];
680
+ if (!oldFile || !nextFile || oldItem.files.length !== 1 || nextItem.files.length !== 1 || oldFile.path !== path.posix.basename(operation.target) ||
681
+ nextFile.path !== oldFile.path || nextFile.mode !== oldFile.mode) {
682
+ throw new Error("Sources journal file operation does not preserve its registered path and mode.");
683
+ }
684
+ }
685
+ else if (nextItem.kind !== "skill" || !nextItem.files.some((file) => file.path === "SKILL.md")) {
686
+ throw new Error("Sources journal tree operation has no root SKILL.md descriptor.");
687
+ }
688
+ }
689
+ for (const item of additions) {
690
+ const operation = byId.get(item.id);
691
+ if (!operation || operation.oldTree !== null || operation.target !== item.content.path || operation.kind !== item.content.kind ||
692
+ (item.content.kind === "file" && (item.files.length !== 1 || item.files[0].path !== path.posix.basename(item.content.path))) ||
693
+ (item.content.kind === "tree" && (item.kind !== "skill" || !item.files.some((file) => file.path === "SKILL.md")))) {
694
+ throw new Error("Sources journal operation does not match a new item and its reviewed content target.");
695
+ }
696
+ }
697
+ if (byId.size !== operations.length || additions.length !== operations.filter((operation) => !previousItems.some((item) => item.id === operation.itemId)).length) {
698
+ throw new Error("Sources journal refers to an unknown or unrecorded catalog item.");
699
+ }
700
+ }
701
+ async function sourceArtifactTreeIfPresent(target, kind) {
702
+ const stat = await statMaybe(target);
703
+ if (!stat)
704
+ return null;
705
+ if (kind === "file" ? !stat.isFile() || stat.isSymbolicLink() : !stat.isDirectory() || stat.isSymbolicLink()) {
706
+ throw new Error(`Sources transaction ${kind} target, stage, or backup has an unsafe kind.`);
707
+ }
708
+ return tree(target, undefined, true);
709
+ }
710
+ function sourcesRelativeTarget(operation) {
711
+ return operation.target.slice("sources/".length);
712
+ }
713
+ function operationStageContentPath(lock, journal, index) {
714
+ const operation = journal.operations[index];
715
+ const relative = sourcesRelativeTarget(operation);
716
+ if (operation.oldTree === null && journal.stableSourcesTree === "absent")
717
+ return path.join(lock, "next-sources", ...relative.split("/"));
718
+ if (operation.oldTree === null && operation.kind === "file")
719
+ return path.join(lock, `next-${index}`, path.posix.basename(operation.target));
720
+ return path.join(lock, `next-${index}`);
721
+ }
722
+ function operationStageContainerPath(lock, journal, index) {
723
+ const operation = journal.operations[index];
724
+ if (operation.oldTree === null && journal.stableSourcesTree === "absent")
725
+ return path.join(lock, "next-sources");
726
+ return path.join(lock, `next-${index}`);
727
+ }
728
+ function operationIsStagedFileParent(journal, index) {
729
+ const operation = journal.operations[index];
730
+ return operation.oldTree === null && operation.kind === "file" && journal.stableSourcesTree !== "absent";
731
+ }
732
+ function ownedSourceDirectory(journal, relativePath) {
733
+ return journal.ownedDirectories?.find((directory) => directory.path === relativePath);
734
+ }
735
+ function ownedDirectoryStagePath(lock, directory) {
736
+ const stageRoot = path.join(lock, directory.stage);
737
+ if (directory.path === "sources" || directory.stage !== "next-sources")
738
+ return stageRoot;
739
+ return path.join(stageRoot, ...directory.path.slice("sources/".length).split("/"));
740
+ }
741
+ async function verifyOwnedDirectory(target, expected, allowMissing = false) {
742
+ const stat = await statMaybe(target);
743
+ if (!stat && allowMissing)
744
+ return false;
745
+ if (!stat || !stat.isDirectory() || stat.isSymbolicLink() ||
746
+ stat.dev !== expected.dev || stat.ino !== expected.ino || (stat.mode & 0o777) !== 0o700) {
747
+ throw new Error(`Owned new sources directory ${expected.path} changed identity, type, or private mode.`);
748
+ }
749
+ return true;
750
+ }
751
+ async function checkedSourcesOperationTarget(root, operation, allowMissing = false) {
752
+ if (!safeStoragePath(operation.target) || !operation.target.startsWith("sources/") || privateStoragePath(operation.target)) {
753
+ throw new Error("Unsafe registered sources content target.");
754
+ }
755
+ const parts = operation.target.split("/");
756
+ let current = root;
757
+ for (const part of parts.slice(0, -1)) {
758
+ current = path.join(current, part);
759
+ const stat = await statMaybe(current);
760
+ if (!stat && allowMissing)
761
+ return undefined;
762
+ if (!stat || !stat.isDirectory() || stat.isSymbolicLink())
763
+ throw new Error("Registered sources target has a missing, unsafe, or symbolic-link parent.");
764
+ }
765
+ const target = path.join(current, parts.at(-1));
766
+ const stat = await statMaybe(target);
767
+ if (!stat && allowMissing)
768
+ return undefined;
769
+ if (!stat || (operation.kind === "file" ? !stat.isFile() : !stat.isDirectory()) || stat.isSymbolicLink()) {
770
+ throw new Error(`Registered sources ${operation.kind} target has an unsafe kind.`);
771
+ }
772
+ return target;
773
+ }
774
+ async function strictTreeFiles(root) {
775
+ await assertNoExcludedTreeNodes(root);
776
+ const files = [];
777
+ async function visit(directory, relative) {
778
+ const beforeDirectory = await lstat(directory);
779
+ if (!beforeDirectory.isDirectory() || beforeDirectory.isSymbolicLink())
780
+ throw new Error("Sources tree contains an unsafe directory.");
781
+ const names = (await readdir(directory)).sort();
782
+ for (const name of names) {
783
+ const relativePath = relative ? `${relative}/${name}` : name;
784
+ const target = path.join(directory, name);
785
+ const before = await lstat(target);
786
+ if (before.isSymbolicLink())
787
+ throw new Error("Sources tree contains an unsafe symbolic link.");
788
+ if (before.isDirectory())
789
+ await visit(target, relativePath);
790
+ else if (before.isFile()) {
791
+ const bytes = await readFile(target);
792
+ const after = await lstat(target);
793
+ if (!after.isFile() || after.isSymbolicLink() || before.dev !== after.dev || before.ino !== after.ino ||
794
+ before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || before.mode !== after.mode) {
795
+ throw new Error("Sources tree file changed while it was being inspected.");
796
+ }
797
+ files.push({ path: relativePath, mode: after.mode & 0o777, sha256: hash(bytes) });
798
+ }
799
+ else
800
+ throw new Error("Sources tree contains an unsupported special file.");
801
+ }
802
+ const afterDirectory = await lstat(directory);
803
+ if (!afterDirectory.isDirectory() || afterDirectory.isSymbolicLink() || beforeDirectory.dev !== afterDirectory.dev ||
804
+ beforeDirectory.ino !== afterDirectory.ino || JSON.stringify((await readdir(directory)).sort()) !== JSON.stringify(names)) {
805
+ throw new Error("Sources tree directory changed while it was being inspected.");
806
+ }
807
+ }
808
+ await visit(root, "");
809
+ return files.sort((left, right) => left.path.localeCompare(right.path));
810
+ }
811
+ async function assertTreeMatchesDescriptors(root, descriptors, verifyModes = false) {
812
+ const actual = await strictTreeFiles(root);
813
+ const expected = descriptors.map(({ path: filePath, mode, sha256 }) => verifyModes
814
+ ? { path: filePath, mode, sha256 }
815
+ : { path: filePath, sha256 }).sort((left, right) => left.path.localeCompare(right.path));
816
+ const observed = verifyModes ? actual : actual.map(({ path: filePath, sha256 }) => ({ path: filePath, sha256 }));
817
+ if (JSON.stringify(observed) !== JSON.stringify(expected))
818
+ throw new Error("Staged sources tree files do not match the next catalog descriptors.");
819
+ }
820
+ async function removeOwnedTreeArtifact(target, commitment) {
821
+ if (await sourceArtifactTreeIfPresent(target, "tree") !== commitment)
822
+ throw new Error("Private sources tree artifact changed; preserving it.");
823
+ const nodes = [];
824
+ async function collect(directory) {
825
+ const stat = await lstat(directory);
826
+ if (!stat.isDirectory() || stat.isSymbolicLink())
827
+ throw new Error("Private sources tree cleanup found an unsafe directory.");
828
+ for (const name of await readdir(directory)) {
829
+ const child = path.join(directory, name);
830
+ const childStat = await lstat(child);
831
+ if (childStat.isSymbolicLink())
832
+ throw new Error("Private sources tree cleanup found an unknown symbolic link; preserving it.");
833
+ if (childStat.isDirectory())
834
+ await collect(child);
835
+ else if (childStat.isFile())
836
+ nodes.push({ path: child, kind: "file", identity: { dev: childStat.dev, ino: childStat.ino }, mode: childStat.mode & 0o777, commitment: hash(await readFile(child)) });
837
+ else
838
+ throw new Error("Private sources tree cleanup found an unknown node; preserving it.");
839
+ }
840
+ nodes.push({ path: directory, kind: "directory", identity: { dev: stat.dev, ino: stat.ino }, mode: stat.mode & 0o777 });
841
+ }
842
+ await collect(target);
843
+ for (const node of nodes) {
844
+ const current = await statMaybe(node.path);
845
+ if (!current || !sameDirectory({ dev: current.dev, ino: current.ino }, node.identity) || (current.mode & 0o777) !== node.mode ||
846
+ (node.kind === "file" ? !current.isFile() || current.isSymbolicLink() : !current.isDirectory() || current.isSymbolicLink())) {
847
+ throw new Error("Private sources tree node changed before cleanup; preserving the tree.");
848
+ }
849
+ if (node.kind === "file") {
850
+ const bytes = await readFile(node.path);
851
+ if (hash(bytes) !== node.commitment)
852
+ throw new Error("Private sources tree file changed before cleanup; preserving it.");
853
+ await unlink(node.path);
854
+ }
855
+ else
856
+ await rmdir(node.path);
857
+ }
858
+ }
859
+ const finalizedFileArtifacts = new Set(["journal.digest", "next-catalog", "old-catalog", "held-catalog", "displaced-catalog", "rollback.json"]);
860
+ function v2LockEntries(journal) {
861
+ const entries = new Set(["owner.json", "journal.json", "journal.digest", "next-catalog", "old-catalog", "held-catalog", "displaced-catalog", "rollback.json", "finalized.json", "finalized.digest"]);
862
+ if (journal.nextSourcesTree !== undefined && journal.stableSourcesTree === "absent")
863
+ entries.add("next-sources");
864
+ for (const index of journal.operations.keys()) {
865
+ entries.add(`next-${index}`);
866
+ entries.add(`old-${index}`);
867
+ entries.add(`displaced-${index}`);
868
+ }
869
+ return entries;
870
+ }
871
+ function catalogOnlyLockEntries() {
872
+ return new Set(["owner.json", "journal.json", "journal.digest", "next-catalog", "old-catalog", "held-catalog", "displaced-catalog", "rollback.json", "finalized.json", "finalized.digest"]);
873
+ }
874
+ async function assertKnownCatalogOnlyLockEntries(lock) {
875
+ const entries = new Set(await readdir(lock));
876
+ if ([...entries].some((entry) => !catalogOnlyLockEntries().has(entry))) {
877
+ throw new Error("Unknown catalog-only transaction stage or artifact; preserving the lock and all bytes.");
878
+ }
879
+ return entries;
880
+ }
881
+ async function createCatalogOnlyCleanup(lock, journal, rawJournal) {
882
+ const entries = await assertKnownCatalogOnlyLockEntries(lock);
883
+ const storedJournal = await checkedFile(path.join(lock, "journal.json"));
884
+ const journalDigest = await checkedFile(path.join(lock, "journal.digest"));
885
+ if (!storedJournal?.equals(rawJournal) || !journalDigest || journalDigest.toString("utf8") !== hash(rawJournal)) {
886
+ throw new Error("Catalog-only journal changed before cleanup custody was recorded.");
887
+ }
888
+ const nextPath = path.join(lock, "next-catalog");
889
+ const nextBytes = await checkedFile(nextPath);
890
+ const nextStat = await statMaybe(nextPath);
891
+ if (!nextBytes || hash(nextBytes) !== journal.newCatalog || !nextStat)
892
+ throw new Error("Catalog-only next-catalog stage is missing or altered; preserve the journal and remaining artifacts for manual inspection with a newly verified recovery build.");
893
+ const oldPath = path.join(lock, "old-catalog");
894
+ const oldBytes = await checkedFile(oldPath);
895
+ const oldStat = await statMaybe(oldPath);
896
+ if (journal.oldCatalog === null ? entries.has("old-catalog") : !oldBytes || !oldStat || hash(oldBytes) !== journal.oldCatalog) {
897
+ throw new Error("Catalog-only old-catalog artifact is missing or unexpected.");
898
+ }
899
+ const heldPath = path.join(lock, "held-catalog");
900
+ const heldBytes = await checkedFile(heldPath);
901
+ const heldStat = await statMaybe(heldPath);
902
+ if (entries.has("held-catalog") && (journal.oldCatalog === null || !heldBytes || !heldStat || hash(heldBytes) !== journal.oldCatalog ||
903
+ !oldStat || !sameDirectory({ dev: oldStat.dev, ino: oldStat.ino }, { dev: heldStat.dev, ino: heldStat.ino }))) {
904
+ throw new Error("Catalog-only held-catalog artifact changed or lost its original inode.");
905
+ }
906
+ const rollback = await checkedFile(path.join(lock, "rollback.json"));
907
+ if (entries.has("rollback.json") && rollback?.toString("utf8") !== journal.token)
908
+ throw new Error("Catalog-only rollback marker is invalid.");
909
+ const displacedPath = path.join(lock, "displaced-catalog");
910
+ const displacedBytes = await checkedFile(displacedPath);
911
+ const displacedStat = await statMaybe(displacedPath);
912
+ if (entries.has("displaced-catalog") && (!rollback || !displacedBytes || !displacedStat || hash(displacedBytes) !== journal.newCatalog ||
913
+ !sameDirectory({ dev: nextStat.dev, ino: nextStat.ino }, { dev: displacedStat.dev, ino: displacedStat.ino }))) {
914
+ throw new Error("Catalog-only displaced catalog is not bound to a checked rollback.");
915
+ }
916
+ const catalogPath = path.join(journal.root, "catalog.json");
917
+ const currentBytes = await checkedFile(catalogPath);
918
+ const currentStat = await statMaybe(catalogPath);
919
+ const currentIsNew = !!currentBytes && hash(currentBytes) === journal.newCatalog && !!currentStat &&
920
+ sameDirectory({ dev: nextStat.dev, ino: nextStat.ino }, { dev: currentStat.dev, ino: currentStat.ino });
921
+ const currentIsOld = journal.oldCatalog !== null && !!currentBytes && hash(currentBytes) === journal.oldCatalog && !!currentStat && !!oldStat &&
922
+ sameDirectory({ dev: oldStat.dev, ino: oldStat.ino }, { dev: currentStat.dev, ino: currentStat.ino });
923
+ if (!currentIsNew && !currentIsOld && !(journal.oldCatalog === null && !currentBytes)) {
924
+ throw new Error("Catalog-only final catalog is not a checked committed or rolled-back inode.");
925
+ }
926
+ if (currentIsNew && journal.oldCatalog !== null && !entries.has("held-catalog")) {
927
+ throw new Error("Committed catalog-only transaction lost its held old catalog inode.");
928
+ }
929
+ const artifacts = [];
930
+ for (const name of [...entries].sort()) {
931
+ if (name === "owner.json" || name === "journal.json" || name === "finalized.json" || name === "finalized.digest")
932
+ continue;
933
+ const file = await checkedFile(path.join(lock, name));
934
+ if (!file)
935
+ throw new Error(`Catalog-only cleanup artifact ${name} is missing.`);
936
+ const expected = name === "journal.digest" ? hash(journalDigest)
937
+ : name === "next-catalog" || name === "displaced-catalog" ? journal.newCatalog
938
+ : name === "old-catalog" || name === "held-catalog" ? journal.oldCatalog
939
+ : name === "rollback.json" ? journal.token : undefined;
940
+ if (!expected || (name === "rollback.json" ? file.toString("utf8") !== expected : hash(file) !== expected)) {
941
+ throw new Error(`Catalog-only cleanup artifact ${name} changed.`);
942
+ }
943
+ artifacts.push({ name, kind: "file", commitment: hash(file) });
944
+ }
945
+ if (!entries.has("next-catalog") || !entries.has("journal.digest") ||
946
+ (journal.oldCatalog === null ? entries.has("old-catalog") : !entries.has("old-catalog"))) {
947
+ throw new Error("Required catalog-only cleanup artifact is missing.");
948
+ }
949
+ return { version: 1, root: journal.root, token: journal.token, operationCount: 0, artifacts };
950
+ }
951
+ function parseFinalizedCleanup(value, expected) {
952
+ if (typeof value !== "object" || value === null || Array.isArray(value))
953
+ throw new Error("Finalized v2 cleanup manifest is malformed; refusing cleanup.");
954
+ const record = value;
955
+ const keys = Object.keys(record);
956
+ const hasOperationKinds = Object.hasOwn(record, "operationKinds");
957
+ const hasOperationStageKinds = Object.hasOwn(record, "operationStageKinds");
958
+ if (keys.length < 5 || keys.length > 7 || !["version", "root", "token", "operationCount", "artifacts"].every((key) => Object.hasOwn(record, key)) ||
959
+ keys.some((key) => !["version", "root", "token", "operationCount", "operationKinds", "operationStageKinds", "artifacts"].includes(key)) ||
960
+ record.version !== 1 || record.root !== expected.root || record.token !== expected.token ||
961
+ typeof record.operationCount !== "number" || !Number.isSafeInteger(record.operationCount) || record.operationCount < 0 || !Array.isArray(record.artifacts) ||
962
+ (hasOperationKinds && (!Array.isArray(record.operationKinds) || record.operationKinds.length !== record.operationCount ||
963
+ record.operationKinds.some((kind) => kind !== "file" && kind !== "tree"))) ||
964
+ (hasOperationStageKinds && (!hasOperationKinds || !Array.isArray(record.operationStageKinds) || record.operationStageKinds.length !== record.operationCount ||
965
+ record.operationStageKinds.some((kind) => kind !== "file" && kind !== "tree"))) || (!hasOperationKinds && record.operationCount > 0 &&
966
+ record.artifacts.some((artifact) => typeof artifact === "object" && artifact !== null && /^(next|old|displaced)-/.test(String(artifact.name)) && artifact.kind !== "source-file"))) {
967
+ throw new Error("Finalized v2 cleanup manifest is malformed or belongs to another session.");
968
+ }
969
+ const operationKinds = hasOperationKinds ? record.operationKinds : undefined;
970
+ const operationStageKinds = hasOperationStageKinds ? record.operationStageKinds : undefined;
971
+ const artifacts = [];
972
+ const names = new Set();
973
+ for (const valueArtifact of record.artifacts) {
974
+ if (typeof valueArtifact !== "object" || valueArtifact === null || Array.isArray(valueArtifact))
975
+ throw new Error("Finalized v2 cleanup artifact is malformed.");
976
+ const artifact = valueArtifact;
977
+ const artifactKeys = Object.keys(artifact);
978
+ if (artifactKeys.length !== 3 || !["name", "kind", "commitment"].every((key) => Object.hasOwn(artifact, key)) ||
979
+ typeof artifact.name !== "string" || typeof artifact.commitment !== "string" || !/^[0-9a-f]{64}$/.test(artifact.commitment) || names.has(artifact.name)) {
980
+ throw new Error("Finalized v2 cleanup artifact is malformed or duplicated.");
981
+ }
982
+ const indexMatch = /^(next|old|displaced)-(0|[1-9][0-9]*)$/.exec(artifact.name);
983
+ const index = indexMatch ? Number(indexMatch[2]) : -1;
984
+ const operationKind = indexMatch && Number.isSafeInteger(index) && index < record.operationCount ? operationKinds?.[index] ?? "file" : undefined;
985
+ const stageKind = indexMatch && Number.isSafeInteger(index) && index < record.operationCount ? operationStageKinds?.[index] ?? operationKind : undefined;
986
+ const permittedKinds = finalizedFileArtifacts.has(artifact.name) ? ["file"]
987
+ : artifact.name === "next-sources" ? ["source-tree"]
988
+ : indexMatch?.[1] === "next" ? stageKind === "file" ? ["source-file"] : stageKind === "tree" ? ["source-tree"] : []
989
+ : operationKind === "tree" ? ["source-tree"] : operationKind === "file" ? ["source-file"] : [];
990
+ if (permittedKinds.length === 0 || !permittedKinds.includes(artifact.kind))
991
+ throw new Error("Finalized v2 cleanup artifact name or file/tree kind is not bound to its operation count.");
992
+ names.add(artifact.name);
993
+ artifacts.push({ name: artifact.name, kind: artifact.kind, commitment: artifact.commitment });
994
+ }
995
+ return { version: 1, root: expected.root, token: expected.token, operationCount: record.operationCount,
996
+ ...(operationKinds ? { operationKinds } : {}), ...(operationStageKinds ? { operationStageKinds } : {}), artifacts };
997
+ }
998
+ function catalogFormat(catalog) {
999
+ if (catalog === undefined)
1000
+ return "v2";
1001
+ try {
1002
+ return parseStoredCatalog(JSON.parse(catalog.toString("utf8"))).version === 1 ? "v1" : "v2";
1003
+ }
1004
+ catch (error) {
1005
+ throw new Error("Invalid copi catalog while capturing storage revision.", { cause: error });
1006
+ }
1007
+ }
1008
+ async function captureStorageRevision(root) {
1009
+ const catalogPath = path.join(root, "catalog.json");
1010
+ const before = await statMaybe(catalogPath);
1011
+ const catalog = await checkedFile(catalogPath);
1012
+ const after = await statMaybe(catalogPath);
1013
+ const beforeIdentity = before ? `${before.dev}:${before.ino}` : null;
1014
+ const catalogIdentity = after ? `${after.dev}:${after.ino}` : null;
1015
+ if (beforeIdentity !== catalogIdentity)
1016
+ throw new Error("Copi catalog changed while capturing its revision.");
1017
+ const format = catalogFormat(catalog);
1018
+ return Object.freeze({
1019
+ root,
1020
+ catalog: catalog?.toString("base64") ?? null,
1021
+ catalogIdentity,
1022
+ format,
1023
+ tree: await tree(path.join(root, format === "v2" ? "sources" : "items"), undefined, format === "v2") ?? "absent",
1024
+ });
1025
+ }
1026
+ async function readFinalizedMetadata(root, lock, token, revision) {
1027
+ const raw = await checkedFile(path.join(lock, "finalized.json"));
1028
+ if (!raw) {
1029
+ if (await statMaybe(path.join(lock, "finalized.digest")))
1030
+ throw new Error("Finalized transaction digest exists without its record; refusing cleanup.");
1031
+ return undefined;
1032
+ }
1033
+ const digest = await checkedFile(path.join(lock, "finalized.digest"));
1034
+ if (!digest || digest.toString("utf8") !== hash(raw))
1035
+ throw new Error("Finalized transaction information is missing or altered; inspect the private journal.");
1036
+ const value = JSON.parse(raw.toString("utf8"));
1037
+ const catalog = revision.catalog === null ? null : hash(Buffer.from(revision.catalog, "base64"));
1038
+ if (value.version !== 1 || value.root !== root || value.token !== token || value.catalog !== catalog || value.tree !== revision.tree)
1039
+ throw new Error("Finalized transaction state was changed externally; refusing cleanup or rollback.");
1040
+ const cleanup = Object.hasOwn(value, "cleanup") ? parseFinalizedCleanup(value.cleanup, { root, token }) : undefined;
1041
+ return { format: revision.format, ...(cleanup ? { cleanup } : {}) };
1042
+ }
1043
+ async function createFinalizedCleanup(lock, journal, rawJournal) {
1044
+ const allowed = v2LockEntries(journal);
1045
+ const entries = await readdir(lock);
1046
+ if (entries.some((entry) => !allowed.has(entry)))
1047
+ throw new Error("Unknown files in v2 sources transaction lock; refusing finalized cleanup.");
1048
+ const storedJournal = await checkedFile(path.join(lock, "journal.json"));
1049
+ const journalDigest = await checkedFile(path.join(lock, "journal.digest"));
1050
+ if (!storedJournal?.equals(rawJournal) || !journalDigest || journalDigest.toString("utf8") !== hash(rawJournal))
1051
+ throw new Error("V2 journal changed before cleanup custody was recorded.");
1052
+ const artifacts = [];
1053
+ const present = new Set(entries);
1054
+ for (const name of entries) {
1055
+ if (name === "owner.json" || name === "journal.json" || name === "finalized.json" || name === "finalized.digest")
1056
+ continue;
1057
+ if (name === "journal.digest") {
1058
+ artifacts.push({ name, kind: "file", commitment: hash(journalDigest) });
1059
+ continue;
1060
+ }
1061
+ if (finalizedFileArtifacts.has(name)) {
1062
+ const file = await checkedFile(path.join(lock, name));
1063
+ const expected = name === "next-catalog" ? journal.newCatalog
1064
+ : name === "old-catalog" || name === "held-catalog" ? journal.oldCatalog
1065
+ : name === "displaced-catalog" ? journal.newCatalog
1066
+ : journal.token;
1067
+ if (!file || (name === "rollback.json" ? file.toString("utf8") !== expected : hash(file) !== expected))
1068
+ throw new Error(`Finalized v2 cleanup artifact ${name} changed.`);
1069
+ artifacts.push({ name, kind: "file", commitment: hash(file) });
1070
+ continue;
1071
+ }
1072
+ if (name === "next-sources") {
1073
+ const actual = await sourceArtifactTreeIfPresent(path.join(lock, name), "tree");
1074
+ if (!actual || actual !== journal.nextSourcesTree)
1075
+ throw new Error("Finalized new sources-root stage changed.");
1076
+ artifacts.push({ name, kind: "source-tree", commitment: actual });
1077
+ continue;
1078
+ }
1079
+ const match = /^(next|old|displaced)-(0|[1-9][0-9]*)$/.exec(name);
1080
+ if (!match)
1081
+ throw new Error("Unknown file in v2 sources transaction lock; refusing finalized cleanup.");
1082
+ const index = Number(match[2]);
1083
+ const operation = journal.operations[index];
1084
+ if (!operation || String(index) !== match[2])
1085
+ throw new Error("Unknown v2 operation index in finalized cleanup.");
1086
+ if (match[1] === "old" && operation.oldTree === null)
1087
+ throw new Error("New target journal unexpectedly contains an old source artifact.");
1088
+ const isParentStage = match[1] === "next" && operationIsStagedFileParent(journal, index);
1089
+ const artifactKind = isParentStage ? "tree" : operation.kind;
1090
+ const actual = await sourceArtifactTreeIfPresent(path.join(lock, name), artifactKind);
1091
+ if (actual === null)
1092
+ throw new Error(`Finalized v2 cleanup artifact ${name} is missing.`);
1093
+ if (isParentStage) {
1094
+ const parentStage = path.join(lock, name);
1095
+ const content = await sourceArtifactTreeIfPresent(operationStageContentPath(lock, journal, index), "file");
1096
+ if (content !== operation.newTree || JSON.stringify((await readdir(parentStage)).sort()) !== JSON.stringify([path.posix.basename(operation.target)])) {
1097
+ throw new Error(`Finalized new-file parent stage ${name} changed.`);
1098
+ }
1099
+ }
1100
+ else {
1101
+ const expected = match[1] === "old" ? operation.oldTree : operation.newTree;
1102
+ if (actual !== expected)
1103
+ throw new Error(`Finalized v2 cleanup artifact ${name} changed.`);
1104
+ }
1105
+ artifacts.push({ name, kind: `source-${artifactKind}`, commitment: actual });
1106
+ }
1107
+ if (!present.has("next-catalog") || !present.has("journal.digest") ||
1108
+ (journal.oldCatalog === null ? present.has("old-catalog") : !present.has("old-catalog")))
1109
+ throw new Error("Required v2 cleanup artifacts are missing.");
1110
+ artifacts.sort((left, right) => left.name.localeCompare(right.name));
1111
+ return {
1112
+ version: 1,
1113
+ root: journal.root,
1114
+ token: journal.token,
1115
+ operationCount: journal.operations.length,
1116
+ operationKinds: journal.operations.map((operation) => operation.kind),
1117
+ ...(journal.nextSourcesTree ? { operationStageKinds: journal.operations.map((operation, index) => operationIsStagedFileParent(journal, index) ? "tree" : operation.kind) } : {}),
1118
+ artifacts,
1119
+ };
1120
+ }
1121
+ async function cleanupFinalizedSourcesLock(root, lock, token, lease) {
1122
+ const revision = await captureStorageRevision(root);
1123
+ if (revision.format !== "v2")
1124
+ throw new Error("V2 finalized cleanup found a legacy catalog; refusing cleanup.");
1125
+ const metadata = await readFinalizedMetadata(root, lock, token, revision);
1126
+ const manifest = metadata?.cleanup;
1127
+ if (!manifest)
1128
+ throw new Error("V2 finalized cleanup custody information is missing; inspect the transaction lock.");
1129
+ const expected = new Set(["owner.json", "finalized.json", "finalized.digest", ...manifest.artifacts.map((artifact) => artifact.name)]);
1130
+ const entries = await readdir(lock);
1131
+ if (entries.length !== expected.size || entries.some((entry) => !expected.has(entry)))
1132
+ throw new Error("Unknown or missing files in finalized v2 transaction lock; refusing cleanup.");
1133
+ for (const artifact of manifest.artifacts) {
1134
+ const target = path.join(lock, artifact.name);
1135
+ if (artifact.kind === "file") {
1136
+ const bytes = await checkedFile(target);
1137
+ if (!bytes || hash(bytes) !== artifact.commitment)
1138
+ throw new Error(`Finalized v2 cleanup artifact ${artifact.name} changed; preserving it.`);
1139
+ }
1140
+ else if (await sourceArtifactTreeIfPresent(target, artifact.kind === "source-tree" ? "tree" : "file") !== artifact.commitment) {
1141
+ throw new Error(`Finalized v2 cleanup artifact ${artifact.name} changed; preserving it.`);
1142
+ }
1143
+ }
1144
+ await assertLeaseOwner(lease);
1145
+ for (const artifact of manifest.artifacts) {
1146
+ const target = path.join(lock, artifact.name);
1147
+ if (artifact.kind === "source-tree")
1148
+ await removeOwnedTreeArtifact(target, artifact.commitment);
1149
+ else
1150
+ await unlink(target);
1151
+ }
1152
+ await unlink(path.join(lock, "finalized.json"));
1153
+ await unlink(path.join(lock, "finalized.digest"));
1154
+ await unlink(path.join(lock, "owner.json"));
1155
+ await rmdir(lock);
1156
+ await syncDirectory(root);
1157
+ }
1158
+ async function cleanupFinalizedCatalogOnlyLock(root, lock, token, lease) {
1159
+ await assertLeaseOwner(lease);
1160
+ const journalPath = path.join(lock, "journal.json");
1161
+ const rawJournal = await checkedFile(journalPath);
1162
+ if (!rawJournal)
1163
+ throw new Error("Catalog-only cleanup journal is missing; preserving the finalized lock.");
1164
+ const journalDigest = await checkedFile(path.join(lock, "journal.digest"));
1165
+ if (!journalDigest || journalDigest.toString("utf8") !== hash(rawJournal))
1166
+ throw new Error("Catalog-only cleanup journal digest is missing or altered.");
1167
+ const parsed = parseStorageJournal(JSON.parse(rawJournal.toString("utf8")), { root, token });
1168
+ if (parsed.version !== 3)
1169
+ throw new Error("Catalog-only cleanup was dispatched for a non-v3 journal.");
1170
+ if (await catalogOnlySourcesTree(root) !== parsed.stableSourcesTree)
1171
+ throw new Error("Catalog-only cleanup found a changed or unsafe sources tree; preserving it.");
1172
+ const revision = await captureStorageRevision(root);
1173
+ if (revision.format !== "v2")
1174
+ throw new Error("Catalog-only cleanup found a legacy catalog; preserving its lock.");
1175
+ const metadata = await readFinalizedMetadata(root, lock, token, revision);
1176
+ const manifest = metadata?.cleanup;
1177
+ if (!manifest)
1178
+ throw new Error("Catalog-only finalized cleanup custody is missing; preserving the journal and stages.");
1179
+ const rebuilt = await createCatalogOnlyCleanup(lock, parsed, rawJournal);
1180
+ if (JSON.stringify(rebuilt) !== JSON.stringify(manifest))
1181
+ throw new Error("Catalog-only finalized cleanup custody changed; preserving the journal and stages.");
1182
+ const expected = new Set(["owner.json", "journal.json", "finalized.json", "finalized.digest", ...manifest.artifacts.map((artifact) => artifact.name)]);
1183
+ const entries = await readdir(lock);
1184
+ if (entries.length !== expected.size || entries.some((entry) => !expected.has(entry))) {
1185
+ throw new Error("Catalog-only cleanup found unknown or missing artifacts; preserving every remaining byte.");
1186
+ }
1187
+ const identities = new Map();
1188
+ for (const artifact of manifest.artifacts) {
1189
+ if (artifact.kind !== "file")
1190
+ throw new Error("Catalog-only cleanup manifest contains a non-file artifact.");
1191
+ const target = path.join(lock, artifact.name);
1192
+ const bytes = await checkedFile(target);
1193
+ const stat = await statMaybe(target);
1194
+ if (!bytes || !stat || hash(bytes) !== artifact.commitment)
1195
+ throw new Error(`Catalog-only cleanup artifact ${artifact.name} is missing or altered; preserve the journal and remaining artifacts for manual inspection with a newly verified recovery build.`);
1196
+ identities.set(artifact.name, { dev: stat.dev, ino: stat.ino });
1197
+ }
1198
+ await assertLeaseOwner(lease);
1199
+ if (await catalogOnlySourcesTree(root) !== parsed.stableSourcesTree) {
1200
+ throw new Error("Catalog-only sources tree changed during cleanup; preserve the journal and remaining artifacts.");
1201
+ }
1202
+ const finalizedDigestPath = path.join(lock, "finalized.digest");
1203
+ const finalizedDigest = await checkedFile(finalizedDigestPath);
1204
+ const finalizedDigestStat = await statMaybe(finalizedDigestPath);
1205
+ if (!finalizedDigest || !finalizedDigestStat)
1206
+ throw new Error("Catalog-only finalized digest disappeared before cleanup.");
1207
+ await unlink(finalizedDigestPath);
1208
+ await unlink(path.join(lock, "finalized.json"));
1209
+ await syncDirectory(lock);
1210
+ const cleanupNames = manifest.artifacts.map((artifact) => artifact.name).filter((name) => name !== "journal.digest").sort();
1211
+ for (const name of cleanupNames) {
1212
+ await assertLeaseOwner(lease);
1213
+ const target = path.join(lock, name);
1214
+ const stat = await statMaybe(target);
1215
+ const bytes = await checkedFile(target);
1216
+ const identity = identities.get(name);
1217
+ const artifact = manifest.artifacts.find((entry) => entry.name === name);
1218
+ if (!stat || !bytes || !identity || !artifact || !sameDirectory({ dev: stat.dev, ino: stat.ino }, identity) || hash(bytes) !== artifact.commitment) {
1219
+ throw new Error(`Catalog-only cleanup artifact ${name} changed before deletion; preserving all remaining artifacts.`);
1220
+ }
1221
+ await unlink(target);
1222
+ }
1223
+ await syncDirectory(lock);
1224
+ if (await catalogOnlySourcesTree(root) !== parsed.stableSourcesTree) {
1225
+ throw new Error("Catalog-only sources tree changed before journal cleanup; preserve the journal and remaining artifacts.");
1226
+ }
1227
+ const digestPath = path.join(lock, "journal.digest");
1228
+ const digestStat = await statMaybe(digestPath);
1229
+ const digestBytes = await checkedFile(digestPath);
1230
+ const digestIdentity = identities.get("journal.digest");
1231
+ const digestArtifact = manifest.artifacts.find((entry) => entry.name === "journal.digest");
1232
+ if (!digestStat || !digestBytes || !digestIdentity || !digestArtifact ||
1233
+ !sameDirectory({ dev: digestStat.dev, ino: digestStat.ino }, digestIdentity) || hash(digestBytes) !== digestArtifact.commitment) {
1234
+ throw new Error("Catalog-only cleanup journal digest changed before deletion; preserving the journal.");
1235
+ }
1236
+ await unlink(digestPath);
1237
+ await syncDirectory(lock);
1238
+ const journalStat = await statMaybe(journalPath);
1239
+ const journalBytesAtFinish = await checkedFile(journalPath);
1240
+ if (!journalStat || !journalBytesAtFinish?.equals(rawJournal))
1241
+ throw new Error("Catalog-only journal changed before final cleanup; preserving it.");
1242
+ await unlink(journalPath);
1243
+ await syncDirectory(lock);
1244
+ await assertLeaseOwner(lease);
1245
+ await unlink(path.join(lock, "owner.json"));
1246
+ await rmdir(lock);
1247
+ await syncDirectory(root);
1248
+ }
1249
+ async function cleanupUnjournaledLock(root, lock, lease) {
1250
+ const expected = new Set(["owner.json", ...lease.unjournaledArtifacts.keys()]);
1251
+ const entries = await readdir(lock);
1252
+ if (entries.length !== expected.size || entries.some((entry) => !expected.has(entry)))
1253
+ throw new Error("Unknown files in an unjournaled transaction lock; refusing cleanup.");
1254
+ for (const [name, artifact] of lease.unjournaledArtifacts) {
1255
+ const target = path.join(lock, name);
1256
+ const stat = await statMaybe(target);
1257
+ if (!stat || !sameDirectory({ dev: stat.dev, ino: stat.ino }, artifact.identity))
1258
+ throw new Error(`Unjournaled artifact ${name} changed; preserving it.`);
1259
+ const commitment = artifact.kind === "file" ? hash((await checkedFile(target)) ?? Buffer.alloc(0)) : await sourceArtifactTreeIfPresent(target, "tree");
1260
+ if (commitment !== artifact.commitment)
1261
+ throw new Error(`Unjournaled artifact ${name} changed; preserving it.`);
1262
+ }
1263
+ await assertLeaseOwner(lease);
1264
+ for (const [name, artifact] of lease.unjournaledArtifacts) {
1265
+ const target = path.join(lock, name);
1266
+ if (artifact.kind === "tree")
1267
+ await removeOwnedTreeArtifact(target, artifact.commitment);
1268
+ else
1269
+ await unlink(target);
1270
+ }
1271
+ await unlink(path.join(lock, "owner.json"));
1272
+ await rmdir(lock);
1273
+ await syncDirectory(root);
1274
+ }
1275
+ async function cleanupLegacyMigrationLock(root, lock, token, lease) {
1276
+ await assertLeaseOwner(lease);
1277
+ const journalPath = path.join(lock, "journal.json");
1278
+ const digestPath = path.join(lock, "journal.digest");
1279
+ const raw = await checkedFile(journalPath);
1280
+ const digestBytes = await checkedFile(digestPath);
1281
+ if (!raw || !digestBytes || digestBytes.toString("utf8") !== hash(raw))
1282
+ throw new Error("Legacy migration journal integrity is missing or altered; preserving all bytes.");
1283
+ const parsed = parseStorageJournal(JSON.parse(raw.toString("utf8")), { root, token });
1284
+ if (parsed.version !== 4 && parsed.version !== 5)
1285
+ throw new Error("Legacy migration cleanup received a different journal version.");
1286
+ if (parsed.version === 5 && raw.toString("utf8") !== canonicalStorageJournalJson(parsed))
1287
+ throw new Error("V5 migration journal is not canonical JSON; preserving all bytes.");
1288
+ const inspected = await inspectLegacyMigrationJournal(root, lock, token, parsed, lease.recoveryClaim !== undefined);
1289
+ if (inspected.currentHash !== parsed.oldCatalog && inspected.currentHash !== parsed.newCatalog) {
1290
+ throw new Error("V4 migration did not reach a checked terminal catalog state; preserving every artifact.");
1291
+ }
1292
+ const files = new Map();
1293
+ for (const name of await readdir(lock)) {
1294
+ if (name === "owner.json")
1295
+ continue;
1296
+ const target = path.join(lock, name);
1297
+ const bytes = await checkedFile(target);
1298
+ const stat = await statMaybe(target);
1299
+ if (!bytes || !stat)
1300
+ throw new Error(`V4 cleanup artifact ${name} is missing or unsafe; preserving all remaining bytes.`);
1301
+ files.set(name, { bytes, dev: stat.dev, ino: stat.ino });
1302
+ }
1303
+ await assertLeaseOwner(lease);
1304
+ const rechecked = await inspectLegacyMigrationJournal(root, lock, token, parsed, lease.recoveryClaim !== undefined);
1305
+ if (rechecked.currentHash !== inspected.currentHash)
1306
+ throw new Error("V4 catalog state changed during cleanup; preserving its journal.");
1307
+ for (const name of [...files.keys()].filter((entry) => entry !== "journal.json" && entry !== "journal.digest").sort()) {
1308
+ await assertLeaseOwner(lease);
1309
+ const target = path.join(lock, name);
1310
+ const bytes = await checkedFile(target);
1311
+ const stat = await statMaybe(target);
1312
+ const expected = files.get(name);
1313
+ if (!bytes?.equals(expected.bytes) || !stat || !sameDirectory({ dev: stat.dev, ino: stat.ino }, expected)) {
1314
+ throw new Error(`V4 cleanup artifact ${name} changed; preserving all remaining bytes.`);
1315
+ }
1316
+ await unlink(target);
1317
+ }
1318
+ await assertLeaseOwner(lease);
1319
+ const journalNow = await checkedFile(journalPath);
1320
+ const digestNow = await checkedFile(digestPath);
1321
+ if (!journalNow?.equals(raw) || !digestNow?.equals(digestBytes))
1322
+ throw new Error("V4 journal changed before cleanup; preserving it.");
1323
+ await unlink(journalPath);
1324
+ await unlink(digestPath);
1325
+ await assertLeaseOwner(lease);
1326
+ await unlink(path.join(lock, "owner.json"));
1327
+ await rmdir(lock);
1328
+ await syncDirectory(root);
1329
+ }
1330
+ async function cleanupTransactionLock(root, lock, token, lease) {
1331
+ await assertLeaseOwner(lease);
1332
+ const rawJournal = await checkedFile(path.join(lock, "journal.json"));
1333
+ if (rawJournal) {
1334
+ const journal = parseStorageJournal(JSON.parse(rawJournal.toString("utf8")), { root, token });
1335
+ if (journal.version === 4 || journal.version === 5) {
1336
+ await cleanupLegacyMigrationLock(root, lock, token, lease);
1337
+ return;
1338
+ }
1339
+ if (journal.version !== 3)
1340
+ throw new Error("A non-catalog-only transaction journal remains after recovery; refusing cleanup.");
1341
+ await cleanupFinalizedCatalogOnlyLock(root, lock, token, lease);
1342
+ return;
1343
+ }
1344
+ const entries = await readdir(lock);
1345
+ const hasFinalizedRecord = entries.includes("finalized.json") || entries.includes("finalized.digest");
1346
+ if (!hasFinalizedRecord) {
1347
+ await cleanupUnjournaledLock(root, lock, lease);
1348
+ return;
1349
+ }
1350
+ const revision = await captureStorageRevision(root);
1351
+ const finalized = await readFinalizedMetadata(root, lock, token, revision);
1352
+ if (finalized?.format === "v2")
1353
+ await cleanupFinalizedSourcesLock(root, lock, token, lease);
1354
+ else if (finalized?.format === "v1") {
1355
+ await rm(lock, { recursive: true });
1356
+ await syncDirectory(root);
1357
+ }
1358
+ else
1359
+ await cleanupUnjournaledLock(root, lock, lease);
1360
+ }
1361
+ async function syncDirectory(target) {
1362
+ const handle = await open(target, "r");
1363
+ try {
1364
+ await handle.sync();
1365
+ }
1366
+ finally {
1367
+ await handle.close();
1368
+ }
1369
+ }
1370
+ async function createPrivateTree(stage, files) {
1371
+ await mkdir(stage, { mode: 0o700 });
1372
+ await chmod(stage, 0o700);
1373
+ for (const file of files) {
1374
+ let directory = stage;
1375
+ for (const segment of file.path.split("/").slice(0, -1)) {
1376
+ directory = path.join(directory, segment);
1377
+ const existing = await statMaybe(directory);
1378
+ if (existing) {
1379
+ if (!existing.isDirectory() || existing.isSymbolicLink())
1380
+ throw new Error("Unsafe directory in private tree stage.");
1381
+ }
1382
+ else {
1383
+ await mkdir(directory, { mode: 0o700 });
1384
+ await chmod(directory, 0o700);
1385
+ }
1386
+ }
1387
+ const target = path.join(stage, ...file.path.split("/"));
1388
+ await writePrivate(target, file.bytes);
1389
+ await chmod(target, 0o600);
1390
+ await syncDirectory(directory);
1391
+ }
1392
+ }
1393
+ async function writePrivate(target, bytes) {
1394
+ const handle = await open(target, "wx", 0o600);
1395
+ try {
1396
+ await handle.writeFile(bytes);
1397
+ await handle.sync();
1398
+ }
1399
+ finally {
1400
+ await handle.close();
1401
+ }
1402
+ }
1403
+ async function checkedLegacyMigrationInput(root, input) {
1404
+ if (input.path !== "catalog.json" && (!safeStoragePath(input.path) ||
1405
+ (!input.path.startsWith("items/") && !input.path.startsWith("sources/")))) {
1406
+ throw new Error("Legacy migration backup input has an unsafe original path.");
1407
+ }
1408
+ const target = path.join(root, ...input.path.split("/"));
1409
+ const before = await lstat(target, { bigint: true });
1410
+ if (!before.isFile() || before.isSymbolicLink() || before.dev.toString() !== input.dev || before.ino.toString() !== input.ino ||
1411
+ Number(before.mode & 511n) !== input.mode)
1412
+ throw new Error(`Legacy migration input ${input.path} changed identity or mode.`);
1413
+ const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
1414
+ try {
1415
+ const opened = await handle.stat({ bigint: true });
1416
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.mode !== before.mode || opened.size !== before.size ||
1417
+ opened.mtimeNs !== before.mtimeNs || opened.ctimeNs !== before.ctimeNs)
1418
+ throw new Error(`Legacy migration input ${input.path} changed while opening.`);
1419
+ const bytes = await handle.readFile();
1420
+ const [afterHandle, afterPath] = await Promise.all([handle.stat({ bigint: true }), lstat(target, { bigint: true })]);
1421
+ if (!afterPath.isFile() || afterPath.isSymbolicLink() || afterHandle.dev !== opened.dev || afterHandle.ino !== opened.ino ||
1422
+ afterHandle.size !== opened.size || afterHandle.mtimeNs !== opened.mtimeNs || afterHandle.ctimeNs !== opened.ctimeNs ||
1423
+ afterPath.dev !== opened.dev || afterPath.ino !== opened.ino || afterPath.mode !== opened.mode || afterPath.size !== opened.size ||
1424
+ afterPath.mtimeNs !== opened.mtimeNs || afterPath.ctimeNs !== opened.ctimeNs || hash(bytes) !== input.sha256 || !bytes.equals(input.bytes)) {
1425
+ throw new Error(`Legacy migration input ${input.path} changed while being read.`);
1426
+ }
1427
+ }
1428
+ finally {
1429
+ await handle.close();
1430
+ }
1431
+ }
1432
+ function migrationInventoryExclusions(startInventory, currentInventory, protocolArtifacts, backupCreated) {
1433
+ const excluded = [...protocolArtifacts];
1434
+ const backupParent = ".migration-backups";
1435
+ const startHadBackupParent = startInventory.nodes.some((node) => node.path === backupParent);
1436
+ const currentBackupParent = currentInventory.nodes.find((node) => node.path === backupParent);
1437
+ const hasUnownedBackupChildren = currentInventory.nodes.some((node) => node.path !== backupParent && path.posix.dirname(node.path) === backupParent);
1438
+ if (backupCreated && !startHadBackupParent && currentBackupParent?.kind === "directory" && !hasUnownedBackupChildren) {
1439
+ excluded.push(backupParent);
1440
+ }
1441
+ return excluded;
1442
+ }
1443
+ async function assertLegacyMigrationSnapshot(root, request, backupCreated, catalogHeld = false, catalogChanged = false) {
1444
+ const protocolArtifacts = [lockName, `${lockName}-recovery`, ...(backupCreated ? [request.backupDir] : []), ...(catalogHeld ? ["catalog.json"] : [])];
1445
+ const omittedProofs = catalogHeld ? [] : ["catalog.json"];
1446
+ const scannedInventory = await scanMigrationInventory(root, { excludedPrefixes: protocolArtifacts });
1447
+ const excluded = migrationInventoryExclusions(request.startInventory, scannedInventory, protocolArtifacts, backupCreated);
1448
+ const expectedInventory = projectMigrationInventory(request.startInventory, excluded, omittedProofs);
1449
+ const currentInventory = projectMigrationInventory(scannedInventory, excluded, omittedProofs);
1450
+ if (expectedInventory.hash !== currentInventory.hash || JSON.stringify(expectedInventory.nodes) !== JSON.stringify(currentInventory.nodes)) {
1451
+ throw new Error("A library node outside the owned migration artifacts changed; preserving the reviewed start inventory.");
1452
+ }
1453
+ const expectedEntries = [...request.rootEntries.filter((entry) => !(catalogHeld && entry === "catalog.json")), lockName,
1454
+ ...(backupCreated ? [".migration-backups"] : [])].sort();
1455
+ const currentEntries = await listManagedMigrationRootEntries(root);
1456
+ if (JSON.stringify(currentEntries) !== JSON.stringify(expectedEntries) || await statMaybe(path.join(root, `${lockName}-recovery`))) {
1457
+ throw new Error("Library root entries changed during legacy migration; refusing the catalog switch.");
1458
+ }
1459
+ if ((await tree(path.join(root, "items"), undefined, true) ?? "absent") !== request.itemsTree ||
1460
+ (await tree(path.join(root, "sources"), undefined, true) ?? "absent") !== request.sourcesTree) {
1461
+ throw new Error("Legacy items or sources changed during adoption; refusing the catalog switch.");
1462
+ }
1463
+ for (const input of request.inputs) {
1464
+ if (backupCreated && input.path === "catalog.json" && (catalogHeld || catalogChanged))
1465
+ continue;
1466
+ await checkedLegacyMigrationInput(root, input);
1467
+ }
1468
+ }
1469
+ async function actualLegacyMigrationInputs(root, catalogBytes, catalogMode) {
1470
+ const entries = [];
1471
+ const visit = async (absolute, relative) => {
1472
+ const beforeDirectory = await lstat(absolute, { bigint: true });
1473
+ if (!beforeDirectory.isDirectory() || beforeDirectory.isSymbolicLink())
1474
+ throw new Error("Legacy migration backup coverage encountered an unsafe directory.");
1475
+ const names = (await readdir(absolute)).sort();
1476
+ for (const name of names) {
1477
+ const childRelative = `${relative}/${name}`;
1478
+ const child = path.join(absolute, name);
1479
+ const stat = await lstat(child, { bigint: true });
1480
+ if (stat.isSymbolicLink())
1481
+ throw new Error("Legacy migration backup coverage encountered a symbolic link.");
1482
+ if (stat.isDirectory())
1483
+ await visit(child, childRelative);
1484
+ else if (stat.isFile()) {
1485
+ const bytes = await readFile(child);
1486
+ const after = await lstat(child, { bigint: true });
1487
+ if (!after.isFile() || after.isSymbolicLink() || stat.dev !== after.dev || stat.ino !== after.ino || stat.mode !== after.mode ||
1488
+ stat.size !== after.size || stat.mtimeNs !== after.mtimeNs || stat.ctimeNs !== after.ctimeNs) {
1489
+ throw new Error("Legacy migration backup input changed while enumerating coverage.");
1490
+ }
1491
+ entries.push({ originalPath: childRelative, sha256: hash(bytes), mode: Number(after.mode & 511n) });
1492
+ }
1493
+ else
1494
+ throw new Error("Legacy migration backup coverage encountered a special file.");
1495
+ }
1496
+ const afterDirectory = await lstat(absolute, { bigint: true });
1497
+ if (afterDirectory.dev !== beforeDirectory.dev || afterDirectory.ino !== beforeDirectory.ino ||
1498
+ JSON.stringify((await readdir(absolute)).sort()) !== JSON.stringify(names)) {
1499
+ throw new Error("Legacy migration backup coverage changed while enumerating a directory.");
1500
+ }
1501
+ };
1502
+ for (const name of ["items", "sources"]) {
1503
+ const target = path.join(root, name);
1504
+ const stat = await statMaybe(target);
1505
+ if (stat) {
1506
+ if (!stat.isDirectory() || stat.isSymbolicLink())
1507
+ throw new Error(`Legacy migration ${name} tree is unsafe.`);
1508
+ await visit(target, name);
1509
+ }
1510
+ }
1511
+ entries.push({ originalPath: "catalog.json", sha256: hash(catalogBytes), mode: catalogMode });
1512
+ return entries.sort((left, right) => left.originalPath < right.originalPath ? -1 : left.originalPath > right.originalPath ? 1 : 0);
1513
+ }
1514
+ async function assertLegacyMigrationInputCoverage(root, request) {
1515
+ const catalogInput = request.inputs.find((input) => input.path === "catalog.json");
1516
+ if (!catalogInput)
1517
+ throw new Error("Legacy migration backup inputs omit catalog.json.");
1518
+ const actual = await actualLegacyMigrationInputs(root, request.oldCatalogBytes, catalogInput.mode);
1519
+ const expected = request.inputs.map(({ path: originalPath, sha256, mode }) => ({ originalPath, sha256, mode }))
1520
+ .sort((left, right) => left.originalPath < right.originalPath ? -1 : left.originalPath > right.originalPath ? 1 : 0);
1521
+ if (JSON.stringify(actual) !== JSON.stringify(expected))
1522
+ throw new Error("Legacy migration backup does not cover every original catalog/items/sources file exactly.");
1523
+ }
1524
+ async function writeLegacyMigrationBackup(root, request) {
1525
+ const parent = path.join(root, ".migration-backups");
1526
+ if (await statMaybe(parent))
1527
+ throw new Error("An existing migration backup namespace is not adopted; preserve it and stop.");
1528
+ await mkdir(parent, { mode: 0o700 });
1529
+ await chmod(parent, 0o700);
1530
+ const backupRoot = path.join(root, ...request.backupDir.split("/"));
1531
+ await mkdir(backupRoot, { mode: 0o700 });
1532
+ await chmod(backupRoot, 0o700);
1533
+ const filesRoot = path.join(backupRoot, "files");
1534
+ await mkdir(filesRoot, { mode: 0o700 });
1535
+ await chmod(filesRoot, 0o700);
1536
+ const madeDirectories = new Set([filesRoot]);
1537
+ for (const input of request.inputs) {
1538
+ let directory = filesRoot;
1539
+ const parts = input.path.split("/");
1540
+ for (const segment of parts.slice(0, -1)) {
1541
+ directory = path.join(directory, segment);
1542
+ if (!madeDirectories.has(directory)) {
1543
+ await mkdir(directory, { mode: 0o700 });
1544
+ await chmod(directory, 0o700);
1545
+ madeDirectories.add(directory);
1546
+ }
1547
+ }
1548
+ const target = path.join(directory, parts.at(-1));
1549
+ await writePrivate(target, input.bytes);
1550
+ await chmod(target, 0o600);
1551
+ await syncDirectory(directory);
1552
+ }
1553
+ await writePrivate(path.join(backupRoot, "manifest.json"), request.backupManifestBytes);
1554
+ await chmod(path.join(backupRoot, "manifest.json"), 0o600);
1555
+ await syncDirectory(filesRoot);
1556
+ await syncDirectory(backupRoot);
1557
+ await syncDirectory(parent);
1558
+ await syncDirectory(root);
1559
+ return { root: backupRoot, parent: await directoryIdentity(parent) };
1560
+ }
1561
+ async function assertLegacyMigrationBackup(root, request) {
1562
+ const entries = request.inputs.map(({ path: originalPath, sha256, mode }) => ({ originalPath, sha256, mode }));
1563
+ const expectedManifest = buildMigrationBackupManifest(entries);
1564
+ if (!expectedManifest.equals(request.backupManifestBytes))
1565
+ throw new Error("Fixed legacy migration backup manifest does not cover the exact reviewed inputs.");
1566
+ const manifestPath = path.join(root, ...request.backupDir.split("/"), "manifest.json");
1567
+ const manifestBytes = await checkedFile(manifestPath);
1568
+ if (!manifestBytes || !manifestBytes.equals(request.backupManifestBytes))
1569
+ throw new Error("Fixed legacy migration backup manifest is missing or altered.");
1570
+ await verifyMigrationBackup({
1571
+ libraryRoot: root,
1572
+ backupDir: request.backupDir,
1573
+ manifestBytes: request.backupManifestBytes,
1574
+ manifestSha256: hash(request.backupManifestBytes),
1575
+ entries,
1576
+ });
1577
+ }
1578
+ /** @internal Plan-bound bridge; the request is checked against the opaque public preview before issuance. */
1579
+ export async function prepareLegacyMigrationReview(plan, value) {
1580
+ if (legacyMigrationPlanReviews.has(plan))
1581
+ throw new Error("Prepared migration already issued a storage review; prepare and review again.");
1582
+ const fields = snapshotOwnDataRecord(value, ["root", "startInventory", "oldCatalogBytes", "oldCatalogIdentity", "catalogBytes", "itemsTree", "sourcesTree", "rootEntries", "inputs", "backupDir", "backupManifestBytes", "reviewId", "sensitivePaths"]);
1583
+ if (!fields || typeof fields.root !== "string" || !path.isAbsolute(fields.root) || path.resolve(fields.root) !== fields.root ||
1584
+ typeof fields.oldCatalogIdentity !== "string" || typeof fields.itemsTree !== "string" || typeof fields.sourcesTree !== "string" ||
1585
+ typeof fields.backupDir !== "string" || typeof fields.reviewId !== "string" || !itemPattern.test(fields.reviewId) ||
1586
+ isNativeProxy(fields.oldCatalogBytes) || !Buffer.isBuffer(fields.oldCatalogBytes) ||
1587
+ isNativeProxy(fields.catalogBytes) || !Buffer.isBuffer(fields.catalogBytes) ||
1588
+ isNativeProxy(fields.backupManifestBytes) || !Buffer.isBuffer(fields.backupManifestBytes)) {
1589
+ throw new Error("Legacy migration review must be a fixed own-data snapshot of canonical paths and bytes.");
1590
+ }
1591
+ const rootEntries = snapshotDenseOwnDataArray(fields.rootEntries);
1592
+ const rawInputs = snapshotDenseOwnDataArray(fields.inputs);
1593
+ const sensitivePaths = snapshotDenseOwnDataArray(fields.sensitivePaths);
1594
+ if (!rootEntries || rootEntries.some((entry) => typeof entry !== "string") || !rawInputs || !sensitivePaths ||
1595
+ sensitivePaths.some((entry) => typeof entry !== "string")) {
1596
+ throw new Error("Legacy migration review collections must be dense own-data arrays.");
1597
+ }
1598
+ const inputs = rawInputs.map((entry) => {
1599
+ const input = snapshotOwnDataRecord(entry, ["path", "bytes", "mode", "sha256", "dev", "ino"]);
1600
+ if (!input || typeof input.path !== "string" || isNativeProxy(input.bytes) || !Buffer.isBuffer(input.bytes) ||
1601
+ typeof input.mode !== "number" || !Number.isInteger(input.mode) || input.mode < 0 || input.mode > 0o777 ||
1602
+ typeof input.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(input.sha256) ||
1603
+ typeof input.dev !== "string" || typeof input.ino !== "string") {
1604
+ throw new Error("Legacy migration review input must be fixed regular-file bytes and identities.");
1605
+ }
1606
+ return { path: input.path, bytes: Buffer.from(input.bytes), mode: input.mode, sha256: input.sha256, dev: input.dev, ino: input.ino };
1607
+ });
1608
+ const startInventory = parseMigrationStartInventory(fields.startInventory);
1609
+ const request = {
1610
+ root: fields.root,
1611
+ startInventory,
1612
+ oldCatalogBytes: Buffer.from(fields.oldCatalogBytes),
1613
+ oldCatalogIdentity: fields.oldCatalogIdentity,
1614
+ catalogBytes: Buffer.from(fields.catalogBytes),
1615
+ itemsTree: fields.itemsTree,
1616
+ sourcesTree: fields.sourcesTree,
1617
+ rootEntries: rootEntries,
1618
+ inputs,
1619
+ backupDir: fields.backupDir,
1620
+ backupManifestBytes: Buffer.from(fields.backupManifestBytes),
1621
+ reviewId: fields.reviewId,
1622
+ sensitivePaths: sensitivePaths.slice().sort(),
1623
+ };
1624
+ const inventoryCatalog = request.startInventory.nodes.find((node) => node.path === "catalog.json");
1625
+ const fixedCatalogInput = request.inputs.find((input) => input.path === "catalog.json");
1626
+ if (!inventoryCatalog || inventoryCatalog.kind !== "file" || !fixedCatalogInput ||
1627
+ inventoryCatalog.bytesHash !== hash(request.oldCatalogBytes) || inventoryCatalog.mode !== fixedCatalogInput.mode ||
1628
+ inventoryCatalog.dev !== fixedCatalogInput.dev || inventoryCatalog.ino !== fixedCatalogInput.ino ||
1629
+ request.oldCatalogIdentity !== `${inventoryCatalog.dev}:${inventoryCatalog.ino}`) {
1630
+ throw new Error("Legacy migration start inventory does not bind the exact original catalog proof.");
1631
+ }
1632
+ const oldRaw = JSON.parse(request.oldCatalogBytes.toString("utf8"));
1633
+ const nextRaw = JSON.parse(request.catalogBytes.toString("utf8"));
1634
+ const oldValue = parseStoredCatalog(oldRaw);
1635
+ const nextValue = parseStoredCatalog(nextRaw);
1636
+ assertCanonicalStoredCatalog(oldRaw, oldValue);
1637
+ assertCanonicalStoredCatalog(nextRaw, nextValue);
1638
+ if (oldValue.version !== 1 || nextValue.version !== 2 || !request.backupDir.startsWith(".migration-backups/") ||
1639
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(request.backupDir.slice(".migration-backups/".length))) {
1640
+ throw new Error("Legacy migration review is not a v1-to-v2 adopt-only transition.");
1641
+ }
1642
+ const journal = {
1643
+ version: 4, mode: "legacy-migration", root: request.root, token: "123e4567-e89b-42d3-a456-426614174000",
1644
+ oldCatalog: hash(request.oldCatalogBytes), newCatalog: hash(request.catalogBytes),
1645
+ stableItemsTree: request.itemsTree, stableSourcesTree: request.sourcesTree, stableSourcesTreeNext: request.sourcesTree,
1646
+ backupDir: request.backupDir, backupManifest: hash(request.backupManifestBytes), operations: [], ownedDirectories: [],
1647
+ };
1648
+ validateLegacyMigrationJournalTargets(journal, oldValue, nextValue);
1649
+ const inputsByPath = new Map(request.inputs.map((input) => [input.path, input]));
1650
+ const inspectedPaths = [];
1651
+ for (const item of nextValue.items) {
1652
+ const source = item.content.kind === "file" ? [inputsByPath.get(item.content.path)] : item.files.map((file) => inputsByPath.get(`${item.content.path}/${file.path}`));
1653
+ if (source.some((entry) => !entry))
1654
+ throw new Error("Legacy migration review target does not bind to fixed existing sources bytes.");
1655
+ const inspection = inspectFixedResource({
1656
+ candidate: { ...item, sourceRoot: path.dirname(item.sourcePath) },
1657
+ files: item.files.map((file, index) => ({ path: file.path, mode: file.mode, bytes: Buffer.from(source[item.content.kind === "file" ? 0 : index].bytes) })),
1658
+ excludedPaths: [],
1659
+ });
1660
+ inspectedPaths.push(...inspection.sensitivePaths.map((filePath) => `${item.id}/${filePath}`));
1661
+ }
1662
+ const exactSensitivePaths = [...new Set(inspectedPaths)].sort();
1663
+ if (JSON.stringify(exactSensitivePaths) !== JSON.stringify(request.sensitivePaths)) {
1664
+ throw new Error("Legacy migration review sensitive paths differ from the fixed selected source bytes.");
1665
+ }
1666
+ await assertPreparedMigrationRequest(plan, request);
1667
+ const review = Object.freeze({ reviewId: request.reviewId });
1668
+ legacyMigrationReviews.set(review, { request, used: false });
1669
+ legacyMigrationPlanReviews.add(plan);
1670
+ return review;
1671
+ }
1672
+ async function inspectLegacyMigrationJournal(root, lock, token, journal, recoveryClaim = false) {
1673
+ if (journal.version === 4 && (journal.operations.length !== 0 || journal.ownedDirectories.length !== 0 ||
1674
+ journal.stableSourcesTree !== journal.stableSourcesTreeNext)) {
1675
+ throw new Error("Legacy migration v4 recovery is not implemented for additions in this build; adopt-only is supported and additions are preserved.");
1676
+ }
1677
+ if (journal.version === 5 && (journal.operations.length !== 0 || journal.ownedDirectories.length !== 0 ||
1678
+ journal.stableSourcesTree !== journal.stableSourcesTreeNext)) {
1679
+ throw new Error("Invalid v5 migration additions; preserving every byte.");
1680
+ }
1681
+ const allowed = new Set(["owner.json", "next-catalog", "old-catalog", "held-catalog", "displaced-catalog", "rollback.json", "journal.json", "journal.digest"]);
1682
+ const names = await readdir(lock);
1683
+ if (names.some((name) => !allowed.has(name)) || !["owner.json", "next-catalog", "old-catalog", "journal.json", "journal.digest"].every((name) => names.includes(name))) {
1684
+ throw new Error("Unknown or missing adopt-only migration transaction artifact; preserving every byte.");
1685
+ }
1686
+ if ((await tree(path.join(root, "items"), undefined, true) ?? "absent") !== journal.stableItemsTree ||
1687
+ (await tree(path.join(root, "sources"), undefined, true) ?? "absent") !== journal.stableSourcesTree) {
1688
+ throw new Error("Legacy items or adopted sources changed during v4 recovery; preserving the journal and backup.");
1689
+ }
1690
+ const owner = await readOwner(lock);
1691
+ if (!owner.migrationRootEntries)
1692
+ throw new Error("V4 migration owner is missing its private root-entry commitment.");
1693
+ const currentCatalogPath = path.join(root, "catalog.json");
1694
+ const currentRootNames = await listManagedMigrationRootEntries(root);
1695
+ const expectedRootNames = [...owner.migrationRootEntries, lockName, ".migration-backups",
1696
+ ...(recoveryClaim ? [`${lockName}-recovery`] : [])].sort();
1697
+ const currentRootCatalog = await checkedFile(currentCatalogPath);
1698
+ const currentHashAtRoot = currentRootCatalog ? hash(currentRootCatalog) : null;
1699
+ if (journal.version === 5) {
1700
+ const protocolArtifacts = [lockName, `${lockName}-recovery`, journal.backupDir, ...(currentHashAtRoot === null ? ["catalog.json"] : [])];
1701
+ const omittedProofs = currentHashAtRoot === null ? [] : ["catalog.json"];
1702
+ const scannedInventory = await scanMigrationInventory(root, { excludedPrefixes: protocolArtifacts });
1703
+ const ignored = migrationInventoryExclusions(journal.startInventory, scannedInventory, protocolArtifacts, true);
1704
+ const expectedInventory = projectMigrationInventory(journal.startInventory, ignored, omittedProofs);
1705
+ const currentInventory = projectMigrationInventory(scannedInventory, ignored, omittedProofs);
1706
+ if (expectedInventory.hash !== currentInventory.hash || JSON.stringify(expectedInventory.nodes) !== JSON.stringify(currentInventory.nodes)) {
1707
+ throw new Error("A library node outside owned v5 migration artifacts changed; preserving the start inventory and all bytes.");
1708
+ }
1709
+ }
1710
+ const expectedNamesForState = currentHashAtRoot === null
1711
+ ? expectedRootNames.filter((entry) => entry !== "catalog.json")
1712
+ : expectedRootNames;
1713
+ if (JSON.stringify(currentRootNames) !== JSON.stringify(expectedNamesForState)) {
1714
+ throw new Error("V4 migration root entries changed; preserving the journal and every byte.");
1715
+ }
1716
+ const backupRoot = path.join(root, ...journal.backupDir.split("/"));
1717
+ const manifestBytes = await checkedFile(path.join(backupRoot, "manifest.json"));
1718
+ if (!manifestBytes || hash(manifestBytes) !== journal.backupManifest)
1719
+ throw new Error("V4 fixed backup manifest is missing or altered; preserving the migration journal and backup.");
1720
+ const manifest = parseMigrationBackupManifest(manifestBytes);
1721
+ const manifestEntries = manifest.entries.map(({ originalPath, sha256, mode }) => ({ originalPath, sha256, mode }));
1722
+ const backupCatalog = await checkedFile(path.join(backupRoot, "files", "catalog.json"));
1723
+ const catalogEntry = manifest.entries.find((entry) => entry.originalPath === "catalog.json");
1724
+ if (!backupCatalog || !catalogEntry || hash(backupCatalog) !== journal.oldCatalog ||
1725
+ JSON.stringify(await actualLegacyMigrationInputs(root, backupCatalog, catalogEntry.mode)) !== JSON.stringify(manifestEntries)) {
1726
+ throw new Error("V4 backup does not exactly cover the original catalog/items/sources bytes; preserving all custody.");
1727
+ }
1728
+ await verifyMigrationBackup({
1729
+ libraryRoot: root,
1730
+ backupDir: journal.backupDir,
1731
+ manifestBytes,
1732
+ manifestSha256: journal.backupManifest,
1733
+ entries: manifestEntries,
1734
+ });
1735
+ const parsedOldRaw = JSON.parse(backupCatalog.toString("utf8"));
1736
+ const parsedOld = parseStoredCatalog(parsedOldRaw);
1737
+ assertCanonicalStoredCatalog(parsedOldRaw, parsedOld);
1738
+ if (parsedOld.version !== 1)
1739
+ throw new Error("V4 backup does not contain its committed original v1 catalog.");
1740
+ const oldStage = path.join(lock, "old-catalog");
1741
+ const oldBytes = await checkedFile(oldStage);
1742
+ const oldStageStat = await statMaybe(oldStage);
1743
+ if (!oldBytes || !oldStageStat || hash(oldBytes) !== journal.oldCatalog || !oldBytes.equals(backupCatalog)) {
1744
+ throw new Error("V4 original catalog stage is missing or altered; preserving it.");
1745
+ }
1746
+ const nextStagePath = path.join(lock, "next-catalog");
1747
+ const nextStage = await checkedFile(nextStagePath);
1748
+ const nextStageStat = await statMaybe(nextStagePath);
1749
+ const catalogPath = path.join(root, "catalog.json");
1750
+ const current = await checkedFile(catalogPath);
1751
+ const currentStat = await statMaybe(catalogPath);
1752
+ const heldPath = path.join(lock, "held-catalog");
1753
+ const held = await checkedFile(heldPath);
1754
+ const heldStat = await statMaybe(heldPath);
1755
+ const displacedPath = path.join(lock, "displaced-catalog");
1756
+ const displaced = await checkedFile(displacedPath);
1757
+ const displacedStat = await statMaybe(displacedPath);
1758
+ const currentHash = current ? hash(current) : null;
1759
+ if (currentHash !== journal.oldCatalog && currentHash !== journal.newCatalog &&
1760
+ !(currentHash === null && held && hash(held) === journal.oldCatalog)) {
1761
+ throw new Error("V4 catalog is neither the checked old nor new inode; preserving journal, backup, and all bytes.");
1762
+ }
1763
+ if (held && (!heldStat || hash(held) !== journal.oldCatalog || !sameDirectory({ dev: heldStat.dev, ino: heldStat.ino }, { dev: oldStageStat.dev, ino: oldStageStat.ino }))) {
1764
+ throw new Error("V4 held original catalog inode is missing or altered; preserving it.");
1765
+ }
1766
+ if (currentHash === journal.newCatalog && !held)
1767
+ throw new Error("V4 committed catalog is missing its checked original held inode; preserving the backup and journal.");
1768
+ if (currentHash === journal.oldCatalog && (!currentStat || !sameDirectory({ dev: currentStat.dev, ino: currentStat.ino }, { dev: oldStageStat.dev, ino: oldStageStat.ino }))) {
1769
+ throw new Error("V4 current original catalog lost its reviewed inode; preserving it.");
1770
+ }
1771
+ if (nextStage) {
1772
+ const parsedNextRaw = JSON.parse(nextStage.toString("utf8"));
1773
+ const parsedNext = parseStoredCatalog(parsedNextRaw);
1774
+ assertCanonicalStoredCatalog(parsedNextRaw, parsedNext);
1775
+ if (!nextStageStat || hash(nextStage) !== journal.newCatalog || parsedNext.version !== 2)
1776
+ throw new Error("V4 next catalog stage is missing or altered.");
1777
+ if (currentHash === journal.newCatalog && (!currentStat || !sameDirectory({ dev: currentStat.dev, ino: currentStat.ino }, { dev: nextStageStat.dev, ino: nextStageStat.ino }))) {
1778
+ throw new Error("V4 installed catalog is not the reviewed staged inode.");
1779
+ }
1780
+ validateLegacyMigrationJournalTargets(journal, parsedOld, parsedNext);
1781
+ }
1782
+ else
1783
+ throw new Error("V4 next catalog stage is missing; preserving journal and backup.");
1784
+ if (displaced && (!displacedStat || hash(displaced) !== journal.newCatalog || !nextStageStat ||
1785
+ !sameDirectory({ dev: displacedStat.dev, ino: displacedStat.ino }, { dev: nextStageStat.dev, ino: nextStageStat.ino }))) {
1786
+ throw new Error("V4 displaced catalog is foreign or altered; preserving it.");
1787
+ }
1788
+ const marker = await checkedFile(path.join(lock, "rollback.json"));
1789
+ if (marker && marker.toString("utf8") !== token)
1790
+ throw new Error("Invalid v4 rollback marker; preserving the journal.");
1791
+ return { currentHash, oldBytes: backupCatalog, nextBytes: nextStage };
1792
+ }
1793
+ async function replacePrivate(target, bytes) {
1794
+ const handle = await open(target, "w", 0o600);
1795
+ try {
1796
+ await handle.writeFile(bytes);
1797
+ await handle.sync();
1798
+ }
1799
+ finally {
1800
+ await handle.close();
1801
+ }
1802
+ }
1803
+ async function canonicalRoot(libraryDir, create) {
1804
+ const requested = path.resolve(libraryDir);
1805
+ if (requested === path.parse(requested).root)
1806
+ throw new Error("Refusing to use the filesystem root as a copi directory.");
1807
+ if (!await statMaybe(requested)) {
1808
+ if (!create)
1809
+ return undefined;
1810
+ await mkdir(requested, { recursive: true, mode: 0o700 });
1811
+ }
1812
+ await checkedDirectory(requested);
1813
+ return realpath(requested);
1814
+ }
1815
+ export async function absentStorageRevision(libraryDir) {
1816
+ let existing = path.resolve(libraryDir);
1817
+ const missing = [];
1818
+ while (!await statMaybe(existing)) {
1819
+ missing.unshift(path.basename(existing));
1820
+ existing = path.dirname(existing);
1821
+ }
1822
+ const root = path.join(await realpath(existing), ...missing);
1823
+ return Object.freeze({ root, catalog: null, catalogIdentity: null, format: "v2", tree: "absent" });
1824
+ }
1825
+ function ownerFrom(value) {
1826
+ const owner = value;
1827
+ if (!owner || owner.version !== 1 || !Number.isSafeInteger(owner.pid) || typeof owner.pid !== "number" || owner.pid < 1 || typeof owner.host !== "string" || typeof owner.token !== "string" || !itemPattern.test(owner.token))
1828
+ throw new Error("Copi transaction lock owner is missing or malformed; inspect the lock before recovery.");
1829
+ if (Object.hasOwn(owner, "migrationRootEntries")) {
1830
+ const entries = owner.migrationRootEntries;
1831
+ if (!Array.isArray(entries) || entries.some((entry) => typeof entry !== "string" || !entry || entry.includes("/") || entry.includes("\\") || entry.includes("\0") || entry === "." || entry === "..") ||
1832
+ new Set(entries).size !== entries.length || JSON.stringify([...entries].sort()) !== JSON.stringify(entries) ||
1833
+ entries.some((entry) => !isManagedMigrationRootName(entry) ||
1834
+ entry === lockName || entry === `${lockName}-recovery` || entry === ".migration-backups")) {
1835
+ throw new Error("Copi migration lock owner root-entry commitment is malformed; inspect the lock before recovery.");
1836
+ }
1837
+ }
1838
+ return owner;
1839
+ }
1840
+ function alive(owner) {
1841
+ if (owner.host !== os.hostname())
1842
+ throw new Error("Copi transaction lock belongs to an unknown host; automatic recovery is unsafe.");
1843
+ try {
1844
+ process.kill(owner.pid, 0);
1845
+ return true;
1846
+ }
1847
+ catch (error) {
1848
+ if (error.code === "ESRCH")
1849
+ return false;
1850
+ throw new Error("Cannot establish copi lock ownership; automatic recovery is unsafe.", { cause: error });
1851
+ }
1852
+ }
1853
+ async function readOwner(lock) {
1854
+ await directoryIdentity(lock);
1855
+ const bytes = await checkedFile(path.join(lock, "owner.json"));
1856
+ try {
1857
+ return ownerFrom(bytes ? JSON.parse(bytes.toString("utf8")) : null);
1858
+ }
1859
+ catch (error) {
1860
+ throw new Error("Copi transaction lock owner is missing or malformed; inspect the lock before recovery.", { cause: error });
1861
+ }
1862
+ }
1863
+ async function directoryIdentity(target) {
1864
+ const stat = await statMaybe(target);
1865
+ if (!stat || !stat.isDirectory() || stat.isSymbolicLink())
1866
+ throw new Error("Copi session directory is missing, unsafe, or a symbolic link; refusing mutation or cleanup.");
1867
+ return { dev: stat.dev, ino: stat.ino };
1868
+ }
1869
+ async function storageDirectories(root, lock, checkItems = true) {
1870
+ await directoryIdentity(root);
1871
+ if (await realpath(root) !== root)
1872
+ throw new Error("Copi storage root changed through a symbolic link.");
1873
+ await directoryIdentity(lock);
1874
+ if (checkItems)
1875
+ await checkedDirectory(path.join(root, "items"));
1876
+ }
1877
+ function sameDirectory(left, right) {
1878
+ return left.dev === right.dev && left.ino === right.ino;
1879
+ }
1880
+ async function issueSessionLease(session, root, lock, owner, claim) {
1881
+ const lease = {
1882
+ root, lock, owner: { ...owner }, stop: new AbortController(), pending: new Set(), unjournaledArtifacts: new Map(),
1883
+ rootIdentity: await directoryIdentity(root), lockIdentity: await directoryIdentity(lock),
1884
+ ...(claim ? { recoveryClaim: { path: claim, identity: await directoryIdentity(claim) } } : {}),
1885
+ };
1886
+ await assertLeaseOwner(lease);
1887
+ sessionLeases.set(session, lease);
1888
+ return lease;
1889
+ }
1890
+ async function assertLeaseOwner(lease) {
1891
+ await storageDirectories(lease.root, lease.lock, false);
1892
+ if (!sameDirectory(await directoryIdentity(lease.root), lease.rootIdentity) || !sameDirectory(await directoryIdentity(lease.lock), lease.lockIdentity))
1893
+ throw new Error("Copi session root or lock directory changed; refusing mutation or cleanup.");
1894
+ const actual = await readOwner(lease.lock);
1895
+ if (actual.pid !== lease.owner.pid || actual.host !== lease.owner.host || actual.token !== lease.owner.token || actual.blocked !== lease.owner.blocked ||
1896
+ JSON.stringify(actual.migrationRootEntries ?? null) !== JSON.stringify(lease.owner.migrationRootEntries ?? null))
1897
+ throw new Error("Copi session lock owner changed; refusing mutation or cleanup.");
1898
+ if (lease.recoveryClaim) {
1899
+ if (!sameDirectory(await directoryIdentity(lease.recoveryClaim.path), lease.recoveryClaim.identity) || alive(actual))
1900
+ throw new Error("Copi recovery lease changed or its previous owner is active; refusing recovery.");
1901
+ }
1902
+ else if (actual.pid !== process.pid || actual.host !== os.hostname())
1903
+ throw new Error("Copi session does not belong to this process and host.");
1904
+ }
1905
+ function rememberUnjournaledArtifact(session, name, identity, kind, commitment) {
1906
+ const lease = sessionLeases.get(session);
1907
+ if (!lease)
1908
+ throw new Error("Invalid or expired copi storage session; refusing to track lock artifacts.");
1909
+ lease.unjournaledArtifacts.set(name, { identity, kind, commitment });
1910
+ }
1911
+ async function rememberUnjournaledFile(session, name, target, bytes) {
1912
+ const stat = await statMaybe(target);
1913
+ if (!stat || !stat.isFile() || stat.isSymbolicLink())
1914
+ throw new Error("Unsafe unjournaled transaction artifact; refusing cleanup.");
1915
+ rememberUnjournaledArtifact(session, name, { dev: stat.dev, ino: stat.ino }, "file", hash(bytes));
1916
+ }
1917
+ function forgetUnjournaledArtifact(session, name) {
1918
+ sessionLeases.get(session)?.unjournaledArtifacts.delete(name);
1919
+ }
1920
+ async function inFlightCheckpoint(callback, control, point, root) {
1921
+ if (control.signal.aborted) {
1922
+ if (control.recovering)
1923
+ return;
1924
+ throw new Error("Copi storage session expired during its in-flight commit.");
1925
+ }
1926
+ let abort;
1927
+ const stopped = new Promise((_resolve, reject) => {
1928
+ abort = () => reject(new Error("Copi storage session expired during its in-flight checkpoint."));
1929
+ control.signal.addEventListener("abort", abort, { once: true });
1930
+ });
1931
+ try {
1932
+ await Promise.race([Promise.resolve().then(() => callback?.(point, root)), stopped]);
1933
+ }
1934
+ catch (error) {
1935
+ if (!control.recovering || !control.signal.aborted)
1936
+ throw error;
1937
+ }
1938
+ finally {
1939
+ control.signal.removeEventListener("abort", abort);
1940
+ }
1941
+ }
1942
+ /** @internal Generalized fixed-byte persistence adapter. Inspection/approval are enforced here too; instance-bound faults cannot disable checks. */
1943
+ export class StorageTransactions {
1944
+ options;
1945
+ constructor(options = {}) {
1946
+ this.options = options;
1947
+ }
1948
+ async run(libraryDir, create, operation, runOptions) {
1949
+ // Product callers declare this before root/lock creation. #commitNewItems repeats the check to protect runtime callers that omit or misstate the option.
1950
+ if (runOptions?.requiresNativePlacement === true)
1951
+ await preflightNativeSourcePlacement(libraryDir);
1952
+ const root = await canonicalRoot(libraryDir, create);
1953
+ if (!root)
1954
+ return operation(undefined);
1955
+ const rootLease = await acquireRootLease(root, this.options.waitMilliseconds ?? 5_000);
1956
+ try {
1957
+ await resumeCompletedMigrationResolution(root, () => rootLease.assertCurrent());
1958
+ if (create) {
1959
+ await chmod(root, 0o700);
1960
+ await rootLease.updateExpectedMode(0o700);
1961
+ }
1962
+ const lock = path.join(root, lockName);
1963
+ const owner = { version: 1, pid: process.pid, host: os.hostname(), token: randomUUID() };
1964
+ const deadline = Date.now() + (this.options.waitMilliseconds ?? 5_000);
1965
+ while (true) {
1966
+ // Recovery claims prevent two readers from replaying a dead writer's journal together.
1967
+ if (await statMaybe(path.join(root, `${lockName}-recovery`))) {
1968
+ if (Date.now() >= deadline)
1969
+ throw new Error("Copi recovery lock has an unknown owner; inspect it before continuing.");
1970
+ await delay(10);
1971
+ continue;
1972
+ }
1973
+ try {
1974
+ await mkdir(lock, { mode: 0o700 });
1975
+ await writePrivate(path.join(lock, "owner.json"), JSON.stringify(owner));
1976
+ await syncDirectory(lock);
1977
+ break;
1978
+ }
1979
+ catch (error) {
1980
+ if (error.code !== "EEXIST")
1981
+ throw error;
1982
+ let previous;
1983
+ try {
1984
+ previous = await readOwner(lock);
1985
+ }
1986
+ catch (error) {
1987
+ // The owner file may still be being created by a cooperating writer.
1988
+ if (Date.now() < deadline) {
1989
+ await delay(10);
1990
+ continue;
1991
+ }
1992
+ throw error;
1993
+ }
1994
+ if (previous.blocked)
1995
+ throw new Error("Copi transaction recovery stopped after an external change; inspect its owner-only journal. No external bytes were overwritten.");
1996
+ if (!alive(previous)) {
1997
+ await this.recoverDead(root, previous);
1998
+ continue;
1999
+ }
2000
+ if (Date.now() >= deadline)
2001
+ throw new Error("Copi library is locked by an active process; retry after that operation finishes.");
2002
+ await delay(10);
2003
+ }
2004
+ }
2005
+ const session = new StorageSession(root, lock, owner.token, this.options.checkpoint);
2006
+ const lease = await issueSessionLease(session, root, lock, owner);
2007
+ let operationFailed = false;
2008
+ let operationError;
2009
+ try {
2010
+ return await operation(session);
2011
+ }
2012
+ catch (error) {
2013
+ operationFailed = true;
2014
+ operationError = error;
2015
+ throw error;
2016
+ }
2017
+ finally {
2018
+ sessionLeases.delete(session); // Reject new calls immediately; private in-flight leases are not exposed.
2019
+ lease.stop.abort(); // Interrupt checkpoints rather than waiting for caller-owned deferred values.
2020
+ await Promise.allSettled([...lease.pending]); // Finish safe rollback before cleanup/releasing the lock.
2021
+ try {
2022
+ await assertLeaseOwner(lease);
2023
+ if (await statMaybe(path.join(lock, "journal.json"))) {
2024
+ try {
2025
+ await cleanupTransactionLock(root, lock, owner.token, lease);
2026
+ }
2027
+ catch (journalError) {
2028
+ // Keep an unverified journal blocked; never delete incomplete cleanup artifacts.
2029
+ const handle = await open(path.join(lock, "owner.json"), "w", 0o600);
2030
+ try {
2031
+ await handle.writeFile(JSON.stringify({ ...lease.owner, blocked: true }));
2032
+ await handle.sync();
2033
+ }
2034
+ finally {
2035
+ await handle.close();
2036
+ }
2037
+ throw journalError;
2038
+ }
2039
+ }
2040
+ else {
2041
+ await cleanupTransactionLock(root, lock, owner.token, lease);
2042
+ }
2043
+ }
2044
+ catch (cleanupError) {
2045
+ if (!operationFailed)
2046
+ throw cleanupError;
2047
+ const primary = operationError instanceof Error ? operationError.message : "Copi operation failed.";
2048
+ const secondary = cleanupError instanceof Error ? cleanupError.message : "Safe cleanup was refused.";
2049
+ throw new AggregateError([operationError, cleanupError], `${primary}; safe cleanup was refused: ${secondary}`);
2050
+ }
2051
+ }
2052
+ }
2053
+ finally {
2054
+ await rootLease.close();
2055
+ }
2056
+ }
2057
+ async recoverDead(root, expected) {
2058
+ const claim = path.join(root, `${lockName}-recovery`);
2059
+ try {
2060
+ await mkdir(claim, { mode: 0o700 });
2061
+ }
2062
+ catch (error) {
2063
+ if (error.code === "EEXIST")
2064
+ return;
2065
+ throw error;
2066
+ }
2067
+ const rootIdentity = await directoryIdentity(root);
2068
+ const claimIdentity = await directoryIdentity(claim);
2069
+ try {
2070
+ const lock = path.join(root, lockName);
2071
+ const current = await readOwner(lock);
2072
+ if (current.token !== expected.token || alive(current))
2073
+ return;
2074
+ const session = new StorageSession(root, lock, current.token);
2075
+ const lease = await issueSessionLease(session, root, lock, current, claim);
2076
+ try {
2077
+ await session.recover(false);
2078
+ await assertLeaseOwner(lease);
2079
+ await cleanupTransactionLock(root, lock, current.token, lease);
2080
+ }
2081
+ finally {
2082
+ sessionLeases.delete(session);
2083
+ }
2084
+ }
2085
+ finally {
2086
+ if (await realpath(root) !== root || !sameDirectory(await directoryIdentity(root), rootIdentity) || !sameDirectory(await directoryIdentity(claim), claimIdentity))
2087
+ throw new Error("Copi recovery claim or root changed; refusing cleanup.");
2088
+ await rm(claim, { recursive: true });
2089
+ }
2090
+ }
2091
+ }
2092
+ export class StorageSession {
2093
+ root;
2094
+ lock;
2095
+ token;
2096
+ checkpoint;
2097
+ checkLegacyItems;
2098
+ constructor(root, lock, token, checkpoint, checkLegacyItems = true) {
2099
+ this.root = root;
2100
+ this.lock = lock;
2101
+ this.token = token;
2102
+ this.checkpoint = checkpoint;
2103
+ this.checkLegacyItems = checkLegacyItems;
2104
+ }
2105
+ async assertLease(checkItems = this.checkLegacyItems) {
2106
+ const lease = sessionLeases.get(this);
2107
+ if (!lease)
2108
+ throw new Error("Invalid or expired copi storage session; use the issued session inside its run callback.");
2109
+ if (lease.operation?.signal.aborted && !lease.operation.recovering)
2110
+ throw new Error("Copi storage session expired during its in-flight commit.");
2111
+ if (this.root !== lease.root || this.lock !== lease.lock || this.token !== lease.owner.token)
2112
+ throw new Error("Copi session identity changed; refusing mutation.");
2113
+ await assertLeaseOwner(lease);
2114
+ if (checkItems)
2115
+ await checkedDirectory(path.join(this.root, "items"));
2116
+ if (sessionLeases.get(this) !== lease || (lease.operation?.signal.aborted && !lease.operation.recovering))
2117
+ throw new Error("Copi storage session expired while checking its lease.");
2118
+ }
2119
+ async capture() {
2120
+ await this.assertLease(false);
2121
+ const catalogPath = path.join(this.root, "catalog.json");
2122
+ const beforeCatalog = await statMaybe(catalogPath);
2123
+ const catalog = await checkedFile(catalogPath);
2124
+ const afterCatalog = await statMaybe(catalogPath);
2125
+ const beforeIdentity = beforeCatalog ? `${beforeCatalog.dev}:${beforeCatalog.ino}` : null;
2126
+ const catalogIdentity = afterCatalog ? `${afterCatalog.dev}:${afterCatalog.ino}` : null;
2127
+ if (beforeIdentity !== catalogIdentity)
2128
+ throw new Error("Copi catalog changed while capturing its revision.");
2129
+ const format = catalogFormat(catalog);
2130
+ const revision = Object.freeze({
2131
+ root: this.root,
2132
+ catalog: catalog?.toString("base64") ?? null,
2133
+ catalogIdentity,
2134
+ format,
2135
+ tree: await tree(path.join(this.root, format === "v2" ? "sources" : "items"), undefined, format === "v2") ?? "absent",
2136
+ });
2137
+ await this.assertLease(format === "v1");
2138
+ return revision;
2139
+ }
2140
+ async assertRevision(expected) {
2141
+ if (JSON.stringify(await this.capture()) !== JSON.stringify(expected))
2142
+ throw new Error("Copi library changed after preparation (catalog or saved bytes); reload and review before retrying. A manual checksum mismatch must not be repaired automatically.");
2143
+ }
2144
+ /** @internal Captures a private, lease-bound before state for an explicitly confirmed sources review. */
2145
+ async prepareSourcesReview(expected, catalog, replacements) {
2146
+ await this.assertLease(false);
2147
+ if (expected.root !== this.root || expected.format !== "v2")
2148
+ throw new Error("Sources before-review requires this issued session's v2 revision.");
2149
+ await this.assertRevision(expected);
2150
+ const review = prepareStorageReview(Buffer.from(catalog), replacements);
2151
+ const state = reviews.get(review);
2152
+ const previous = expected.catalog === null ? { version: 2, items: [], groups: [] } : parseStoredCatalog(JSON.parse(Buffer.from(expected.catalog, "base64").toString("utf8")));
2153
+ const next = parseStoredCatalog(JSON.parse(state.catalog.toString("utf8")));
2154
+ if (previous.version !== 2 || next.version !== 2)
2155
+ throw new Error("Sources before-review requires v2 catalogs.");
2156
+ assertModernCatalog(previous);
2157
+ assertModernCatalog(next);
2158
+ const beforeItems = [];
2159
+ for (const replacement of state.replacements) {
2160
+ const oldItem = previous.items.find((item) => item.id === replacement.itemId);
2161
+ if (oldItem)
2162
+ beforeItems.push(await captureSourcesBefore(this.root, oldItem));
2163
+ }
2164
+ await this.assertRevision(expected);
2165
+ await this.assertLease(false);
2166
+ state.before = { root: this.root, expected: structuredClone(expected), items: beforeItems, used: false };
2167
+ return review;
2168
+ }
2169
+ async commit(expected, catalog, replacements = [], authorization) {
2170
+ const lease = sessionLeases.get(this);
2171
+ if (!lease || lease.stop.signal.aborted)
2172
+ throw new Error("Invalid or expired copi storage session; refusing a new commit.");
2173
+ if (this.root !== lease.root || this.lock !== lease.lock || this.token !== lease.owner.token)
2174
+ throw new Error("Copi session identity changed; refusing mutation.");
2175
+ const control = { signal: lease.stop.signal, recovering: false };
2176
+ const worker = new StorageSession(lease.root, lease.lock, lease.owner.token, (point, root) => inFlightCheckpoint(this.checkpoint, control, point, root), expected.format === "v1");
2177
+ sessionLeases.set(worker, { ...lease, pending: new Set(), operation: control });
2178
+ const operation = worker.#commitInFlight(expected, catalog, replacements, authorization);
2179
+ lease.pending.add(operation);
2180
+ try {
2181
+ await operation;
2182
+ }
2183
+ finally {
2184
+ lease.pending.delete(operation);
2185
+ sessionLeases.delete(worker);
2186
+ }
2187
+ }
2188
+ /** @internal Dedicated v1-to-v2 adopt-only path. Ordinary commit() deliberately remains v2-only. */
2189
+ async commitLegacyMigration(review, approvalValue) {
2190
+ const lease = sessionLeases.get(this);
2191
+ if (!lease || lease.stop.signal.aborted || this.root !== lease.root || this.lock !== lease.lock || this.token !== lease.owner.token) {
2192
+ throw new Error("Invalid or expired copi storage session; refusing legacy migration.");
2193
+ }
2194
+ const reviewState = typeof review === "object" && review !== null ? legacyMigrationReviews.get(review) : undefined;
2195
+ if (!reviewState || reviewState.used || review.reviewId !== reviewState.request.reviewId || reviewState.request.root !== this.root) {
2196
+ throw new Error("Legacy migration requires an issued, unused review bound to this root and storage lease.");
2197
+ }
2198
+ const approval = snapshotOwnDataRecord(approvalValue, ["reviewId", "confirmed", "approvedSensitivePaths"]);
2199
+ const approvedPaths = approval ? snapshotDenseOwnDataArray(approval.approvedSensitivePaths) : undefined;
2200
+ if (!approval || approval.reviewId !== reviewState.request.reviewId || approval.confirmed !== true || !approvedPaths ||
2201
+ approvedPaths.some((entry) => typeof entry !== "string") ||
2202
+ new Set(approvedPaths).size !== approvedPaths.length || approvedPaths.length !== reviewState.request.sensitivePaths.length ||
2203
+ reviewState.request.sensitivePaths.some((entry) => !approvedPaths.includes(entry))) {
2204
+ throw new Error("Legacy migration approval must confirm this opaque review and its exact sensitive-path set.");
2205
+ }
2206
+ reviewState.used = true;
2207
+ const request = reviewState.request;
2208
+ const control = { signal: lease.stop.signal, recovering: false };
2209
+ const worker = new StorageSession(lease.root, lease.lock, lease.owner.token, (point, root) => inFlightCheckpoint(this.checkpoint, control, point, root), false);
2210
+ sessionLeases.set(worker, { ...lease, pending: new Set(), operation: control });
2211
+ const operation = worker.#commitLegacyMigrationInFlight(request);
2212
+ lease.pending.add(operation);
2213
+ try {
2214
+ await operation;
2215
+ }
2216
+ finally {
2217
+ lease.pending.delete(operation);
2218
+ sessionLeases.delete(worker);
2219
+ }
2220
+ }
2221
+ async #commitLegacyMigrationInFlight(request) {
2222
+ const lease = sessionLeases.get(this);
2223
+ if (!lease || request.root !== this.root)
2224
+ throw new Error("Invalid or expired copi storage session; refusing legacy migration.");
2225
+ const oldRaw = JSON.parse(request.oldCatalogBytes.toString("utf8"));
2226
+ const nextRaw = JSON.parse(request.catalogBytes.toString("utf8"));
2227
+ const oldValue = parseStoredCatalog(oldRaw);
2228
+ const nextValue = parseStoredCatalog(nextRaw);
2229
+ assertCanonicalStoredCatalog(oldRaw, oldValue);
2230
+ assertCanonicalStoredCatalog(nextRaw, nextValue);
2231
+ if (oldValue.version !== 1 || nextValue.version !== 2 || !request.reviewId ||
2232
+ !request.backupDir.startsWith(".migration-backups/") ||
2233
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(request.backupDir.slice(".migration-backups/".length))) {
2234
+ throw new Error("Legacy migration requires an exact approved adoption review and a private UUID backup target.");
2235
+ }
2236
+ const itemsTree = request.itemsTree;
2237
+ const sourcesTree = request.sourcesTree;
2238
+ if (!/^(?:absent|[0-9a-f]{64})$/.test(itemsTree) || !/^(?:absent|[0-9a-f]{64})$/.test(sourcesTree)) {
2239
+ throw new Error("Legacy migration requires fixed items and sources tree commitments.");
2240
+ }
2241
+ const startInventory = parseMigrationStartInventory(request.startInventory);
2242
+ const catalogInput = request.inputs.find((input) => input.path === "catalog.json");
2243
+ const inventoryCatalog = startInventory.nodes.find((node) => node.path === "catalog.json");
2244
+ if (!catalogInput || !catalogInput.bytes.equals(request.oldCatalogBytes) ||
2245
+ request.oldCatalogIdentity !== `${catalogInput.dev}:${catalogInput.ino}` || !inventoryCatalog ||
2246
+ inventoryCatalog.kind !== "file" || inventoryCatalog.bytesHash !== hash(request.oldCatalogBytes) ||
2247
+ inventoryCatalog.mode !== catalogInput.mode || inventoryCatalog.dev !== catalogInput.dev || inventoryCatalog.ino !== catalogInput.ino) {
2248
+ throw new Error("Legacy migration catalog bytes and original inode are not bound to the fixed backup inputs.");
2249
+ }
2250
+ const journal = {
2251
+ version: 5,
2252
+ mode: "legacy-migration",
2253
+ root: this.root,
2254
+ token: this.token,
2255
+ oldCatalog: hash(request.oldCatalogBytes),
2256
+ newCatalog: hash(request.catalogBytes),
2257
+ stableItemsTree: itemsTree,
2258
+ stableSourcesTree: sourcesTree,
2259
+ stableSourcesTreeNext: sourcesTree,
2260
+ backupDir: request.backupDir,
2261
+ backupManifest: hash(request.backupManifestBytes),
2262
+ operations: [],
2263
+ ownedDirectories: [],
2264
+ startInventory,
2265
+ };
2266
+ validateLegacyMigrationJournalTargets(journal, oldValue, nextValue);
2267
+ const inputPaths = new Set(request.inputs.map((input) => input.path));
2268
+ for (const item of nextValue.items) {
2269
+ if (!item.content.path.startsWith("sources/"))
2270
+ throw new Error("Adopt-only migration requires every item payload to already exist in sources/.");
2271
+ const target = path.join(this.root, ...item.content.path.split("/"));
2272
+ const targetStat = await lstat(target, { bigint: true });
2273
+ if (targetStat.isSymbolicLink() || (item.content.kind === "file" ? !targetStat.isFile() : !targetStat.isDirectory())) {
2274
+ throw new Error("Adopt-only migration target is missing or has the wrong filesystem kind.");
2275
+ }
2276
+ const required = item.content.kind === "file"
2277
+ ? [item.content.path]
2278
+ : item.files.map((file) => `${item.content.path}/${file.path}`);
2279
+ if (required.some((relative) => !inputPaths.has(relative)))
2280
+ throw new Error("Adopt-only migration target is not covered by its fixed existing-sources snapshot.");
2281
+ }
2282
+ const backupEntries = request.inputs.map(({ path: originalPath, sha256, mode }) => ({ originalPath, sha256, mode }));
2283
+ if (!buildMigrationBackupManifest(backupEntries).equals(request.backupManifestBytes)) {
2284
+ throw new Error("Migration backup manifest is not the fixed complete input snapshot.");
2285
+ }
2286
+ if (new Set(request.rootEntries).size !== request.rootEntries.length ||
2287
+ JSON.stringify([...request.rootEntries].sort()) !== JSON.stringify(request.rootEntries) ||
2288
+ !request.rootEntries.includes("catalog.json") ||
2289
+ request.rootEntries.some((entry) => !isManagedMigrationRootName(entry) ||
2290
+ entry === lockName || entry === `${lockName}-recovery` || entry === ".migration-backups")) {
2291
+ throw new Error("Adopt-only migration root entries are not a fixed canonical snapshot.");
2292
+ }
2293
+ await this.assertLease(false);
2294
+ await assertLegacyMigrationSnapshot(this.root, request, false);
2295
+ await assertLegacyMigrationInputCoverage(this.root, request);
2296
+ const ownerPath = path.join(this.lock, "owner.json");
2297
+ const ownerTemp = path.join(this.lock, "owner.migration.tmp");
2298
+ const currentOwner = await readOwner(this.lock);
2299
+ if (currentOwner.migrationRootEntries || currentOwner.token !== this.token)
2300
+ throw new Error("Migration owner cannot be rebound to a different or existing root snapshot.");
2301
+ await writePrivate(ownerTemp, JSON.stringify({ ...currentOwner, migrationRootEntries: request.rootEntries }));
2302
+ await rememberUnjournaledFile(this, "owner.migration.tmp", ownerTemp, JSON.stringify({ ...currentOwner, migrationRootEntries: request.rootEntries }));
2303
+ await rename(ownerTemp, ownerPath);
2304
+ forgetUnjournaledArtifact(this, "owner.migration.tmp");
2305
+ lease.owner.migrationRootEntries = [...request.rootEntries];
2306
+ await this.assertLease(false);
2307
+ let journalInstalled = false;
2308
+ const backup = await writeLegacyMigrationBackup(this.root, request);
2309
+ const backupIdentity = await directoryIdentity(backup.root);
2310
+ const assertOwnedBackup = async () => {
2311
+ if (!sameDirectory(await directoryIdentity(backup.root), backupIdentity) ||
2312
+ !sameDirectory(await directoryIdentity(path.join(this.root, ".migration-backups")), backup.parent)) {
2313
+ throw new Error("Migration backup directory identity changed; preserving backup and stopping.");
2314
+ }
2315
+ };
2316
+ try {
2317
+ await this.checkpoint?.("stage-write", this.root);
2318
+ await this.assertLease(false);
2319
+ await assertLegacyMigrationSnapshot(this.root, request, true);
2320
+ await assertLegacyMigrationInputCoverage(this.root, request);
2321
+ await assertLegacyMigrationBackup(this.root, request);
2322
+ await assertOwnedBackup();
2323
+ await this.checkpoint?.("stage-sync", this.root);
2324
+ await this.assertLease(false);
2325
+ await assertLegacyMigrationSnapshot(this.root, request, true);
2326
+ await assertLegacyMigrationBackup(this.root, request);
2327
+ const catalogStage = path.join(this.lock, "next-catalog");
2328
+ await writePrivate(catalogStage, request.catalogBytes);
2329
+ await rememberUnjournaledFile(this, "next-catalog", catalogStage, request.catalogBytes);
2330
+ const originalCatalogPath = path.join(this.root, "catalog.json");
2331
+ const oldCatalogStage = path.join(this.lock, "old-catalog");
2332
+ await link(originalCatalogPath, oldCatalogStage);
2333
+ await rememberUnjournaledFile(this, "old-catalog", oldCatalogStage, request.oldCatalogBytes);
2334
+ const oldCurrent = await checkedFile(originalCatalogPath);
2335
+ const oldStat = await statMaybe(originalCatalogPath);
2336
+ const oldStageStat = await statMaybe(oldCatalogStage);
2337
+ if (!oldCurrent?.equals(request.oldCatalogBytes) || !oldStat || !oldStageStat ||
2338
+ request.oldCatalogIdentity !== `${oldStat.dev}:${oldStat.ino}` || !sameDirectory({ dev: oldStat.dev, ino: oldStat.ino }, { dev: oldStageStat.dev, ino: oldStageStat.ino })) {
2339
+ throw new Error("Original v1 catalog changed before migration journaling; preserving its inode.");
2340
+ }
2341
+ await syncDirectory(this.lock);
2342
+ await this.assertLease(false);
2343
+ await assertLegacyMigrationSnapshot(this.root, request, true);
2344
+ await assertLegacyMigrationBackup(this.root, request);
2345
+ const initialNames = new Set(["owner.json", "next-catalog", "old-catalog"]);
2346
+ const lockNames = await readdir(this.lock);
2347
+ if (lockNames.length !== initialNames.size || lockNames.some((name) => !initialNames.has(name))) {
2348
+ throw new Error("Unknown migration transaction stage before v4 journaling; preserving every artifact.");
2349
+ }
2350
+ const journalBytes = Buffer.from(canonicalStorageJournalJson(journal), "utf8");
2351
+ const journalTemp = path.join(this.lock, "journal.tmp");
2352
+ await writePrivate(journalTemp, journalBytes);
2353
+ await rememberUnjournaledFile(this, "journal.tmp", journalTemp, journalBytes);
2354
+ const journalPath = path.join(this.lock, "journal.json");
2355
+ await link(journalTemp, journalPath);
2356
+ journalInstalled = true;
2357
+ await unlink(journalTemp);
2358
+ forgetUnjournaledArtifact(this, "journal.tmp");
2359
+ await writePrivate(path.join(this.lock, "journal.digest"), hash(journalBytes));
2360
+ await rememberUnjournaledFile(this, "journal.digest", path.join(this.lock, "journal.digest"), hash(journalBytes));
2361
+ await syncDirectory(this.lock);
2362
+ await this.checkpoint?.("journal", this.root);
2363
+ await this.assertLease(false);
2364
+ const rawJournal = await checkedFile(journalPath);
2365
+ const rawDigest = await checkedFile(path.join(this.lock, "journal.digest"));
2366
+ if (!rawJournal?.equals(journalBytes) || !rawDigest || rawDigest.toString("utf8") !== hash(rawJournal)) {
2367
+ throw new Error("V4 migration journal changed before catalog switch; preserving all bytes.");
2368
+ }
2369
+ await assertLegacyMigrationSnapshot(this.root, request, true);
2370
+ await assertLegacyMigrationBackup(this.root, request);
2371
+ await assertOwnedBackup();
2372
+ await this.checkpoint?.("before-catalog", this.root);
2373
+ await this.assertLease(false);
2374
+ const heldPath = path.join(this.lock, "held-catalog");
2375
+ if (await statMaybe(heldPath))
2376
+ throw new Error("Unexpected held catalog before migration switch.");
2377
+ const currentBeforeHold = await checkedFile(originalCatalogPath);
2378
+ const currentBeforeHoldStat = await statMaybe(originalCatalogPath);
2379
+ if (!currentBeforeHold?.equals(request.oldCatalogBytes) || !currentBeforeHoldStat ||
2380
+ request.oldCatalogIdentity !== `${currentBeforeHoldStat.dev}:${currentBeforeHoldStat.ino}`) {
2381
+ throw new Error("Original catalog changed before migration hold; refusing replacement.");
2382
+ }
2383
+ await rename(originalCatalogPath, heldPath);
2384
+ await syncDirectory(this.root);
2385
+ await this.checkpoint?.("catalog-held", this.root);
2386
+ await this.assertLease(false);
2387
+ await assertLegacyMigrationSnapshot(this.root, request, true, true);
2388
+ await assertLegacyMigrationBackup(this.root, request);
2389
+ await assertOwnedBackup();
2390
+ if (await statMaybe(originalCatalogPath))
2391
+ throw new Error("A foreign catalog appeared during migration switch; preserving it.");
2392
+ await link(catalogStage, originalCatalogPath);
2393
+ await syncDirectory(this.root);
2394
+ await this.checkpoint?.("catalog-renamed", this.root);
2395
+ await this.assertLease(false);
2396
+ await assertLegacyMigrationSnapshot(this.root, request, true, false, true);
2397
+ await assertLegacyMigrationBackup(this.root, request);
2398
+ await assertOwnedBackup();
2399
+ const installed = await checkedFile(originalCatalogPath);
2400
+ const installedStat = await statMaybe(originalCatalogPath);
2401
+ const stageStat = await statMaybe(catalogStage);
2402
+ if (!installed?.equals(request.catalogBytes) || !installedStat || !stageStat ||
2403
+ !sameDirectory({ dev: installedStat.dev, ino: installedStat.ino }, { dev: stageStat.dev, ino: stageStat.ino })) {
2404
+ throw new Error("Installed v2 catalog is not the exclusive fixed stage inode.");
2405
+ }
2406
+ await this.checkpoint?.("directory-sync", this.root);
2407
+ await this.assertLease(false);
2408
+ await syncDirectory(this.root);
2409
+ await this.checkpoint?.("committed", this.root);
2410
+ await this.assertLease(false);
2411
+ await this.recover(false);
2412
+ }
2413
+ catch (error) {
2414
+ if (!journalInstalled)
2415
+ throw error;
2416
+ const operation = sessionLeases.get(this)?.operation;
2417
+ if (operation)
2418
+ operation.recovering = true;
2419
+ try {
2420
+ await this.recover(true);
2421
+ }
2422
+ catch (recoveryError) {
2423
+ throw new Error("Adopt-only v4 migration could not safely recover; retained journal, backup, and catalog artifacts.", {
2424
+ cause: new AggregateError([error, recoveryError]),
2425
+ });
2426
+ }
2427
+ throw error;
2428
+ }
2429
+ }
2430
+ async #commitInFlight(expected, catalog, replacements, authorization) {
2431
+ await this.assertLease();
2432
+ const prepared = approvedStorage(catalog, replacements, authorization);
2433
+ catalog = prepared.catalog;
2434
+ replacements = prepared.replacements;
2435
+ await this.assertLease();
2436
+ await this.assertRevision(expected);
2437
+ const previousRaw = expected.catalog === null ? undefined : JSON.parse(Buffer.from(expected.catalog, "base64").toString("utf8"));
2438
+ const previousStored = previousRaw === undefined ? { version: 2, items: [], groups: [] } : parseStoredCatalog(previousRaw);
2439
+ const nextRaw = JSON.parse(catalog.toString("utf8"));
2440
+ const nextStored = parseStoredCatalog(nextRaw);
2441
+ if (expected.format !== "v2" || previousStored.version !== 2 || nextStored.version !== 2)
2442
+ throw new Error("Ordinary storage commits require catalog v2; explicitly migrate legacy v1 storage first.");
2443
+ assertModernCatalog(previousStored);
2444
+ assertModernCatalog(nextStored);
2445
+ if (replacements.length === 0) {
2446
+ if (previousRaw !== undefined)
2447
+ assertCanonicalStoredCatalog(previousRaw, previousStored);
2448
+ assertCanonicalStoredCatalog(nextRaw, nextStored);
2449
+ await this.#commitCatalogOnly(expected, previousStored, nextStored, catalog);
2450
+ return;
2451
+ }
2452
+ const removedPaths = omittedSourcePaths(prepared, previousStored, nextStored);
2453
+ if (removedPaths.length > 0 && (!prepared.before || !authorization || authorization.approval.confirmed !== true)) {
2454
+ throw new Error("Ordinary source deletion requires a session-bound before review and confirmed approval.");
2455
+ }
2456
+ if (prepared.before) {
2457
+ if (prepared.before.used)
2458
+ throw new Error("Sources before-review token has already been used.");
2459
+ prepared.before.used = true;
2460
+ await assertBeforeReviewCurrent(this.root, expected, prepared.before);
2461
+ }
2462
+ await this.#commitSources(expected, previousStored, nextStored, catalog, replacements, prepared.before);
2463
+ }
2464
+ async #commitCatalogOnly(expected, previous, next, catalog) {
2465
+ validateCatalogOnlyTransition(previous, next);
2466
+ const stableSourcesTree = await catalogOnlySourcesTree(this.root);
2467
+ if (stableSourcesTree !== expected.tree)
2468
+ throw new Error("Sources tree changed after capture; refusing catalog-only commit.");
2469
+ await this.assertRevision(expected);
2470
+ let journalInstalled = false;
2471
+ let journalBytes;
2472
+ const catalogStage = path.join(this.lock, "next-catalog");
2473
+ const oldCatalogStage = path.join(this.lock, "old-catalog");
2474
+ const journalPath = path.join(this.lock, "journal.json");
2475
+ try {
2476
+ const catalogHandle = await open(catalogStage, "wx", 0o600);
2477
+ try {
2478
+ const stageStat = await catalogHandle.stat();
2479
+ rememberUnjournaledArtifact(this, "next-catalog", { dev: stageStat.dev, ino: stageStat.ino }, "file", hash(catalog));
2480
+ await catalogHandle.writeFile(catalog);
2481
+ await this.checkpoint?.("stage-write", this.root);
2482
+ await this.assertLease(false);
2483
+ if (await catalogOnlySourcesTree(this.root) !== stableSourcesTree)
2484
+ throw new Error("Sources tree changed while staging catalog-only metadata.");
2485
+ await this.checkpoint?.("stage-sync", this.root);
2486
+ await this.assertLease(false);
2487
+ await catalogHandle.sync();
2488
+ }
2489
+ finally {
2490
+ await catalogHandle.close();
2491
+ }
2492
+ const stagedCatalog = await checkedFile(catalogStage);
2493
+ if (!stagedCatalog?.equals(catalog))
2494
+ throw new Error("Staged catalog changed from the fixed catalog-only bytes.");
2495
+ await syncDirectory(this.lock);
2496
+ const oldBytes = expected.catalog === null ? undefined : Buffer.from(expected.catalog, "base64");
2497
+ if (oldBytes) {
2498
+ const catalogPath = path.join(this.root, "catalog.json");
2499
+ const current = await checkedFile(catalogPath);
2500
+ const currentStat = await statMaybe(catalogPath);
2501
+ if (!current?.equals(oldBytes) || !currentStat || expected.catalogIdentity !== `${currentStat.dev}:${currentStat.ino}`) {
2502
+ throw new Error("Original catalog changed before catalog-only staging; preserving its inode.");
2503
+ }
2504
+ await link(catalogPath, oldCatalogStage);
2505
+ await rememberUnjournaledFile(this, "old-catalog", oldCatalogStage, oldBytes);
2506
+ const oldStageStat = await statMaybe(oldCatalogStage);
2507
+ if (!oldStageStat || !sameDirectory({ dev: currentStat.dev, ino: currentStat.ino }, { dev: oldStageStat.dev, ino: oldStageStat.ino })) {
2508
+ throw new Error("Catalog-only old-catalog stage did not retain the original inode.");
2509
+ }
2510
+ }
2511
+ await this.assertRevision(expected);
2512
+ if (await catalogOnlySourcesTree(this.root) !== stableSourcesTree)
2513
+ throw new Error("Sources tree changed before catalog-only journaling.");
2514
+ const journal = {
2515
+ version: 3,
2516
+ mode: "catalog-only",
2517
+ root: this.root,
2518
+ token: this.token,
2519
+ oldCatalog: oldBytes ? hash(oldBytes) : null,
2520
+ newCatalog: hash(catalog),
2521
+ stableSourcesTree,
2522
+ operations: [],
2523
+ };
2524
+ journalBytes = Buffer.from(JSON.stringify(journal));
2525
+ const journalTemp = path.join(this.lock, "journal.tmp");
2526
+ await writePrivate(journalTemp, journalBytes);
2527
+ await rememberUnjournaledFile(this, "journal.tmp", journalTemp, journalBytes);
2528
+ await link(journalTemp, journalPath);
2529
+ journalInstalled = true;
2530
+ await unlink(journalTemp);
2531
+ forgetUnjournaledArtifact(this, "journal.tmp");
2532
+ await writePrivate(path.join(this.lock, "journal.digest"), hash(journalBytes));
2533
+ await syncDirectory(this.lock);
2534
+ await this.checkpoint?.("journal", this.root);
2535
+ await this.assertLease(false);
2536
+ const entries = await assertKnownCatalogOnlyLockEntries(this.lock);
2537
+ const expectedEntries = new Set(["owner.json", "next-catalog", "journal.json", "journal.digest", ...(oldBytes ? ["old-catalog"] : [])]);
2538
+ if (entries.size !== expectedEntries.size || [...entries].some((entry) => !expectedEntries.has(entry))) {
2539
+ throw new Error("Unexpected catalog-only lock stage before catalog installation; preserving it.");
2540
+ }
2541
+ const rawJournal = await checkedFile(journalPath);
2542
+ const journalDigest = await checkedFile(path.join(this.lock, "journal.digest"));
2543
+ if (!journalBytes || !rawJournal?.equals(journalBytes) || !journalDigest || journalDigest.toString("utf8") !== hash(rawJournal)) {
2544
+ throw new Error("Catalog-only journal or digest changed before catalog installation.");
2545
+ }
2546
+ const parsed = parseStorageJournal(JSON.parse(rawJournal.toString("utf8")), { root: this.root, token: this.token });
2547
+ if (parsed.version !== 3 || parsed.mode !== "catalog-only" || JSON.stringify(parsed) !== JSON.stringify(journal)) {
2548
+ throw new Error("Catalog-only journal changed its fixed v3 dispatch or commitments.");
2549
+ }
2550
+ const stagedAfterJournal = await checkedFile(catalogStage);
2551
+ if (!stagedAfterJournal?.equals(catalog))
2552
+ throw new Error("Catalog-only next-catalog stage changed after journaling.");
2553
+ await this.assertRevision(expected);
2554
+ if (await catalogOnlySourcesTree(this.root) !== journal.stableSourcesTree)
2555
+ throw new Error("Sources tree changed after catalog-only journaling.");
2556
+ const catalogPath = path.join(this.root, "catalog.json");
2557
+ const heldPath = path.join(this.lock, "held-catalog");
2558
+ const current = await checkedFile(catalogPath);
2559
+ const currentStat = await statMaybe(catalogPath);
2560
+ if ((current ? hash(current) : null) !== journal.oldCatalog ||
2561
+ (currentStat ? `${currentStat.dev}:${currentStat.ino}` : null) !== expected.catalogIdentity) {
2562
+ throw new Error("Catalog changed before catalog-only hold; refusing overwrite.");
2563
+ }
2564
+ if (await statMaybe(heldPath))
2565
+ throw new Error("Unexpected held catalog in catalog-only transaction.");
2566
+ if (current)
2567
+ await rename(catalogPath, heldPath);
2568
+ await syncDirectory(this.root);
2569
+ await this.checkpoint?.("catalog-held", this.root);
2570
+ await this.assertLease(false);
2571
+ if (await catalogOnlySourcesTree(this.root) !== journal.stableSourcesTree)
2572
+ throw new Error("Sources tree changed while holding catalog-only metadata.");
2573
+ const held = await checkedFile(heldPath);
2574
+ const heldStat = await statMaybe(heldPath);
2575
+ const oldStageStat = await statMaybe(oldCatalogStage);
2576
+ if ((held ? hash(held) : null) !== journal.oldCatalog ||
2577
+ (heldStat ? !oldStageStat || !sameDirectory({ dev: heldStat.dev, ino: heldStat.ino }, { dev: oldStageStat.dev, ino: oldStageStat.ino }) : journal.oldCatalog !== null)) {
2578
+ throw new Error("Held catalog-only inode changed; preserving it.");
2579
+ }
2580
+ if (await statMaybe(catalogPath))
2581
+ throw new Error("A catalog appeared during catalog-only hold; refusing replacement.");
2582
+ if (!(await checkedFile(catalogStage))?.equals(catalog))
2583
+ throw new Error("Catalog-only stage changed before exclusive installation.");
2584
+ await link(catalogStage, catalogPath);
2585
+ await syncDirectory(this.root);
2586
+ await this.checkpoint?.("catalog-renamed", this.root);
2587
+ await this.assertLease(false);
2588
+ const installed = await checkedFile(catalogPath);
2589
+ const installedStat = await statMaybe(catalogPath);
2590
+ const stageStat = await statMaybe(catalogStage);
2591
+ if (!installed?.equals(catalog) || !installedStat || !stageStat ||
2592
+ !sameDirectory({ dev: installedStat.dev, ino: installedStat.ino }, { dev: stageStat.dev, ino: stageStat.ino }) ||
2593
+ await catalogOnlySourcesTree(this.root) !== journal.stableSourcesTree) {
2594
+ throw new Error("Catalog-only installed inode or stable sources tree changed.");
2595
+ }
2596
+ await this.checkpoint?.("directory-sync", this.root);
2597
+ await this.assertLease(false);
2598
+ await syncDirectory(this.root);
2599
+ await this.checkpoint?.("committed", this.root);
2600
+ await this.assertLease(false);
2601
+ if (await catalogOnlySourcesTree(this.root) !== journal.stableSourcesTree ||
2602
+ hash((await checkedFile(catalogPath)) ?? Buffer.alloc(0)) !== journal.newCatalog) {
2603
+ throw new Error("Catalog-only library changed during commit finalization.");
2604
+ }
2605
+ const heldAtFinish = await checkedFile(heldPath);
2606
+ const heldAtFinishStat = await statMaybe(heldPath);
2607
+ const oldStageAtFinish = await statMaybe(oldCatalogStage);
2608
+ if ((heldAtFinish ? hash(heldAtFinish) : null) !== journal.oldCatalog ||
2609
+ (heldAtFinishStat ? !oldStageAtFinish || !sameDirectory({ dev: heldAtFinishStat.dev, ino: heldAtFinishStat.ino }, { dev: oldStageAtFinish.dev, ino: oldStageAtFinish.ino }) : journal.oldCatalog !== null)) {
2610
+ throw new Error("Held original catalog changed during catalog-only finalization.");
2611
+ }
2612
+ await this.markFinalized(await createCatalogOnlyCleanup(this.lock, journal, journalBytes));
2613
+ await this.checkpoint?.("finalized", this.root);
2614
+ await this.recover(false);
2615
+ }
2616
+ catch (error) {
2617
+ if (!journalInstalled)
2618
+ throw error;
2619
+ const operation = sessionLeases.get(this)?.operation;
2620
+ if (operation)
2621
+ operation.recovering = true;
2622
+ try {
2623
+ await this.recover(true);
2624
+ }
2625
+ catch (recoveryError) {
2626
+ throw new Error("Copi catalog-only transaction could not safely recover; retained its journal and catalog artifacts.", {
2627
+ cause: new AggregateError([error, recoveryError]),
2628
+ });
2629
+ }
2630
+ throw error;
2631
+ }
2632
+ }
2633
+ async #commitNewItems(expected, previous, next, catalog, replacements, operations) {
2634
+ // Defense in depth for direct adapter calls: an omitted/false JS option may already have created a temporary lock/root lease, but this check rejects before payload, stage, or journal writes; run() cleans the lock and releases the lease.
2635
+ await preflightNativeSourcePlacement(this.root);
2636
+ if (operations.length === 0 || operations.some((operation) => operation.oldTree !== null) || next.items.length <= previous.items.length) {
2637
+ throw new Error("New-item storage requires a complete set of additions-only source operations.");
2638
+ }
2639
+ const sourcesRoot = path.join(this.root, "sources");
2640
+ const sourcesStat = await statMaybe(sourcesRoot);
2641
+ if (sourcesStat && (!sourcesStat.isDirectory() || sourcesStat.isSymbolicLink()))
2642
+ throw new Error("Unsafe or symbolic-link sources root for new content.");
2643
+ const sourcesWereAbsent = !sourcesStat;
2644
+ const additions = next.items.slice(previous.items.length);
2645
+ const replacementById = new Map(replacements.map((replacement) => [replacement.itemId, replacement]));
2646
+ const targets = new Set(operations.map((operation) => sourcesRelativeTarget(operation)));
2647
+ const ownedDirectories = [];
2648
+ let nextSourcesTree;
2649
+ let stableSourcesTreeNext;
2650
+ let stableSourcesTree;
2651
+ const nextSourcesStage = path.join(this.lock, "next-sources");
2652
+ await this.assertRevision(expected);
2653
+ if (sourcesWereAbsent) {
2654
+ const stagedFiles = additions.flatMap((item) => {
2655
+ const replacement = replacementById.get(item.id);
2656
+ if (!replacement)
2657
+ throw new Error("Reviewed content for a new item disappeared before staging.");
2658
+ return replacement.files.map((file) => ({ ...file, path: `${item.id}/${file.path}` }));
2659
+ });
2660
+ await createPrivateTree(nextSourcesStage, stagedFiles);
2661
+ const rootStat = await lstat(nextSourcesStage);
2662
+ ownedDirectories.push({ path: "sources", stage: "next-sources", dev: rootStat.dev, ino: rootStat.ino });
2663
+ for (const operation of operations) {
2664
+ if (operation.kind !== "file")
2665
+ continue;
2666
+ const parent = path.join(nextSourcesStage, operation.itemId);
2667
+ const stat = await lstat(parent);
2668
+ ownedDirectories.push({ path: `sources/${operation.itemId}`, stage: "next-sources", dev: stat.dev, ino: stat.ino });
2669
+ }
2670
+ const stagedTree = await tree(nextSourcesStage, undefined, true);
2671
+ if (!stagedTree)
2672
+ throw new Error("Private new sources root stage disappeared.");
2673
+ rememberUnjournaledArtifact(this, "next-sources", { dev: rootStat.dev, ino: rootStat.ino }, "tree", stagedTree);
2674
+ await this.checkpoint?.("stage-write", this.root);
2675
+ await this.assertLease(false);
2676
+ await this.checkpoint?.("stage-sync", this.root);
2677
+ await this.assertLease(false);
2678
+ const expectedHashes = new Map(additions.flatMap((item) => item.files.map((file) => [`${item.id}/${file.path}`, file.sha256])));
2679
+ // Private stored modes differ from the catalog's original source/apply modes; capture the staged snapshot itself.
2680
+ const currentSnapshotFiles = await strictTreeFiles(nextSourcesStage);
2681
+ const allDescriptors = currentSnapshotFiles.map((file) => {
2682
+ if (expectedHashes.get(file.path) !== file.sha256)
2683
+ throw new Error("Staged sources files do not match the next catalog checksums.");
2684
+ return { path: file.path, mode: file.mode, sha256: file.sha256 };
2685
+ });
2686
+ if (allDescriptors.length !== expectedHashes.size)
2687
+ throw new Error("Staged sources tree does not match the next catalog file set.");
2688
+ await assertTreeMatchesDescriptors(nextSourcesStage, allDescriptors, true);
2689
+ for (const [index, operation] of operations.entries()) {
2690
+ const item = additions.find((entry) => entry.id === operation.itemId);
2691
+ const stageTarget = path.join(nextSourcesStage, ...sourcesRelativeTarget(operation).split("/"));
2692
+ const replacementTree = await sourceArtifactTreeIfPresent(stageTarget, operation.kind);
2693
+ if (!replacementTree)
2694
+ throw new Error(`Private new ${operation.kind} target disappeared before journaling.`);
2695
+ if (operation.kind === "file")
2696
+ await assertTreeMatchesDescriptors(path.dirname(stageTarget), item.files);
2697
+ else
2698
+ await assertTreeMatchesDescriptors(stageTarget, item.files);
2699
+ operations[index] = { ...operation, newTree: replacementTree };
2700
+ }
2701
+ nextSourcesTree = stagedTree;
2702
+ stableSourcesTreeNext = await tree(nextSourcesStage, undefined, true, targets) ?? "absent";
2703
+ stableSourcesTree = "absent";
2704
+ }
2705
+ else {
2706
+ stableSourcesTree = await tree(sourcesRoot, undefined, true) ?? "absent";
2707
+ const stagedByItem = new Map();
2708
+ for (const [index, operation] of operations.entries()) {
2709
+ const stage = path.join(this.lock, `next-${index}`);
2710
+ const replacement = replacementById.get(operation.itemId);
2711
+ const item = additions.find((entry) => entry.id === operation.itemId);
2712
+ if (!replacement || !item)
2713
+ throw new Error("Reviewed new item or bytes disappeared before staging.");
2714
+ await this.assertLease(false);
2715
+ if (operation.kind === "file") {
2716
+ await createPrivateTree(stage, replacement.files);
2717
+ const stat = await lstat(stage);
2718
+ ownedDirectories.push({ path: `sources/${item.id}`, stage: `next-${index}`, dev: stat.dev, ino: stat.ino });
2719
+ await assertTreeMatchesDescriptors(stage, item.files);
2720
+ }
2721
+ else {
2722
+ await createPrivateTree(stage, replacement.files);
2723
+ await assertTreeMatchesDescriptors(stage, item.files);
2724
+ }
2725
+ const stageStat = await lstat(stage);
2726
+ const stagedTree = await tree(stage, undefined, true);
2727
+ if (!stagedTree)
2728
+ throw new Error("Private new item stage disappeared.");
2729
+ rememberUnjournaledArtifact(this, `next-${index}`, { dev: stageStat.dev, ino: stageStat.ino }, "tree", stagedTree);
2730
+ await this.checkpoint?.("stage-write", this.root);
2731
+ await this.assertLease(false);
2732
+ await this.checkpoint?.("stage-sync", this.root);
2733
+ await this.assertLease(false);
2734
+ const contentPath = operation.kind === "file" ? path.join(stage, path.posix.basename(operation.target)) : stage;
2735
+ const replacementTree = await sourceArtifactTreeIfPresent(contentPath, operation.kind);
2736
+ if (!replacementTree)
2737
+ throw new Error(`Private new ${operation.kind} target disappeared before journaling.`);
2738
+ operations[index] = { ...operation, newTree: replacementTree };
2739
+ stagedByItem.set(item.id, stage);
2740
+ }
2741
+ const substitutions = new Map(operations.map((operation) => [operation.itemId, stagedByItem.get(operation.itemId)]));
2742
+ const replacementsBySourcesPath = new Map(operations.map((operation) => [operation.itemId, substitutions.get(operation.itemId)]));
2743
+ const mapped = new Map();
2744
+ for (const operation of operations)
2745
+ mapped.set(operation.itemId, replacementsBySourcesPath.get(operation.itemId));
2746
+ const plannedSources = await tree(sourcesRoot, mapped, true);
2747
+ const plannedStable = await tree(sourcesRoot, mapped, true, targets);
2748
+ if (!plannedSources || !plannedStable)
2749
+ throw new Error("Cannot compute the checked new sources revision.");
2750
+ nextSourcesTree = plannedSources;
2751
+ stableSourcesTreeNext = plannedStable;
2752
+ }
2753
+ await this.assertRevision(expected);
2754
+ const catalogStage = path.join(this.lock, "next-catalog");
2755
+ await this.assertLease(false);
2756
+ const catalogHandle = await open(catalogStage, "wx", 0o600);
2757
+ try {
2758
+ const catalogStat = await catalogHandle.stat();
2759
+ rememberUnjournaledArtifact(this, "next-catalog", { dev: catalogStat.dev, ino: catalogStat.ino }, "file", hash(catalog));
2760
+ await catalogHandle.writeFile(catalog);
2761
+ await this.checkpoint?.("stage-write", this.root);
2762
+ await this.assertLease(false);
2763
+ await this.checkpoint?.("stage-sync", this.root);
2764
+ await this.assertLease(false);
2765
+ await catalogHandle.sync();
2766
+ }
2767
+ finally {
2768
+ await catalogHandle.close();
2769
+ }
2770
+ const stagedCatalog = await checkedFile(catalogStage);
2771
+ if (!stagedCatalog || !stagedCatalog.equals(catalog))
2772
+ throw new Error("Staged catalog changed before new sources swap; refusing commit.");
2773
+ await this.assertLease(false);
2774
+ if (expected.catalog !== null) {
2775
+ const oldCatalogPath = path.join(this.lock, "old-catalog");
2776
+ const oldCatalogBytes = Buffer.from(expected.catalog, "base64");
2777
+ await writePrivate(oldCatalogPath, oldCatalogBytes);
2778
+ await rememberUnjournaledFile(this, "old-catalog", oldCatalogPath, oldCatalogBytes);
2779
+ }
2780
+ const journal = {
2781
+ version: 2,
2782
+ root: this.root,
2783
+ token: this.token,
2784
+ oldCatalog: expected.catalog === null ? null : hash(Buffer.from(expected.catalog, "base64")),
2785
+ newCatalog: hash(catalog),
2786
+ stableSourcesTree,
2787
+ stableSourcesTreeNext,
2788
+ nextSourcesTree,
2789
+ ownedDirectories,
2790
+ operations,
2791
+ };
2792
+ const journalBytes = Buffer.from(JSON.stringify(journal));
2793
+ const journalTemp = path.join(this.lock, "journal.tmp");
2794
+ await writePrivate(journalTemp, journalBytes);
2795
+ await rememberUnjournaledFile(this, "journal.tmp", journalTemp, journalBytes);
2796
+ await rename(journalTemp, path.join(this.lock, "journal.json"));
2797
+ forgetUnjournaledArtifact(this, "journal.tmp");
2798
+ await writePrivate(path.join(this.lock, "journal.digest"), hash(journalBytes));
2799
+ await syncDirectory(this.lock);
2800
+ try {
2801
+ await this.checkpoint?.("journal", this.root);
2802
+ await this.assertLease(false);
2803
+ const rawJournal = await checkedFile(path.join(this.lock, "journal.json"));
2804
+ const journalDigest = await checkedFile(path.join(this.lock, "journal.digest"));
2805
+ if (!rawJournal?.equals(journalBytes) || !journalDigest || journalDigest.toString("utf8") !== hash(rawJournal))
2806
+ throw new Error("Sources journal or raw digest changed before new-target swap; refusing commit.");
2807
+ await this.assertRevision(expected);
2808
+ if ((await tree(sourcesRoot, undefined, true) ?? "absent") !== expected.tree)
2809
+ throw new Error("Sources changed after capture; refusing new-target swap.");
2810
+ validateSourcesJournalTargets(journal, previous, next);
2811
+ if (sourcesWereAbsent) {
2812
+ if (await statMaybe(sourcesRoot))
2813
+ throw new Error("A sources root appeared after the new-target review; refusing to adopt it.");
2814
+ if (await sourceArtifactTreeIfPresent(nextSourcesStage, "tree") !== journal.nextSourcesTree)
2815
+ throw new Error("Private new sources root changed after journaling.");
2816
+ for (const directory of ownedDirectories) {
2817
+ const stagedPath = directory.path === "sources" ? nextSourcesStage : path.join(nextSourcesStage, ...directory.path.slice("sources/".length).split("/"));
2818
+ await verifyOwnedDirectory(stagedPath, directory);
2819
+ }
2820
+ await renameNewSourcesStageExclusive(this.root, nextSourcesStage, sourcesRoot);
2821
+ for (const directory of ownedDirectories) {
2822
+ const livePath = path.join(this.root, ...directory.path.split("/"));
2823
+ await verifyOwnedDirectory(livePath, directory);
2824
+ }
2825
+ await this.checkpoint?.("new-renamed", this.root);
2826
+ await this.assertLease(false);
2827
+ }
2828
+ else {
2829
+ for (const [index, operation] of operations.entries()) {
2830
+ const item = next.items.find((entry) => entry.id === operation.itemId);
2831
+ const stage = path.join(this.lock, `next-${index}`);
2832
+ const target = path.join(this.root, ...operation.target.split("/"));
2833
+ const targetParent = path.dirname(target);
2834
+ await this.checkpoint?.("before-swap", this.root);
2835
+ await this.assertLease(false);
2836
+ await assertNewSourcesTargetVacant(this.root, item);
2837
+ if (operation.kind === "file") {
2838
+ const directory = ownedSourceDirectory(journal, `sources/${item.id}`);
2839
+ if (!directory)
2840
+ throw new Error("New file target has no journaled private parent directory.");
2841
+ await verifyOwnedDirectory(stage, directory);
2842
+ if (await sourceArtifactTreeIfPresent(path.join(stage, path.posix.basename(operation.target)), "file") !== operation.newTree)
2843
+ throw new Error("Private new file stage changed before installation.");
2844
+ await renameNewSourcesStageExclusive(this.root, stage, targetParent);
2845
+ await verifyOwnedDirectory(targetParent, directory);
2846
+ }
2847
+ else {
2848
+ if (await sourceArtifactTreeIfPresent(stage, "tree") !== operation.newTree)
2849
+ throw new Error("Private new tree stage changed before installation.");
2850
+ await renameNewSourcesStageExclusive(this.root, stage, target);
2851
+ }
2852
+ const installed = await checkedSourcesOperationTarget(this.root, operation);
2853
+ if (!installed || await sourceArtifactTreeIfPresent(installed, operation.kind) !== operation.newTree)
2854
+ throw new Error("New sources target changed during its installation.");
2855
+ await this.checkpoint?.("new-renamed", this.root);
2856
+ await this.assertLease(false);
2857
+ }
2858
+ }
2859
+ await this.checkpoint?.("before-catalog", this.root);
2860
+ await this.assertLease(false);
2861
+ if ((await tree(sourcesRoot, undefined, true) ?? "absent") !== journal.nextSourcesTree ||
2862
+ (await tree(sourcesRoot, undefined, true, targets) ?? "absent") !== journal.stableSourcesTreeNext) {
2863
+ throw new Error("Sources tree, new target, or adjacent/unregistered inode changed before catalog update.");
2864
+ }
2865
+ const actual = await checkedFile(path.join(this.root, "catalog.json"));
2866
+ if ((actual ? hash(actual) : null) !== journal.oldCatalog)
2867
+ throw new Error("Catalog changed during new-item commit; refusing overwrite.");
2868
+ for (const operation of operations) {
2869
+ const target = await checkedSourcesOperationTarget(this.root, operation);
2870
+ if (!target || await sourceArtifactTreeIfPresent(target, operation.kind) !== operation.newTree)
2871
+ throw new Error("New registered content changed before catalog update.");
2872
+ if (operation.kind === "tree")
2873
+ await assertTreeMatchesDescriptors(target, next.items.find((item) => item.id === operation.itemId).files);
2874
+ else {
2875
+ const parent = path.dirname(target);
2876
+ const directory = ownedSourceDirectory(journal, `sources/${operation.itemId}`);
2877
+ if (!directory)
2878
+ throw new Error("New file target parent is not owned by this journal.");
2879
+ await verifyOwnedDirectory(parent, directory);
2880
+ if (JSON.stringify(await readdir(parent)) !== JSON.stringify([path.posix.basename(target)]))
2881
+ throw new Error("New file target parent contains an unknown artifact.");
2882
+ }
2883
+ }
2884
+ if (!(await checkedFile(catalogStage))?.equals(catalog))
2885
+ throw new Error("Staged catalog changed before commit.");
2886
+ const catalogPath = path.join(this.root, "catalog.json");
2887
+ const heldPath = path.join(this.lock, "held-catalog");
2888
+ const beforeCatalogStat = await statMaybe(catalogPath);
2889
+ if (beforeCatalogStat && expected.catalogIdentity !== `${beforeCatalogStat.dev}:${beforeCatalogStat.ino}`)
2890
+ throw new Error("Original catalog inode changed before it was held.");
2891
+ if (await statMaybe(heldPath))
2892
+ throw new Error("Unexpected held catalog; refusing overwrite.");
2893
+ if (actual)
2894
+ await rename(catalogPath, heldPath);
2895
+ await this.checkpoint?.("catalog-held", this.root);
2896
+ await this.assertLease(false);
2897
+ const held = await checkedFile(heldPath);
2898
+ if ((held ? hash(held) : null) !== journal.oldCatalog)
2899
+ throw new Error("Original catalog changed in its held inode; retained external bytes.");
2900
+ const heldStat = await statMaybe(heldPath);
2901
+ if (heldStat && expected.catalogIdentity !== `${heldStat.dev}:${heldStat.ino}`)
2902
+ throw new Error("Original held catalog inode changed; retained it.");
2903
+ await link(catalogStage, catalogPath);
2904
+ await this.checkpoint?.("catalog-renamed", this.root);
2905
+ await this.assertLease(false);
2906
+ await this.checkpoint?.("directory-sync", this.root);
2907
+ await this.assertLease(false);
2908
+ for (const operation of operations) {
2909
+ const target = await checkedSourcesOperationTarget(this.root, operation);
2910
+ if (target)
2911
+ await syncDirectory(path.dirname(target));
2912
+ }
2913
+ await syncDirectory(this.root);
2914
+ await this.checkpoint?.("committed", this.root);
2915
+ await this.assertLease(false);
2916
+ if ((await tree(sourcesRoot, undefined, true) ?? "absent") !== journal.nextSourcesTree ||
2917
+ (await tree(sourcesRoot, undefined, true, targets) ?? "absent") !== journal.stableSourcesTreeNext ||
2918
+ hash((await checkedFile(catalogPath))) !== journal.newCatalog)
2919
+ throw new Error("New sources library changed during commit finalization.");
2920
+ const heldAtFinish = await checkedFile(heldPath);
2921
+ const heldAtFinishStat = await statMaybe(heldPath);
2922
+ if ((heldAtFinish ? hash(heldAtFinish) : null) !== journal.oldCatalog || (heldAtFinishStat && expected.catalogIdentity !== `${heldAtFinishStat.dev}:${heldAtFinishStat.ino}`))
2923
+ throw new Error("Original held catalog changed during finalization; retaining external bytes.");
2924
+ const cleanup = await createFinalizedCleanup(this.lock, journal, journalBytes);
2925
+ await this.markFinalized(cleanup);
2926
+ await this.checkpoint?.("finalized", this.root);
2927
+ await this.recover(false);
2928
+ }
2929
+ catch (error) {
2930
+ const operation = sessionLeases.get(this)?.operation;
2931
+ if (operation)
2932
+ operation.recovering = true;
2933
+ try {
2934
+ await this.recover(true);
2935
+ }
2936
+ catch (recoveryError) {
2937
+ throw new Error("Copi new-item transaction could not safely recover an external change; retained private journal and source bytes.", { cause: new AggregateError([error, recoveryError]) });
2938
+ }
2939
+ throw error;
2940
+ }
2941
+ }
2942
+ async #commitSources(expected, previous, next, catalog, replacements, before) {
2943
+ const operations = await validateSourcesTransition(this.root, previous, next, replacements, before);
2944
+ if (next.items.length > previous.items.length) {
2945
+ await this.#commitNewItems(expected, previous, next, catalog, replacements, operations);
2946
+ return;
2947
+ }
2948
+ const sourcesRoot = path.join(this.root, "sources");
2949
+ const targets = new Set(operations.map((operation) => operation.target.slice("sources/".length)));
2950
+ const stableSourcesTree = operations.length > 0
2951
+ ? await tree(sourcesRoot, undefined, true, targets)
2952
+ : await tree(sourcesRoot, undefined, true);
2953
+ if (stableSourcesTree === null)
2954
+ throw new Error("Registered sources root is missing.");
2955
+ await this.assertRevision(expected);
2956
+ for (const [index, operation] of operations.entries()) {
2957
+ const target = operation.kind === "file"
2958
+ ? await checkedSourceFileTarget(this.root, operation.target)
2959
+ : await checkedSourceTreeTarget(this.root, operation.target);
2960
+ if (!target)
2961
+ throw new Error(`Registered sources ${operation.kind} disappeared before staging.`);
2962
+ const stage = path.join(this.lock, `next-${index}`);
2963
+ const replacement = replacements.find((entry) => entry.itemId === operation.itemId);
2964
+ const nextItem = next.items.find((item) => item.id === operation.itemId);
2965
+ if (!replacement || !nextItem)
2966
+ throw new Error("Registered sources replacement disappeared before staging.");
2967
+ await this.assertLease(false);
2968
+ if (operation.kind === "file") {
2969
+ const handle = await open(stage, "wx", 0o600);
2970
+ const stageBytes = replacement.files[0].bytes;
2971
+ try {
2972
+ const stageStat = await handle.stat();
2973
+ rememberUnjournaledArtifact(this, `next-${index}`, { dev: stageStat.dev, ino: stageStat.ino }, "file", hash(stageBytes));
2974
+ await handle.writeFile(stageBytes);
2975
+ await this.checkpoint?.("stage-write", this.root);
2976
+ await this.assertLease(false);
2977
+ await this.checkpoint?.("stage-sync", this.root);
2978
+ await this.assertLease(false);
2979
+ await handle.sync();
2980
+ }
2981
+ finally {
2982
+ await handle.close();
2983
+ }
2984
+ const staged = await checkedFile(stage);
2985
+ if (!staged || !staged.equals(stageBytes))
2986
+ throw new Error("Staged sources bytes changed from the fixed inspected bytes; refusing save.");
2987
+ }
2988
+ else {
2989
+ await createPrivateTree(stage, replacement.files);
2990
+ await assertTreeMatchesDescriptors(stage, nextItem.files);
2991
+ const stageStat = await lstat(stage);
2992
+ const stagedTree = await tree(stage, undefined, true);
2993
+ if (stagedTree === null)
2994
+ throw new Error("Private sources tree stage disappeared.");
2995
+ rememberUnjournaledArtifact(this, `next-${index}`, { dev: stageStat.dev, ino: stageStat.ino }, "tree", stagedTree);
2996
+ await this.checkpoint?.("stage-write", this.root);
2997
+ await this.assertLease(false);
2998
+ await this.checkpoint?.("stage-sync", this.root);
2999
+ await this.assertLease(false);
3000
+ await assertTreeMatchesDescriptors(stage, nextItem.files);
3001
+ }
3002
+ const oldTree = await sourceArtifactTreeIfPresent(target, operation.kind);
3003
+ const newTree = await sourceArtifactTreeIfPresent(stage, operation.kind);
3004
+ if (oldTree === null || newTree === null)
3005
+ throw new Error(`Registered sources ${operation.kind} or its private stage disappeared.`);
3006
+ operations[index] = { ...operation, oldTree, newTree };
3007
+ }
3008
+ const catalogStage = path.join(this.lock, "next-catalog");
3009
+ await this.assertLease(false);
3010
+ const catalogHandle = await open(catalogStage, "wx", 0o600);
3011
+ try {
3012
+ const catalogStat = await catalogHandle.stat();
3013
+ rememberUnjournaledArtifact(this, "next-catalog", { dev: catalogStat.dev, ino: catalogStat.ino }, "file", hash(catalog));
3014
+ await catalogHandle.writeFile(catalog);
3015
+ await this.checkpoint?.("stage-write", this.root);
3016
+ await this.assertLease(false);
3017
+ await this.checkpoint?.("stage-sync", this.root);
3018
+ await this.assertLease(false);
3019
+ await catalogHandle.sync();
3020
+ }
3021
+ finally {
3022
+ await catalogHandle.close();
3023
+ }
3024
+ const stagedCatalog = await checkedFile(catalogStage);
3025
+ if (!stagedCatalog || !stagedCatalog.equals(catalog))
3026
+ throw new Error("Staged catalog changed before sources swap; refusing commit.");
3027
+ await this.assertLease(false);
3028
+ if (expected.catalog !== null) {
3029
+ const oldCatalogPath = path.join(this.lock, "old-catalog");
3030
+ const oldCatalogBytes = Buffer.from(expected.catalog, "base64");
3031
+ await writePrivate(oldCatalogPath, oldCatalogBytes);
3032
+ await rememberUnjournaledFile(this, "old-catalog", oldCatalogPath, oldCatalogBytes);
3033
+ }
3034
+ const journal = {
3035
+ version: 2,
3036
+ root: this.root,
3037
+ token: this.token,
3038
+ oldCatalog: expected.catalog === null ? null : hash(Buffer.from(expected.catalog, "base64")),
3039
+ newCatalog: hash(catalog),
3040
+ stableSourcesTree,
3041
+ operations,
3042
+ };
3043
+ const journalBytes = Buffer.from(JSON.stringify(journal));
3044
+ const journalTemp = path.join(this.lock, "journal.tmp");
3045
+ await writePrivate(journalTemp, journalBytes);
3046
+ await rememberUnjournaledFile(this, "journal.tmp", journalTemp, journalBytes);
3047
+ await rename(journalTemp, path.join(this.lock, "journal.json"));
3048
+ forgetUnjournaledArtifact(this, "journal.tmp");
3049
+ await writePrivate(path.join(this.lock, "journal.digest"), hash(journalBytes));
3050
+ await syncDirectory(this.lock);
3051
+ try {
3052
+ await this.checkpoint?.("journal", this.root);
3053
+ await this.assertLease(false);
3054
+ const rawJournal = await checkedFile(path.join(this.lock, "journal.json"));
3055
+ const journalDigest = await checkedFile(path.join(this.lock, "journal.digest"));
3056
+ if (!rawJournal || !rawJournal.equals(journalBytes) || !journalDigest || journalDigest.toString("utf8") !== hash(rawJournal))
3057
+ throw new Error("Sources journal or raw digest changed before swap; refusing commit.");
3058
+ await this.assertRevision(expected);
3059
+ const previousSourcesTree = await tree(sourcesRoot, undefined, true);
3060
+ if (previousSourcesTree !== expected.tree)
3061
+ throw new Error("Sources changed after capture; refusing swap.");
3062
+ const stageByTarget = new Map(operations.map((operation, index) => [operation.target.slice("sources/".length), path.join(this.lock, `next-${index}`)]));
3063
+ const expectedNextTree = await tree(sourcesRoot, stageByTarget, true);
3064
+ if (expectedNextTree === null)
3065
+ throw new Error("Cannot compute the expected sources tree.");
3066
+ validateSourcesJournalTargets(journal, previous, next);
3067
+ for (const [index, operation] of operations.entries()) {
3068
+ const target = operation.kind === "file"
3069
+ ? await checkedSourceFileTarget(this.root, operation.target)
3070
+ : await checkedSourceTreeTarget(this.root, operation.target);
3071
+ if (!target)
3072
+ throw new Error(`Registered sources ${operation.kind} disappeared during commit.`);
3073
+ const oldPath = path.join(this.lock, `old-${index}`);
3074
+ await this.checkpoint?.("before-swap", this.root);
3075
+ await this.assertLease(false);
3076
+ if (await sourceArtifactTreeIfPresent(target, operation.kind) !== operation.oldTree)
3077
+ throw new Error(`Registered sources ${operation.kind} changed during commit; refusing overwrite.`);
3078
+ await rename(target, oldPath);
3079
+ await this.checkpoint?.("old-renamed", this.root);
3080
+ await this.assertLease(false);
3081
+ if (await sourceArtifactTreeIfPresent(oldPath, operation.kind) !== operation.oldTree)
3082
+ throw new Error("Original sources inode changed after rename; retained external bytes instead of deleting them.");
3083
+ if (await statMaybe(target))
3084
+ throw new Error(`A source ${operation.kind} appeared at the registered target during commit.`);
3085
+ const stage = path.join(this.lock, `next-${index}`);
3086
+ if (await sourceArtifactTreeIfPresent(stage, operation.kind) !== operation.newTree)
3087
+ throw new Error("Private sources stage changed during commit.");
3088
+ await rename(stage, target);
3089
+ await this.checkpoint?.("new-renamed", this.root);
3090
+ await this.assertLease(false);
3091
+ }
3092
+ await this.checkpoint?.("before-catalog", this.root);
3093
+ await this.assertLease(false);
3094
+ if (await tree(sourcesRoot, undefined, true) !== expectedNextTree || await tree(sourcesRoot, undefined, true, targets) !== stableSourcesTree)
3095
+ throw new Error("Sources tree or an adjacent/unregistered inode changed during swap; refusing catalog update.");
3096
+ const actual = await checkedFile(path.join(this.root, "catalog.json"));
3097
+ if ((actual ? hash(actual) : null) !== journal.oldCatalog)
3098
+ throw new Error("Catalog changed during sources commit; refusing overwrite.");
3099
+ for (const [index, operation] of operations.entries()) {
3100
+ const target = operation.kind === "file"
3101
+ ? await checkedSourceFileTarget(this.root, operation.target)
3102
+ : await checkedSourceTreeTarget(this.root, operation.target);
3103
+ if (!target || await sourceArtifactTreeIfPresent(target, operation.kind) !== operation.newTree)
3104
+ throw new Error(`Registered sources ${operation.kind} changed before catalog update.`);
3105
+ if (await sourceArtifactTreeIfPresent(path.join(this.lock, `old-${index}`), operation.kind) !== operation.oldTree)
3106
+ throw new Error("Original sources inode changed; retaining the external bytes.");
3107
+ }
3108
+ if (!(await checkedFile(catalogStage))?.equals(catalog))
3109
+ throw new Error("Staged catalog changed before commit.");
3110
+ const catalogPath = path.join(this.root, "catalog.json");
3111
+ const heldPath = path.join(this.lock, "held-catalog");
3112
+ const beforeCatalogStat = await statMaybe(catalogPath);
3113
+ if (beforeCatalogStat && expected.catalogIdentity !== `${beforeCatalogStat.dev}:${beforeCatalogStat.ino}`)
3114
+ throw new Error("Original catalog inode changed before it was held.");
3115
+ if (await statMaybe(heldPath))
3116
+ throw new Error("Unexpected held catalog; refusing overwrite.");
3117
+ if (actual)
3118
+ await rename(catalogPath, heldPath);
3119
+ await this.checkpoint?.("catalog-held", this.root);
3120
+ await this.assertLease(false);
3121
+ const held = await checkedFile(heldPath);
3122
+ if ((held ? hash(held) : null) !== journal.oldCatalog)
3123
+ throw new Error("Original catalog changed in its held inode; retained external bytes.");
3124
+ const heldStat = await statMaybe(heldPath);
3125
+ if (heldStat && expected.catalogIdentity !== `${heldStat.dev}:${heldStat.ino}`)
3126
+ throw new Error("Original held catalog inode changed; retained it.");
3127
+ await link(catalogStage, catalogPath);
3128
+ await this.checkpoint?.("catalog-renamed", this.root);
3129
+ await this.assertLease(false);
3130
+ await this.checkpoint?.("directory-sync", this.root);
3131
+ await this.assertLease(false);
3132
+ for (const operation of operations) {
3133
+ const target = operation.kind === "file"
3134
+ ? await checkedSourceFileTarget(this.root, operation.target)
3135
+ : await checkedSourceTreeTarget(this.root, operation.target);
3136
+ if (target)
3137
+ await syncDirectory(path.dirname(target));
3138
+ }
3139
+ await syncDirectory(this.root);
3140
+ await this.checkpoint?.("committed", this.root);
3141
+ await this.assertLease(false);
3142
+ if (await tree(sourcesRoot, undefined, true) !== expectedNextTree || await tree(sourcesRoot, undefined, true, targets) !== stableSourcesTree || hash((await checkedFile(catalogPath))) !== journal.newCatalog)
3143
+ throw new Error("Sources library changed during commit finalization.");
3144
+ for (const [index, operation] of operations.entries())
3145
+ if (await sourceArtifactTreeIfPresent(path.join(this.lock, `old-${index}`), operation.kind) !== operation.oldTree)
3146
+ throw new Error("Original sources inode changed during finalization; retaining external bytes.");
3147
+ const heldAtFinish = await checkedFile(heldPath);
3148
+ const heldAtFinishStat = await statMaybe(heldPath);
3149
+ if ((heldAtFinish ? hash(heldAtFinish) : null) !== journal.oldCatalog || (heldAtFinishStat && expected.catalogIdentity !== `${heldAtFinishStat.dev}:${heldAtFinishStat.ino}`))
3150
+ throw new Error("Original held catalog changed during finalization; retaining external bytes.");
3151
+ const cleanup = await createFinalizedCleanup(this.lock, journal, journalBytes);
3152
+ await this.markFinalized(cleanup);
3153
+ await this.checkpoint?.("finalized", this.root);
3154
+ await this.recover(false);
3155
+ }
3156
+ catch (error) {
3157
+ const operation = sessionLeases.get(this)?.operation;
3158
+ if (operation)
3159
+ operation.recovering = true;
3160
+ try {
3161
+ await this.recover(true);
3162
+ }
3163
+ catch (recoveryError) {
3164
+ throw new Error("Copi sources transaction could not safely recover an external change; retained private journal and source bytes.", { cause: new AggregateError([error, recoveryError]) });
3165
+ }
3166
+ throw error;
3167
+ }
3168
+ }
3169
+ async markFinalized(cleanup) {
3170
+ const revision = await this.capture();
3171
+ const value = {
3172
+ version: 1,
3173
+ root: this.root,
3174
+ token: this.token,
3175
+ catalog: revision.catalog === null ? null : hash(Buffer.from(revision.catalog, "base64")),
3176
+ tree: revision.tree,
3177
+ ...(cleanup ? { cleanup } : {}),
3178
+ };
3179
+ const record = Buffer.from(JSON.stringify(value));
3180
+ const finalizedPath = path.join(this.lock, "finalized.json");
3181
+ const digestPath = path.join(this.lock, "finalized.digest");
3182
+ const existing = await checkedFile(finalizedPath);
3183
+ if (existing) {
3184
+ const existingDigest = await checkedFile(digestPath);
3185
+ if (!existingDigest || existingDigest.toString("utf8") !== hash(existing))
3186
+ throw new Error("Existing finalized transaction information is altered; refusing replacement.");
3187
+ const oldValue = JSON.parse(existing.toString("utf8"));
3188
+ if (oldValue.version !== value.version || oldValue.root !== value.root || oldValue.token !== value.token || oldValue.catalog !== value.catalog || oldValue.tree !== value.tree)
3189
+ throw new Error("Existing finalized transaction state changed; refusing replacement.");
3190
+ if (Object.hasOwn(oldValue, "cleanup")) {
3191
+ if (JSON.stringify(oldValue.cleanup) !== JSON.stringify(cleanup))
3192
+ throw new Error("Existing finalized cleanup custody record changed.");
3193
+ return;
3194
+ }
3195
+ if (!cleanup)
3196
+ return;
3197
+ await replacePrivate(finalizedPath, record);
3198
+ await replacePrivate(digestPath, hash(record));
3199
+ }
3200
+ else {
3201
+ await writePrivate(finalizedPath, record);
3202
+ await writePrivate(digestPath, hash(record));
3203
+ }
3204
+ await syncDirectory(this.lock);
3205
+ }
3206
+ async verifyFinalized() {
3207
+ const raw = await checkedFile(path.join(this.lock, "finalized.json"));
3208
+ if (!raw)
3209
+ return false;
3210
+ const digest = await checkedFile(path.join(this.lock, "finalized.digest"));
3211
+ if (!digest || digest.toString("utf8") !== hash(raw))
3212
+ throw new Error("Finalized transaction information is missing or altered; inspect the private journal.");
3213
+ const value = JSON.parse(raw.toString("utf8"));
3214
+ const revision = await this.capture();
3215
+ const catalog = revision.catalog === null ? null : hash(Buffer.from(revision.catalog, "base64"));
3216
+ if (value.version !== 1 || value.root !== this.root || value.token !== this.token || value.catalog !== catalog || value.tree !== revision.tree)
3217
+ throw new Error("Finalized transaction state was changed externally; refusing cleanup or rollback.");
3218
+ return true;
3219
+ }
3220
+ async recoverNewSources(journal, rollback, rawJournal, previous, nextCatalog, rawOld) {
3221
+ const rollbackMarker = await checkedFile(path.join(this.lock, "rollback.json"));
3222
+ if (rollbackMarker) {
3223
+ if (rollbackMarker.toString("utf8") !== this.token)
3224
+ throw new Error("Invalid new-target rollback marker; refusing recovery.");
3225
+ rollback = true;
3226
+ }
3227
+ const catalogPath = path.join(this.root, "catalog.json");
3228
+ const currentCatalog = await checkedFile(catalogPath);
3229
+ const currentHash = currentCatalog ? hash(currentCatalog) : null;
3230
+ const heldPath = path.join(this.lock, "held-catalog");
3231
+ const held = await checkedFile(heldPath);
3232
+ const heldHash = held ? hash(held) : null;
3233
+ if (held && heldHash !== journal.oldCatalog)
3234
+ throw new Error("Original v2 catalog changed in its held inode; refusing new-target recovery.");
3235
+ const catalogInTransit = currentHash === null && journal.oldCatalog !== null && heldHash === journal.oldCatalog;
3236
+ if (currentHash !== journal.oldCatalog && currentHash !== journal.newCatalog && !catalogInTransit)
3237
+ throw new Error("Catalog changed externally; refusing new-target recovery.");
3238
+ const committed = currentHash === journal.newCatalog;
3239
+ if (committed && journal.oldCatalog !== null && heldHash !== journal.oldCatalog)
3240
+ throw new Error("Original v2 held catalog is missing; refusing new-target recovery.");
3241
+ const displacedCatalog = await checkedFile(path.join(this.lock, "displaced-catalog"));
3242
+ if (displacedCatalog && hash(displacedCatalog) !== journal.newCatalog)
3243
+ throw new Error("Displaced v2 catalog was changed externally.");
3244
+ if (committed && (!currentCatalog || hash(currentCatalog) !== journal.newCatalog))
3245
+ throw new Error("Current new-item catalog does not match its journal commitment.");
3246
+ const sourcesRoot = path.join(this.root, "sources");
3247
+ const lockEntries = await readdir(this.lock);
3248
+ if (journal.stableSourcesTree === "absent" && lockEntries.some((entry) => /^next-(?:0|[1-9][0-9]*)$/.test(entry))) {
3249
+ throw new Error("Unknown per-item stage exists alongside the journaled new sources-root stage.");
3250
+ }
3251
+ const targets = new Set(journal.operations.map(sourcesRelativeTarget));
3252
+ const stageSourcesRoot = path.join(this.lock, "next-sources");
3253
+ const stagedSourcesCommitment = await sourceArtifactTreeIfPresent(stageSourcesRoot, "tree");
3254
+ if (stagedSourcesCommitment !== null && stagedSourcesCommitment !== journal.nextSourcesTree)
3255
+ throw new Error("Private new sources root stage is altered or unsafe.");
3256
+ const sourcesRootIdentity = ownedSourceDirectory(journal, "sources");
3257
+ if (journal.stableSourcesTree === "absent" && !sourcesRootIdentity)
3258
+ throw new Error("New sources root is missing its journaled identity.");
3259
+ if (sourcesRootIdentity) {
3260
+ if (stagedSourcesCommitment !== null)
3261
+ await verifyOwnedDirectory(stageSourcesRoot, sourcesRootIdentity);
3262
+ await verifyOwnedDirectory(sourcesRoot, sourcesRootIdentity, true);
3263
+ }
3264
+ else if (!await checkedDirectory(sourcesRoot)) {
3265
+ throw new Error("Existing sources root disappeared during new-item recovery.");
3266
+ }
3267
+ for (const directory of journal.ownedDirectories ?? []) {
3268
+ const staged = ownedDirectoryStagePath(this.lock, directory);
3269
+ const stagedStat = await statMaybe(staged);
3270
+ if (stagedStat)
3271
+ await verifyOwnedDirectory(staged, directory);
3272
+ const live = path.join(this.root, ...directory.path.split("/"));
3273
+ const liveStat = await statMaybe(live);
3274
+ if (liveStat)
3275
+ await verifyOwnedDirectory(live, directory);
3276
+ if (stagedStat && liveStat)
3277
+ throw new Error(`Owned source directory ${directory.path} exists both in stage and live storage.`);
3278
+ }
3279
+ const stableNow = await tree(sourcesRoot, undefined, true, targets) ?? "absent";
3280
+ const currentSourcesTree = await tree(sourcesRoot, undefined, true) ?? "absent";
3281
+ const states = [];
3282
+ for (const [index, operation] of journal.operations.entries()) {
3283
+ const target = path.join(this.root, ...operation.target.split("/"));
3284
+ const checkedTarget = await checkedSourcesOperationTarget(this.root, operation, true);
3285
+ const current = checkedTarget ? await sourceArtifactTreeIfPresent(checkedTarget, operation.kind) : null;
3286
+ const backupPath = path.join(this.lock, `old-${index}`);
3287
+ if (await statMaybe(backupPath))
3288
+ throw new Error("A new-item journal unexpectedly contains an old source backup.");
3289
+ const stagedContent = operationStageContentPath(this.lock, journal, index);
3290
+ const stage = await sourceArtifactTreeIfPresent(stagedContent, operation.kind);
3291
+ const displacedPath = path.join(this.lock, `displaced-${index}`);
3292
+ const displaced = await sourceArtifactTreeIfPresent(displacedPath, operation.kind);
3293
+ if (current !== null && current !== operation.newTree)
3294
+ throw new Error("New registered target was externally added or changed; preserving it.");
3295
+ if (stage !== null && stage !== operation.newTree)
3296
+ throw new Error("Private new-target stage is altered or unsafe.");
3297
+ if (displaced !== null && displaced !== operation.newTree)
3298
+ throw new Error("Displaced new-target bytes were altered externally.");
3299
+ if (Number(current === operation.newTree) + Number(stage === operation.newTree) + Number(displaced === operation.newTree) !== 1) {
3300
+ throw new Error("New-target recovery does not have exactly one checked current, staged, or displaced content inode.");
3301
+ }
3302
+ if (committed && !rollback && current !== operation.newTree)
3303
+ throw new Error("Committed new source content is missing or altered.");
3304
+ if (operationIsStagedFileParent(journal, index) && stage !== null) {
3305
+ const parentStage = operationStageContainerPath(this.lock, journal, index);
3306
+ const parentIdentity = ownedSourceDirectory(journal, `sources/${operation.itemId}`);
3307
+ if (!parentIdentity)
3308
+ throw new Error("New file stage has no owned parent-directory identity.");
3309
+ await verifyOwnedDirectory(parentStage, parentIdentity);
3310
+ if (JSON.stringify((await readdir(parentStage)).sort()) !== JSON.stringify([path.posix.basename(operation.target)])) {
3311
+ throw new Error("Private new-file stage contains an unknown artifact.");
3312
+ }
3313
+ }
3314
+ if (operation.kind === "file" && stage !== null && journal.stableSourcesTree === "absent") {
3315
+ const stagedParent = path.dirname(stagedContent);
3316
+ if (JSON.stringify(await readdir(stagedParent).then((names) => names.sort())) !== JSON.stringify([path.posix.basename(operation.target)])) {
3317
+ throw new Error("Private new-file stage contains an unknown artifact.");
3318
+ }
3319
+ }
3320
+ const item = nextCatalog.items.find((entry) => entry.id === operation.itemId);
3321
+ if (!item)
3322
+ throw new Error("New-target journal references a missing catalog item.");
3323
+ const contentPath = current === operation.newTree ? checkedTarget : stage === operation.newTree ? stagedContent : displacedPath;
3324
+ if (operation.kind === "file") {
3325
+ if (item.files.length !== 1 || hash(await readFile(contentPath)) !== item.files[0].sha256)
3326
+ throw new Error("New file bytes do not match the fixed catalog descriptor.");
3327
+ }
3328
+ else
3329
+ await assertTreeMatchesDescriptors(contentPath, item.files);
3330
+ states.push({ operation, current, stage, displaced });
3331
+ }
3332
+ if (await this.verifyFinalized()) {
3333
+ if (!committed || rollback || currentSourcesTree !== journal.nextSourcesTree || stableNow !== journal.stableSourcesTreeNext ||
3334
+ states.some((state) => state.current !== state.operation.newTree))
3335
+ throw new Error("Finalized new-item transaction no longer matches its committed source tree.");
3336
+ await this.markFinalized(await createFinalizedCleanup(this.lock, journal, rawJournal));
3337
+ await rm(path.join(this.lock, "journal.json"));
3338
+ return;
3339
+ }
3340
+ if (committed && !rollback) {
3341
+ if (currentSourcesTree !== journal.nextSourcesTree || stableNow !== journal.stableSourcesTreeNext ||
3342
+ states.some((state) => state.current !== state.operation.newTree))
3343
+ throw new Error("Unregistered or adjacent sources changed; refusing to finalize new-item commit.");
3344
+ await this.markFinalized(await createFinalizedCleanup(this.lock, journal, rawJournal));
3345
+ await rm(path.join(this.lock, "journal.json"));
3346
+ return;
3347
+ }
3348
+ if (!rollbackMarker) {
3349
+ await writePrivate(path.join(this.lock, "rollback.json"), this.token);
3350
+ await syncDirectory(this.lock);
3351
+ }
3352
+ await this.checkpoint?.("rollback-started", this.root);
3353
+ for (const state of states) {
3354
+ const { operation } = state;
3355
+ await this.assertLease(false);
3356
+ const checkedTarget = await checkedSourcesOperationTarget(this.root, operation, true);
3357
+ if (checkedTarget && await sourceArtifactTreeIfPresent(checkedTarget, operation.kind) === operation.newTree) {
3358
+ const index = journal.operations.indexOf(operation);
3359
+ const displacedPath = path.join(this.lock, `displaced-${index}`);
3360
+ if (await statMaybe(displacedPath))
3361
+ throw new Error("Unexpected displaced new-target artifact during rollback.");
3362
+ await rename(checkedTarget, displacedPath);
3363
+ if (await sourceArtifactTreeIfPresent(displacedPath, operation.kind) !== operation.newTree)
3364
+ throw new Error("New target changed while being displaced; preserving it.");
3365
+ await this.checkpoint?.("rollback-displaced", this.root);
3366
+ }
3367
+ await this.assertLease(false);
3368
+ if (await checkedSourcesOperationTarget(this.root, operation, true))
3369
+ throw new Error("A new target appeared during rollback; preserving the external content.");
3370
+ }
3371
+ const owned = [...(journal.ownedDirectories ?? [])].sort((left, right) => right.path.length - left.path.length);
3372
+ for (const directory of owned) {
3373
+ const live = path.join(this.root, ...directory.path.split("/"));
3374
+ if (!await statMaybe(live))
3375
+ continue;
3376
+ await verifyOwnedDirectory(live, directory);
3377
+ if ((await readdir(live)).length !== 0)
3378
+ throw new Error(`Owned directory ${directory.path} contains an unknown artifact; preserving it.`);
3379
+ await rmdir(live);
3380
+ }
3381
+ if (committed || catalogInTransit) {
3382
+ if (committed) {
3383
+ const displacedPath = path.join(this.lock, "displaced-catalog");
3384
+ if (await statMaybe(displacedPath))
3385
+ throw new Error("Unexpected displaced catalog during new-item rollback.");
3386
+ await rename(catalogPath, displacedPath);
3387
+ const displaced = await checkedFile(displacedPath);
3388
+ if (!displaced || hash(displaced) !== journal.newCatalog)
3389
+ throw new Error("New catalog changed during rollback; preserving it.");
3390
+ await this.checkpoint?.("rollback-catalog-held", this.root);
3391
+ }
3392
+ if (rawOld) {
3393
+ await this.assertLease(false);
3394
+ const checkedHeld = await checkedFile(heldPath);
3395
+ if (!checkedHeld || hash(checkedHeld) !== journal.oldCatalog)
3396
+ throw new Error("Original v2 catalog is missing or altered.");
3397
+ if (await statMaybe(catalogPath))
3398
+ throw new Error("An external catalog appeared during new-item rollback.");
3399
+ await link(heldPath, catalogPath);
3400
+ }
3401
+ }
3402
+ for (const [index, state] of states.entries()) {
3403
+ const current = await checkedSourcesOperationTarget(this.root, state.operation, true);
3404
+ if (current || await sourceArtifactTreeIfPresent(path.join(this.lock, `old-${index}`), state.operation.kind) !== null) {
3405
+ throw new Error("New-item rollback did not restore an absent target without inventing an old source.");
3406
+ }
3407
+ const stage = await sourceArtifactTreeIfPresent(operationStageContentPath(this.lock, journal, index), state.operation.kind);
3408
+ const displaced = await sourceArtifactTreeIfPresent(path.join(this.lock, `displaced-${index}`), state.operation.kind);
3409
+ if ((stage !== null && stage !== state.operation.newTree) || (displaced !== null && displaced !== state.operation.newTree)) {
3410
+ throw new Error("Retained new-target stage or displaced inode changed during recovery.");
3411
+ }
3412
+ }
3413
+ if ((await tree(sourcesRoot, undefined, true) ?? "absent") !== journal.stableSourcesTree)
3414
+ throw new Error("Adjacent, unregistered, or newly added sources changed; checked new targets were retracted and the journal is retained.");
3415
+ const restoredCatalog = await checkedFile(catalogPath);
3416
+ if ((restoredCatalog ? hash(restoredCatalog) : null) !== journal.oldCatalog)
3417
+ throw new Error("New-item rollback failed to restore the exact original catalog state.");
3418
+ await syncDirectory(this.root);
3419
+ await this.markFinalized(await createFinalizedCleanup(this.lock, journal, rawJournal));
3420
+ await rm(path.join(this.lock, "journal.json"));
3421
+ await this.checkpoint?.("rollback-finalized", this.root);
3422
+ }
3423
+ async recoverCatalogOnly(journal, rollback, rawJournal) {
3424
+ const entries = await assertKnownCatalogOnlyLockEntries(this.lock);
3425
+ if (await catalogOnlySourcesTree(this.root) !== journal.stableSourcesTree) {
3426
+ throw new Error("Catalog-only sources tree changed or became unsafe; preserving the journal and all sources.");
3427
+ }
3428
+ const rawNext = await checkedFile(path.join(this.lock, "next-catalog"));
3429
+ const nextStat = await statMaybe(path.join(this.lock, "next-catalog"));
3430
+ if (!rawNext || !nextStat || hash(rawNext) !== journal.newCatalog)
3431
+ throw new Error("Catalog-only next catalog is missing or altered; preserve the journal and remaining artifacts for manual inspection with a newly verified recovery build.");
3432
+ const rawNextValue = JSON.parse(rawNext.toString("utf8"));
3433
+ const nextStored = parseStoredCatalog(rawNextValue);
3434
+ if (nextStored.version !== 2)
3435
+ throw new Error("Catalog-only recovery requires a v2 catalog.");
3436
+ assertModernCatalog(nextStored);
3437
+ assertCanonicalStoredCatalog(rawNextValue, nextStored);
3438
+ const oldPath = path.join(this.lock, "old-catalog");
3439
+ const rawOld = journal.oldCatalog === null ? undefined : await checkedFile(oldPath);
3440
+ const oldStat = journal.oldCatalog === null ? undefined : await statMaybe(oldPath);
3441
+ if (journal.oldCatalog === null ? entries.has("old-catalog") : !rawOld || !oldStat || hash(rawOld) !== journal.oldCatalog) {
3442
+ throw new Error("Catalog-only old catalog is missing or altered.");
3443
+ }
3444
+ const rawOldValue = rawOld ? JSON.parse(rawOld.toString("utf8")) : undefined;
3445
+ const previousStored = rawOldValue === undefined ? null : parseStoredCatalog(rawOldValue);
3446
+ if (previousStored && previousStored.version !== 2)
3447
+ throw new Error("Catalog-only recovery cannot use a legacy v1 catalog.");
3448
+ if (previousStored?.version === 2)
3449
+ assertCanonicalStoredCatalog(rawOldValue, previousStored);
3450
+ const previous = previousStored?.version === 2 ? previousStored : { version: 2, items: [], groups: [] };
3451
+ validateCatalogOnlyTransition(previous, nextStored);
3452
+ const digest = await checkedFile(path.join(this.lock, "journal.digest"));
3453
+ if (!digest || digest.toString("utf8") !== hash(rawJournal))
3454
+ throw new Error("Catalog-only journal digest is missing or altered.");
3455
+ const heldPath = path.join(this.lock, "held-catalog");
3456
+ const heldBytes = await checkedFile(heldPath);
3457
+ const heldStat = await statMaybe(heldPath);
3458
+ if (entries.has("held-catalog") && (journal.oldCatalog === null || !heldBytes || !heldStat || hash(heldBytes) !== journal.oldCatalog ||
3459
+ !oldStat || !sameDirectory({ dev: oldStat.dev, ino: oldStat.ino }, { dev: heldStat.dev, ino: heldStat.ino }))) {
3460
+ throw new Error("Catalog-only held old inode is missing, altered, or foreign; preserving it.");
3461
+ }
3462
+ const rollbackBytes = await checkedFile(path.join(this.lock, "rollback.json"));
3463
+ const rollbackMarker = !!rollbackBytes;
3464
+ if (rollbackMarker && rollbackBytes.toString("utf8") !== journal.token)
3465
+ throw new Error("Catalog-only rollback marker has a foreign transaction nonce.");
3466
+ const displacedPath = path.join(this.lock, "displaced-catalog");
3467
+ const displacedBytes = await checkedFile(displacedPath);
3468
+ const displacedStat = await statMaybe(displacedPath);
3469
+ if (entries.has("displaced-catalog") && (!rollbackMarker || !displacedBytes || !displacedStat || hash(displacedBytes) !== journal.newCatalog ||
3470
+ !sameDirectory({ dev: nextStat.dev, ino: nextStat.ino }, { dev: displacedStat.dev, ino: displacedStat.ino }))) {
3471
+ throw new Error("Catalog-only displaced catalog is not an owned rollback inode.");
3472
+ }
3473
+ const catalogPath = path.join(this.root, "catalog.json");
3474
+ const currentBytes = await checkedFile(catalogPath);
3475
+ const currentStat = await statMaybe(catalogPath);
3476
+ const currentHash = currentBytes ? hash(currentBytes) : null;
3477
+ const currentIsNew = currentHash === journal.newCatalog && !!currentStat &&
3478
+ sameDirectory({ dev: nextStat.dev, ino: nextStat.ino }, { dev: currentStat.dev, ino: currentStat.ino });
3479
+ const currentIsOld = journal.oldCatalog !== null && currentHash === journal.oldCatalog && !!currentStat && !!oldStat &&
3480
+ sameDirectory({ dev: oldStat.dev, ino: oldStat.ino }, { dev: currentStat.dev, ino: currentStat.ino });
3481
+ const catalogInTransit = journal.oldCatalog !== null && currentHash === null && !!heldStat;
3482
+ const initiallyAbsent = journal.oldCatalog === null && currentHash === null;
3483
+ if (!currentIsNew && !currentIsOld && !catalogInTransit && !initiallyAbsent) {
3484
+ throw new Error("Catalog-only current catalog is foreign, altered, or has lost its journal-bound inode.");
3485
+ }
3486
+ if ((currentIsNew || catalogInTransit) && journal.oldCatalog !== null && !heldStat) {
3487
+ throw new Error("Catalog-only recovery lost the original held catalog inode; preserve the journal and remaining artifacts for manual inspection with a newly verified recovery build.");
3488
+ }
3489
+ if (await this.verifyFinalized()) {
3490
+ if (rollback || rollbackMarker || !currentIsNew)
3491
+ throw new Error("Finalized catalog-only transaction cannot be rolled back or resumed from a partial state; preserving the journal.");
3492
+ if (await catalogOnlySourcesTree(this.root) !== journal.stableSourcesTree)
3493
+ throw new Error("Finalized catalog-only source tree changed; preserving it.");
3494
+ await this.markFinalized(await createCatalogOnlyCleanup(this.lock, journal, rawJournal));
3495
+ return;
3496
+ }
3497
+ if (currentIsNew && !rollback && !rollbackMarker) {
3498
+ if (await catalogOnlySourcesTree(this.root) !== journal.stableSourcesTree)
3499
+ throw new Error("Catalog-only source tree changed before commit recovery.");
3500
+ await this.markFinalized(await createCatalogOnlyCleanup(this.lock, journal, rawJournal));
3501
+ return;
3502
+ }
3503
+ if (!rollbackMarker) {
3504
+ await writePrivate(path.join(this.lock, "rollback.json"), this.token);
3505
+ await syncDirectory(this.lock);
3506
+ }
3507
+ if (currentIsNew) {
3508
+ if (entries.has("displaced-catalog"))
3509
+ throw new Error("Unexpected displaced catalog before catalog-only rollback.");
3510
+ await rename(catalogPath, displacedPath);
3511
+ const displaced = await checkedFile(displacedPath);
3512
+ const movedStat = await statMaybe(displacedPath);
3513
+ if (!displaced || hash(displaced) !== journal.newCatalog || !movedStat ||
3514
+ !sameDirectory({ dev: nextStat.dev, ino: nextStat.ino }, { dev: movedStat.dev, ino: movedStat.ino })) {
3515
+ throw new Error("Catalog-only new catalog changed while being displaced; preserving it.");
3516
+ }
3517
+ await this.checkpoint?.("rollback-catalog-held", this.root);
3518
+ }
3519
+ const currentAfterDisplace = await checkedFile(catalogPath);
3520
+ if (journal.oldCatalog === null) {
3521
+ if (currentAfterDisplace)
3522
+ throw new Error("An external catalog appeared during absent-catalog rollback; preserving it.");
3523
+ }
3524
+ else if (currentIsOld) {
3525
+ const currentAfterStat = await statMaybe(catalogPath);
3526
+ if (!currentAfterStat || !oldStat || !sameDirectory({ dev: oldStat.dev, ino: oldStat.ino }, { dev: currentAfterStat.dev, ino: currentAfterStat.ino })) {
3527
+ throw new Error("Original catalog inode changed during catalog-only rollback.");
3528
+ }
3529
+ }
3530
+ else {
3531
+ const heldNow = await checkedFile(heldPath);
3532
+ const heldNowStat = await statMaybe(heldPath);
3533
+ const oldNowStat = await statMaybe(oldPath);
3534
+ if (currentAfterDisplace || !heldNow || hash(heldNow) !== journal.oldCatalog || !heldNowStat || !oldNowStat ||
3535
+ !sameDirectory({ dev: oldNowStat.dev, ino: oldNowStat.ino }, { dev: heldNowStat.dev, ino: heldNowStat.ino })) {
3536
+ throw new Error("Original held catalog inode changed before restoration; preserving it.");
3537
+ }
3538
+ await link(heldPath, catalogPath);
3539
+ }
3540
+ if (await catalogOnlySourcesTree(this.root) !== journal.stableSourcesTree) {
3541
+ throw new Error("Catalog-only source tree changed during rollback; retaining journal and source bytes.");
3542
+ }
3543
+ const restored = await checkedFile(catalogPath);
3544
+ const restoredStat = await statMaybe(catalogPath);
3545
+ if (journal.oldCatalog === null ? !!restored || !!restoredStat : !restored || hash(restored) !== journal.oldCatalog || !restoredStat || !oldStat ||
3546
+ !sameDirectory({ dev: oldStat.dev, ino: oldStat.ino }, { dev: restoredStat.dev, ino: restoredStat.ino })) {
3547
+ throw new Error("Catalog-only rollback did not restore the exact original catalog presence and inode.");
3548
+ }
3549
+ await syncDirectory(this.root);
3550
+ await this.markFinalized(await createCatalogOnlyCleanup(this.lock, journal, rawJournal));
3551
+ await this.checkpoint?.("rollback-finalized", this.root);
3552
+ }
3553
+ async recoverSources(journal, rollback, rawJournal) {
3554
+ const allowedEntries = v2LockEntries(journal);
3555
+ const lockEntries = await readdir(this.lock);
3556
+ if (lockEntries.some((entry) => !allowedEntries.has(entry))) {
3557
+ throw new Error("Unknown files in v2 sources transaction lock; inspect before recovery.");
3558
+ }
3559
+ const rawNext = await checkedFile(path.join(this.lock, "next-catalog"));
3560
+ if (!rawNext || hash(rawNext) !== journal.newCatalog)
3561
+ throw new Error("V2 journal next catalog is missing or altered.");
3562
+ const nextStored = parseStoredCatalog(JSON.parse(rawNext.toString("utf8")));
3563
+ assertModernCatalog(nextStored);
3564
+ const rawOld = journal.oldCatalog === null ? undefined : await checkedFile(path.join(this.lock, "old-catalog"));
3565
+ if (journal.oldCatalog !== null && (!rawOld || hash(rawOld) !== journal.oldCatalog))
3566
+ throw new Error("V2 journal old catalog is missing or altered.");
3567
+ if (journal.oldCatalog === null && await statMaybe(path.join(this.lock, "old-catalog")))
3568
+ throw new Error("Unexpected old catalog for an initially absent v2 catalog.");
3569
+ const previousStored = rawOld ? parseStoredCatalog(JSON.parse(rawOld.toString("utf8"))) : null;
3570
+ if (previousStored && previousStored.version !== 2)
3571
+ throw new Error("V2 source recovery cannot use a legacy v1 catalog.");
3572
+ const previous = previousStored?.version === 2 ? previousStored : null;
3573
+ validateSourcesJournalTargets(journal, previous, nextStored);
3574
+ validateSourcesCatalogTransition(previous, nextStored, journal.operations);
3575
+ if (journal.operations.some((operation) => operation.oldTree === null)) {
3576
+ await this.recoverNewSources(journal, rollback, rawJournal, previous, nextStored, rawOld);
3577
+ return;
3578
+ }
3579
+ const rollbackMarker = await checkedFile(path.join(this.lock, "rollback.json"));
3580
+ if (rollbackMarker) {
3581
+ if (rollbackMarker.toString("utf8") !== this.token)
3582
+ throw new Error("Invalid rollback recovery marker; refusing v2 recovery.");
3583
+ rollback = true;
3584
+ }
3585
+ const currentCatalog = await checkedFile(path.join(this.root, "catalog.json"));
3586
+ const currentHash = currentCatalog ? hash(currentCatalog) : null;
3587
+ const heldPath = path.join(this.lock, "held-catalog");
3588
+ const held = await checkedFile(heldPath);
3589
+ const heldHash = held ? hash(held) : null;
3590
+ if (held && heldHash !== journal.oldCatalog)
3591
+ throw new Error("Original v2 catalog changed in its held inode; refusing recovery.");
3592
+ const catalogInTransit = currentHash === null && journal.oldCatalog !== null && heldHash === journal.oldCatalog;
3593
+ if (currentHash !== journal.oldCatalog && currentHash !== journal.newCatalog && !catalogInTransit)
3594
+ throw new Error("V2 catalog changed externally; refusing recovery.");
3595
+ const committed = currentHash === journal.newCatalog;
3596
+ if (committed && journal.oldCatalog !== null && heldHash !== journal.oldCatalog)
3597
+ throw new Error("Original v2 held catalog is missing; refusing recovery.");
3598
+ const displacedCatalog = await checkedFile(path.join(this.lock, "displaced-catalog"));
3599
+ if (displacedCatalog && hash(displacedCatalog) !== journal.newCatalog)
3600
+ throw new Error("Displaced v2 catalog was changed externally.");
3601
+ if (committed && (hash(currentCatalog) !== journal.newCatalog || !rawNext.equals(currentCatalog)))
3602
+ throw new Error("Current v2 catalog does not match its staged next catalog.");
3603
+ const targets = new Set(journal.operations.map((operation) => operation.target.slice("sources/".length)));
3604
+ const sourcesRoot = path.join(this.root, "sources");
3605
+ const stableNow = await tree(sourcesRoot, undefined, true, targets);
3606
+ if (stableNow === null)
3607
+ throw new Error("V2 sources root disappeared during recovery.");
3608
+ const stableMatches = stableNow === journal.stableSourcesTree;
3609
+ const states = [];
3610
+ for (const [index, operation] of journal.operations.entries()) {
3611
+ const target = path.join(this.root, ...operation.target.split("/"));
3612
+ if (operation.kind === "file")
3613
+ await checkedSourceFileTarget(this.root, operation.target, true);
3614
+ else
3615
+ await checkedSourceTreeTarget(this.root, operation.target, true);
3616
+ const current = await sourceArtifactTreeIfPresent(target, operation.kind);
3617
+ const backup = await sourceArtifactTreeIfPresent(path.join(this.lock, `old-${index}`), operation.kind);
3618
+ const stage = await sourceArtifactTreeIfPresent(path.join(this.lock, `next-${index}`), operation.kind);
3619
+ const displaced = await sourceArtifactTreeIfPresent(path.join(this.lock, `displaced-${index}`), operation.kind);
3620
+ if (current !== operation.oldTree && current !== operation.newTree && !(current === null && backup === operation.oldTree && (stage === operation.newTree || displaced === operation.newTree)))
3621
+ throw new Error("V2 registered sources bytes or inode changed externally; refusing recovery.");
3622
+ if (displaced !== null && displaced !== operation.newTree)
3623
+ throw new Error("V2 displaced sources inode was changed externally.");
3624
+ if (backup !== null && backup !== operation.oldTree)
3625
+ throw new Error("V2 original sources inode is missing or altered.");
3626
+ if (stage !== null && stage !== operation.newTree)
3627
+ throw new Error("V2 private stage is altered or unsafe.");
3628
+ if (Number(current === operation.oldTree) + Number(backup === operation.oldTree) !== 1)
3629
+ throw new Error("V2 recovery does not have exactly one checked original sources inode.");
3630
+ if (Number(current === operation.newTree) + Number(stage === operation.newTree) + Number(displaced === operation.newTree) !== 1)
3631
+ throw new Error("V2 recovery does not have exactly one checked replacement inode.");
3632
+ if (committed && !rollback && current !== operation.newTree)
3633
+ throw new Error(`Committed v2 sources ${operation.kind} is missing or altered.`);
3634
+ if ((!committed || rollback) && current !== operation.oldTree && backup !== operation.oldTree)
3635
+ throw new Error("V2 rollback bytes are missing.");
3636
+ const nextItem = nextStored.items.find((item) => item.id === operation.itemId);
3637
+ const newPath = current === operation.newTree ? target : stage === operation.newTree ? path.join(this.lock, `next-${index}`) : path.join(this.lock, `displaced-${index}`);
3638
+ if (operation.kind === "file") {
3639
+ const nextFile = nextItem.files[0];
3640
+ if (!nextFile || hash(await readFile(newPath)) !== nextFile.sha256)
3641
+ throw new Error("V2 replacement bytes do not match the new catalog descriptor.");
3642
+ }
3643
+ else
3644
+ await assertTreeMatchesDescriptors(newPath, nextItem.files);
3645
+ states.push({ operation, current, backup, stage, displaced, target });
3646
+ }
3647
+ if (await this.verifyFinalized()) {
3648
+ if (!committed || !stableMatches || states.some((state) => state.current !== state.operation.newTree))
3649
+ throw new Error("Finalized v2 transaction no longer matches its checked source tree.");
3650
+ await this.markFinalized(await createFinalizedCleanup(this.lock, journal, rawJournal));
3651
+ await rm(path.join(this.lock, "journal.json"));
3652
+ return;
3653
+ }
3654
+ if (committed && !rollback) {
3655
+ if (!stableMatches)
3656
+ throw new Error("Unregistered or adjacent sources changed; refusing to finalize v2 commit.");
3657
+ await this.markFinalized(await createFinalizedCleanup(this.lock, journal, rawJournal));
3658
+ await rm(path.join(this.lock, "journal.json"));
3659
+ return;
3660
+ }
3661
+ if (journal.oldCatalog === null)
3662
+ throw new Error("V2 file replacement rollback requires its original catalog.");
3663
+ if (!rollbackMarker) {
3664
+ await writePrivate(path.join(this.lock, "rollback.json"), this.token);
3665
+ await syncDirectory(this.lock);
3666
+ }
3667
+ await this.checkpoint?.("rollback-started", this.root);
3668
+ for (const [index, state] of states.entries()) {
3669
+ const { operation, target } = state;
3670
+ await this.assertLease(false);
3671
+ const current = await sourceArtifactTreeIfPresent(target, operation.kind);
3672
+ if (current === operation.oldTree)
3673
+ continue;
3674
+ if (current !== null) {
3675
+ const displacedPath = path.join(this.lock, `displaced-${index}`);
3676
+ if (current !== operation.newTree || await statMaybe(displacedPath))
3677
+ throw new Error("V2 live sources target changed during rollback; preserving the inode.");
3678
+ await rename(target, displacedPath);
3679
+ if (await sourceArtifactTreeIfPresent(displacedPath, operation.kind) !== operation.newTree)
3680
+ throw new Error("V2 replacement inode changed while being displaced; preserving it.");
3681
+ await this.checkpoint?.("rollback-displaced", this.root);
3682
+ }
3683
+ await this.assertLease(false);
3684
+ if (await statMaybe(target))
3685
+ throw new Error(`An external ${operation.kind} appeared at the v2 sources target during rollback.`);
3686
+ const original = path.join(this.lock, `old-${index}`);
3687
+ if (await sourceArtifactTreeIfPresent(original, operation.kind) !== operation.oldTree)
3688
+ throw new Error("V2 original sources inode changed before restoration; preserving it.");
3689
+ await rename(original, target);
3690
+ await syncDirectory(path.dirname(target));
3691
+ await this.checkpoint?.("rollback-restored", this.root);
3692
+ await this.assertLease(false);
3693
+ }
3694
+ if (committed || catalogInTransit) {
3695
+ const catalogPath = path.join(this.root, "catalog.json");
3696
+ if (committed) {
3697
+ const displacedPath = path.join(this.lock, "displaced-catalog");
3698
+ if (await statMaybe(displacedPath))
3699
+ throw new Error("Unexpected displaced catalog during v2 rollback.");
3700
+ await rename(catalogPath, displacedPath);
3701
+ if ((await checkedFile(displacedPath) && hash((await checkedFile(displacedPath)))) !== journal.newCatalog)
3702
+ throw new Error("V2 displaced catalog changed during rollback; preserving it.");
3703
+ await this.checkpoint?.("rollback-catalog-held", this.root);
3704
+ }
3705
+ if (rawOld) {
3706
+ await this.assertLease(false);
3707
+ const checkedHeld = await checkedFile(heldPath);
3708
+ if (!checkedHeld || hash(checkedHeld) !== journal.oldCatalog)
3709
+ throw new Error("V2 original held catalog is missing or altered.");
3710
+ if (await statMaybe(catalogPath))
3711
+ throw new Error("An external catalog appeared during v2 rollback.");
3712
+ await link(heldPath, catalogPath);
3713
+ }
3714
+ }
3715
+ for (const [index, operation] of journal.operations.entries()) {
3716
+ const current = operation.kind === "file"
3717
+ ? await checkedSourceFileTarget(this.root, operation.target)
3718
+ : await checkedSourceTreeTarget(this.root, operation.target);
3719
+ if (!current || await sourceArtifactTreeIfPresent(current, operation.kind) !== operation.oldTree)
3720
+ throw new Error("V2 rollback did not restore the checked original inode.");
3721
+ for (const [prefix, expectedTree] of [["next", operation.newTree], ["displaced", operation.newTree], ["old", operation.oldTree]]) {
3722
+ const leftover = await sourceArtifactTreeIfPresent(path.join(this.lock, `${prefix}-${index}`), operation.kind);
3723
+ if (leftover !== null && leftover !== expectedTree)
3724
+ throw new Error("V2 retained source stage or backup changed during recovery.");
3725
+ }
3726
+ }
3727
+ if (stableNow !== await tree(sourcesRoot, undefined, true, targets))
3728
+ throw new Error("Adjacent or unregistered sources changed during v2 rollback; retaining the journal.");
3729
+ if (!stableMatches)
3730
+ throw new Error("Adjacent or unregistered sources changed during v2 transaction; checked targets rolled back, journal retained.");
3731
+ const restoredCatalog = await checkedFile(path.join(this.root, "catalog.json"));
3732
+ if (!restoredCatalog || hash(restoredCatalog) !== journal.oldCatalog)
3733
+ throw new Error("V2 rollback failed to restore the exact original catalog.");
3734
+ await syncDirectory(this.root);
3735
+ await this.markFinalized(await createFinalizedCleanup(this.lock, journal, rawJournal));
3736
+ await rm(path.join(this.lock, "journal.json"));
3737
+ await this.checkpoint?.("rollback-finalized", this.root);
3738
+ }
3739
+ async #recoverLegacyMigration(journal, rollback) {
3740
+ await this.assertLease(false);
3741
+ const journalPath = path.join(this.lock, "journal.json");
3742
+ const raw = await checkedFile(journalPath);
3743
+ const digestBytes = await checkedFile(path.join(this.lock, "journal.digest"));
3744
+ if (!raw || !digestBytes || digestBytes.toString("utf8") !== hash(raw))
3745
+ throw new Error("Legacy migration journal integrity is missing or altered; preserving all bytes.");
3746
+ const parsed = parseStorageJournal(JSON.parse(raw.toString("utf8")), { root: this.root, token: this.token });
3747
+ if ((parsed.version !== 4 && parsed.version !== 5) || JSON.stringify(parsed) !== JSON.stringify(journal) ||
3748
+ (parsed.version === 5 && raw.toString("utf8") !== canonicalStorageJournalJson(parsed))) {
3749
+ throw new Error("Legacy migration journal changed dispatch, canonical encoding, or fixed commitments.");
3750
+ }
3751
+ const state = await inspectLegacyMigrationJournal(this.root, this.lock, this.token, journal, sessionLeases.get(this)?.recoveryClaim !== undefined);
3752
+ const marker = await checkedFile(path.join(this.lock, "rollback.json"));
3753
+ if (marker)
3754
+ rollback = true;
3755
+ const catalogPath = path.join(this.root, "catalog.json");
3756
+ if (state.currentHash === journal.oldCatalog)
3757
+ return;
3758
+ if (state.currentHash === journal.newCatalog && !rollback)
3759
+ return;
3760
+ if (state.currentHash === journal.newCatalog) {
3761
+ if (await statMaybe(path.join(this.lock, "displaced-catalog")))
3762
+ throw new Error("V4 rollback found an unexpected displaced catalog; preserving all bytes.");
3763
+ if (!marker) {
3764
+ await writePrivate(path.join(this.lock, "rollback.json"), this.token);
3765
+ await syncDirectory(this.lock);
3766
+ }
3767
+ await this.checkpoint?.("rollback-started", this.root);
3768
+ await this.assertLease(false);
3769
+ const current = await checkedFile(catalogPath);
3770
+ const nextStage = await checkedFile(path.join(this.lock, "next-catalog"));
3771
+ const currentStat = await statMaybe(catalogPath);
3772
+ const nextStat = await statMaybe(path.join(this.lock, "next-catalog"));
3773
+ if (!current?.equals(state.nextBytes) || !nextStage?.equals(state.nextBytes) || !currentStat || !nextStat ||
3774
+ !sameDirectory({ dev: currentStat.dev, ino: currentStat.ino }, { dev: nextStat.dev, ino: nextStat.ino })) {
3775
+ throw new Error("V4 new catalog inode changed before rollback; preserving it.");
3776
+ }
3777
+ await rename(catalogPath, path.join(this.lock, "displaced-catalog"));
3778
+ await syncDirectory(this.root);
3779
+ await this.checkpoint?.("rollback-catalog-held", this.root);
3780
+ await this.assertLease(false);
3781
+ }
3782
+ if (await statMaybe(catalogPath))
3783
+ throw new Error("A foreign catalog appeared during v4 recovery; preserving it.");
3784
+ const heldPath = path.join(this.lock, "held-catalog");
3785
+ const held = await checkedFile(heldPath);
3786
+ const oldStage = await checkedFile(path.join(this.lock, "old-catalog"));
3787
+ const restoreFrom = held ? heldPath : path.join(this.lock, "old-catalog");
3788
+ if ((!held || !held.equals(state.oldBytes)) && (!oldStage || !oldStage.equals(state.oldBytes))) {
3789
+ throw new Error("V4 original catalog bytes are unavailable for safe rollback; preserving the backup and journal.");
3790
+ }
3791
+ await link(restoreFrom, catalogPath);
3792
+ await syncDirectory(this.root);
3793
+ await this.checkpoint?.("rollback-restored", this.root);
3794
+ await this.assertLease(false);
3795
+ const restored = await checkedFile(catalogPath);
3796
+ const restoredStat = await statMaybe(catalogPath);
3797
+ const sourceStat = await statMaybe(restoreFrom);
3798
+ if (!restored?.equals(state.oldBytes) || !restoredStat || !sourceStat ||
3799
+ !sameDirectory({ dev: restoredStat.dev, ino: restoredStat.ino }, { dev: sourceStat.dev, ino: sourceStat.ino })) {
3800
+ throw new Error("V4 recovery did not restore the exact original catalog inode.");
3801
+ }
3802
+ await this.checkpoint?.("rollback-finalized", this.root);
3803
+ }
3804
+ async recover(rollback) {
3805
+ await this.assertLease(false);
3806
+ const raw = await checkedFile(path.join(this.lock, "journal.json"));
3807
+ if (!raw) {
3808
+ if (await this.verifyFinalized())
3809
+ return;
3810
+ const entries = await readdir(this.lock);
3811
+ if (entries.some((entry) => entry !== "owner.json"))
3812
+ throw new Error("Unjournaled files in dead copi transaction lock; inspect before recovery.");
3813
+ return; // Without a journal or finalization custody record, retain every staged byte for inspection.
3814
+ }
3815
+ const digest = await checkedFile(path.join(this.lock, "journal.digest"));
3816
+ if (!digest || digest.toString("utf8") !== hash(raw))
3817
+ throw new Error("Transaction journal is missing integrity information or was altered; refusing recovery.");
3818
+ const parsed = parseStorageJournal(JSON.parse(raw.toString("utf8")), { root: this.root, token: this.token });
3819
+ if (parsed.version === 3) {
3820
+ await this.recoverCatalogOnly(parsed, rollback, raw);
3821
+ return;
3822
+ }
3823
+ if (parsed.version === 2) {
3824
+ await this.recoverSources(parsed, rollback, raw);
3825
+ return;
3826
+ }
3827
+ if (parsed.version === 4 || parsed.version === 5) {
3828
+ await this.#recoverLegacyMigration(parsed, rollback);
3829
+ return;
3830
+ }
3831
+ const journal = parsed;
3832
+ await this.assertLease(true);
3833
+ if (await this.verifyFinalized()) {
3834
+ await rm(path.join(this.lock, "journal.json"));
3835
+ return;
3836
+ }
3837
+ const rollbackMarker = await checkedFile(path.join(this.lock, "rollback.json"));
3838
+ if (rollbackMarker) {
3839
+ if (rollbackMarker.toString("utf8") !== this.token)
3840
+ throw new Error("Invalid rollback recovery marker; refusing recovery.");
3841
+ rollback = true;
3842
+ }
3843
+ const catalog = await checkedFile(path.join(this.root, "catalog.json"));
3844
+ const currentHash = catalog ? hash(catalog) : null;
3845
+ const heldPath = path.join(this.lock, "held-catalog");
3846
+ const held = await checkedFile(heldPath);
3847
+ const heldHash = held ? hash(held) : null;
3848
+ if (held && heldHash !== journal.oldCatalog)
3849
+ throw new Error("Original catalog was changed externally in its held inode; refusing rollback or deletion.");
3850
+ const catalogInTransit = currentHash === null && journal.oldCatalog !== null && heldHash === journal.oldCatalog;
3851
+ if (currentHash !== journal.oldCatalog && currentHash !== journal.newCatalog && !catalogInTransit)
3852
+ throw new Error("Catalog changed externally; refusing rollback.");
3853
+ const committed = currentHash === journal.newCatalog;
3854
+ if (committed && journal.oldCatalog !== null && heldHash !== journal.oldCatalog)
3855
+ throw new Error("Original held catalog is missing; refusing recovery.");
3856
+ const displacedCatalog = await checkedFile(path.join(this.lock, "displaced-catalog"));
3857
+ if (displacedCatalog && hash(displacedCatalog) !== journal.newCatalog)
3858
+ throw new Error("Displaced catalog was changed externally; refusing deletion.");
3859
+ // Validate *all* targets/backups first, before modifying anything during recovery.
3860
+ for (const [index, operation] of journal.operations.entries()) {
3861
+ const current = await tree(path.join(this.root, "items", operation.itemId));
3862
+ const backup = await tree(path.join(this.lock, `old-${index}`));
3863
+ const stage = await tree(path.join(this.lock, `next-${index}`));
3864
+ const displaced = await tree(path.join(this.lock, `displaced-${index}`));
3865
+ if (current !== operation.oldTree && current !== operation.newTree && !(current === null && backup === operation.oldTree && (stage === operation.newTree || displaced === operation.newTree)))
3866
+ throw new Error("Saved bytes changed externally; refusing rollback.");
3867
+ if (displaced !== null && displaced !== operation.newTree)
3868
+ throw new Error("Displaced snapshot bytes were changed externally.");
3869
+ if (backup !== null && backup !== operation.oldTree)
3870
+ throw new Error("Rollback bytes are missing or altered.");
3871
+ if (stage !== null && stage !== operation.newTree)
3872
+ throw new Error("Staged bytes are altered.");
3873
+ if (committed && !rollback && current !== operation.newTree)
3874
+ throw new Error("Committed snapshot bytes are missing or altered.");
3875
+ if ((!committed || rollback) && operation.oldTree !== null && current !== operation.oldTree && backup !== operation.oldTree)
3876
+ throw new Error("Rollback bytes are missing.");
3877
+ }
3878
+ if (committed && !rollback) {
3879
+ await this.markFinalized();
3880
+ await rm(path.join(this.lock, "journal.json"));
3881
+ return;
3882
+ }
3883
+ const oldCatalog = await checkedFile(path.join(this.lock, "old-catalog"));
3884
+ if ((oldCatalog ? hash(oldCatalog) : null) !== journal.oldCatalog)
3885
+ throw new Error("Rollback catalog is missing or altered.");
3886
+ if (!rollbackMarker) {
3887
+ await writePrivate(path.join(this.lock, "rollback.json"), this.token);
3888
+ await syncDirectory(this.lock);
3889
+ }
3890
+ await this.checkpoint?.("rollback-started", this.root);
3891
+ for (const [index, operation] of journal.operations.entries()) {
3892
+ await this.assertLease();
3893
+ const target = path.join(this.root, "items", operation.itemId);
3894
+ const current = await tree(target);
3895
+ if (current === operation.oldTree)
3896
+ continue;
3897
+ if (current !== null) {
3898
+ // Keep recovery itself restartable; never recursively delete a live snapshot.
3899
+ await rename(target, path.join(this.lock, `displaced-${index}`));
3900
+ if (await tree(path.join(this.lock, `displaced-${index}`)) !== operation.newTree)
3901
+ throw new Error("Snapshot changed externally during rollback; retained bytes instead of deleting them.");
3902
+ await this.checkpoint?.("rollback-displaced", this.root);
3903
+ }
3904
+ await this.assertLease();
3905
+ if (await statMaybe(target))
3906
+ throw new Error("An external snapshot appeared during rollback; refusing overwrite.");
3907
+ if (operation.oldTree !== null) {
3908
+ const original = path.join(this.lock, `old-${index}`);
3909
+ if (await tree(original) !== operation.oldTree)
3910
+ throw new Error("Original rollback bytes changed externally; retained them instead of restoring unchecked data.");
3911
+ await rename(original, target);
3912
+ }
3913
+ await this.checkpoint?.("rollback-restored", this.root);
3914
+ await this.assertLease();
3915
+ }
3916
+ await this.assertLease();
3917
+ if (committed) {
3918
+ const destination = path.join(this.root, "catalog.json");
3919
+ await rename(destination, path.join(this.lock, "displaced-catalog"));
3920
+ const displaced = await checkedFile(path.join(this.lock, "displaced-catalog"));
3921
+ if (!displaced || hash(displaced) !== journal.newCatalog)
3922
+ throw new Error("Catalog changed externally during rollback; retained bytes instead of deleting them.");
3923
+ await this.checkpoint?.("rollback-catalog-held", this.root);
3924
+ }
3925
+ if (committed || catalogInTransit) {
3926
+ if (oldCatalog) {
3927
+ await this.assertLease();
3928
+ const original = await checkedFile(heldPath);
3929
+ if (!original || hash(original) !== journal.oldCatalog)
3930
+ throw new Error("Original held catalog is missing or altered.");
3931
+ await link(heldPath, path.join(this.root, "catalog.json"));
3932
+ }
3933
+ }
3934
+ // Late writes to displaced/backup inodes must not be discarded by cleanup.
3935
+ for (const [index, operation] of journal.operations.entries()) {
3936
+ for (const [prefix, expectedTree] of [["old", operation.oldTree], ["next", operation.newTree], ["displaced", operation.newTree]]) {
3937
+ const retained = await tree(path.join(this.lock, `${prefix}-${index}`));
3938
+ if (retained !== null && retained !== expectedTree)
3939
+ throw new Error("Retained snapshot inode changed externally during recovery; refusing deletion.");
3940
+ }
3941
+ }
3942
+ const retainedCatalog = await checkedFile(path.join(this.lock, "displaced-catalog"));
3943
+ if (retainedCatalog && hash(retainedCatalog) !== journal.newCatalog)
3944
+ throw new Error("Retained catalog inode changed externally during recovery; refusing deletion.");
3945
+ await syncDirectory(this.root);
3946
+ await this.markFinalized();
3947
+ await rm(path.join(this.lock, "journal.json"));
3948
+ await this.checkpoint?.("rollback-finalized", this.root);
3949
+ }
3950
+ }
3951
+ export const storageTransactions = new StorageTransactions();