@papillon6814/copi 0.1.0 → 0.2.1

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 +12 -3
  52. package/dist/src/wizard.js +287 -19
  53. package/package.json +5 -3
@@ -0,0 +1,22 @@
1
+ export interface MigrationBackupInputEntry {
2
+ readonly originalPath: string;
3
+ readonly sha256: string;
4
+ readonly mode: number;
5
+ }
6
+ export interface MigrationBackupManifestEntry extends MigrationBackupInputEntry {
7
+ readonly backupPath: string;
8
+ }
9
+ export interface MigrationBackupManifest {
10
+ readonly version: 1;
11
+ readonly entries: readonly MigrationBackupManifestEntry[];
12
+ }
13
+ export interface MigrationBackupExpected {
14
+ readonly libraryRoot: string;
15
+ readonly backupDir: string;
16
+ readonly manifestBytes: Buffer;
17
+ readonly manifestSha256: string;
18
+ readonly entries: readonly MigrationBackupInputEntry[];
19
+ }
20
+ export declare function buildMigrationBackupManifest(entries: readonly MigrationBackupInputEntry[]): Buffer;
21
+ export declare function parseMigrationBackupManifest(bytes: Buffer): MigrationBackupManifest;
22
+ export declare function verifyMigrationBackup(expectedValue: MigrationBackupExpected): Promise<void>;
@@ -0,0 +1,403 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { types as utilTypes } from "node:util";
4
+ import { lstat, open, readdir, realpath } from "node:fs/promises";
5
+ import path from "node:path";
6
+ const sha256Pattern = /^[0-9a-f]{64}$/;
7
+ const backupUuidPattern = /^\.migration-backups\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
8
+ const manifestName = "manifest.json";
9
+ const isNativeProxy = utilTypes.isProxy;
10
+ const privateDirectoryMode = 0o700;
11
+ const privateFileMode = 0o600;
12
+ function digest(bytes) {
13
+ return createHash("sha256").update(bytes).digest("hex");
14
+ }
15
+ function isProxyObject(value) {
16
+ return ((typeof value === "object" && value !== null) || typeof value === "function") && isNativeProxy(value);
17
+ }
18
+ function isRecord(value) {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ }
21
+ function hasExactKeys(value, keys) {
22
+ const actual = Object.keys(value);
23
+ return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
24
+ }
25
+ function compareStrings(left, right) {
26
+ return left < right ? -1 : left > right ? 1 : 0;
27
+ }
28
+ function snapshotOwnDataRecord(value, expectedKeys) {
29
+ if (typeof value !== "object" || value === null || isProxyObject(value))
30
+ return undefined;
31
+ try {
32
+ if (Array.isArray(value))
33
+ return undefined;
34
+ const ownKeys = Reflect.ownKeys(value);
35
+ if (ownKeys.length !== expectedKeys.length || ownKeys.some((key) => typeof key !== "string" || !expectedKeys.includes(key))) {
36
+ return undefined;
37
+ }
38
+ const snapshot = Object.create(null);
39
+ for (const key of expectedKeys) {
40
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
41
+ if (!descriptor || !Object.hasOwn(descriptor, "value") || descriptor.enumerable !== true)
42
+ return undefined;
43
+ snapshot[key] = descriptor.value;
44
+ }
45
+ return snapshot;
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ function snapshotDenseOwnDataArray(value) {
52
+ try {
53
+ if (isProxyObject(value) || !Array.isArray(value))
54
+ return undefined;
55
+ const ownKeys = Reflect.ownKeys(value);
56
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length");
57
+ if (!lengthDescriptor || !Object.hasOwn(lengthDescriptor, "value") || lengthDescriptor.enumerable !== false ||
58
+ typeof lengthDescriptor.value !== "number" || !Number.isSafeInteger(lengthDescriptor.value) || lengthDescriptor.value < 0 ||
59
+ ownKeys.length !== lengthDescriptor.value + 1) {
60
+ return undefined;
61
+ }
62
+ const length = lengthDescriptor.value;
63
+ const ownKeySet = new Set();
64
+ for (const key of ownKeys) {
65
+ if (typeof key !== "string" ||
66
+ (key !== "length" && (!Number.isSafeInteger(Number(key)) || Number(key) < 0 || Number(key) >= length || String(Number(key)) !== key))) {
67
+ return undefined;
68
+ }
69
+ ownKeySet.add(key);
70
+ }
71
+ if (!ownKeySet.has("length"))
72
+ return undefined;
73
+ const snapshot = [];
74
+ for (let index = 0; index < length; index += 1) {
75
+ const key = String(index);
76
+ if (!ownKeySet.has(key))
77
+ return undefined;
78
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
79
+ if (!descriptor || !Object.hasOwn(descriptor, "value") || descriptor.enumerable !== true)
80
+ return undefined;
81
+ snapshot.push(descriptor.value);
82
+ }
83
+ return snapshot;
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ function safeOriginalPath(value) {
90
+ if (typeof value !== "string" || value.length === 0 || value.includes("\\") || value.includes("\0") ||
91
+ path.posix.isAbsolute(value) || path.posix.normalize(value) !== value)
92
+ return false;
93
+ const parts = value.split("/");
94
+ if (parts.some((part) => part.length === 0 || part === "." || part === ".."))
95
+ return false;
96
+ return value === "catalog.json" || (parts.length > 1 && (parts[0] === "items" || parts[0] === "sources"));
97
+ }
98
+ function normalizeInputEntries(value) {
99
+ const inputEntries = snapshotDenseOwnDataArray(value);
100
+ if (!inputEntries)
101
+ throw new Error("Migration backup entries must be a dense own-data array; Proxy arrays are not accepted.");
102
+ const entries = inputEntries.map((entry) => {
103
+ const fields = snapshotOwnDataRecord(entry, ["originalPath", "sha256", "mode"]);
104
+ if (!fields || !safeOriginalPath(fields.originalPath) || typeof fields.sha256 !== "string" || !sha256Pattern.test(fields.sha256) ||
105
+ typeof fields.mode !== "number" || !Number.isInteger(fields.mode) || fields.mode < 0 || fields.mode > 0o777) {
106
+ throw new Error("Invalid path, SHA-256 digest, mode, Proxy, or own-data fields in migration backup entry.");
107
+ }
108
+ return {
109
+ originalPath: fields.originalPath,
110
+ sha256: fields.sha256,
111
+ mode: fields.mode,
112
+ };
113
+ });
114
+ entries.sort((left, right) => compareStrings(left.originalPath, right.originalPath));
115
+ if (!entries.some((entry) => entry.originalPath === "catalog.json")) {
116
+ throw new Error("Migration backup manifest must cover catalog.json.");
117
+ }
118
+ for (let index = 1; index < entries.length; index += 1) {
119
+ const previous = entries[index - 1].originalPath;
120
+ const current = entries[index].originalPath;
121
+ if (previous === current || current.startsWith(`${previous}/`)) {
122
+ throw new Error("Duplicate or overlapping paths in migration backup manifest.");
123
+ }
124
+ }
125
+ return entries;
126
+ }
127
+ function serializeManifest(entries) {
128
+ const manifest = {
129
+ version: 1,
130
+ entries: entries.map((entry) => ({
131
+ originalPath: entry.originalPath,
132
+ backupPath: `files/${entry.originalPath}`,
133
+ sha256: entry.sha256,
134
+ mode: entry.mode,
135
+ })),
136
+ };
137
+ return Buffer.from(`${JSON.stringify(manifest)}\n`, "utf8");
138
+ }
139
+ export function buildMigrationBackupManifest(entries) {
140
+ return serializeManifest(normalizeInputEntries(entries));
141
+ }
142
+ function parseManifestObject(value) {
143
+ if (!isRecord(value) || !hasExactKeys(value, ["version", "entries"]) || value.version !== 1 || !Array.isArray(value.entries)) {
144
+ throw new Error("Invalid migration backup manifest envelope.");
145
+ }
146
+ const entries = [];
147
+ let previousPath;
148
+ for (const valueEntry of value.entries) {
149
+ if (!isRecord(valueEntry) || !hasExactKeys(valueEntry, ["originalPath", "backupPath", "sha256", "mode"]) ||
150
+ !safeOriginalPath(valueEntry.originalPath) || valueEntry.backupPath !== `files/${valueEntry.originalPath}` ||
151
+ typeof valueEntry.sha256 !== "string" || !sha256Pattern.test(valueEntry.sha256) ||
152
+ typeof valueEntry.mode !== "number" || !Number.isInteger(valueEntry.mode) || valueEntry.mode < 0 || valueEntry.mode > 0o777) {
153
+ throw new Error("Invalid path, backup mapping, SHA-256 digest, mode, or fields in migration backup manifest entry.");
154
+ }
155
+ if (previousPath !== undefined && compareStrings(previousPath, valueEntry.originalPath) >= 0) {
156
+ throw new Error("Migration backup manifest entries are not in strict canonical order.");
157
+ }
158
+ previousPath = valueEntry.originalPath;
159
+ entries.push({
160
+ originalPath: valueEntry.originalPath,
161
+ backupPath: valueEntry.backupPath,
162
+ sha256: valueEntry.sha256,
163
+ mode: valueEntry.mode,
164
+ });
165
+ }
166
+ const normalized = normalizeInputEntries(entries.map(({ originalPath, sha256, mode }) => ({ originalPath, sha256, mode })));
167
+ if (normalized.length !== entries.length)
168
+ throw new Error("Invalid migration backup manifest coverage.");
169
+ return { version: 1, entries };
170
+ }
171
+ export function parseMigrationBackupManifest(bytes) {
172
+ if (isProxyObject(bytes))
173
+ throw new Error("Proxy-backed migration backup manifest bytes are not accepted.");
174
+ if (!Buffer.isBuffer(bytes))
175
+ throw new Error("Migration backup manifest must be supplied as fixed bytes.");
176
+ let value;
177
+ try {
178
+ value = JSON.parse(bytes.toString("utf8"));
179
+ }
180
+ catch {
181
+ throw new Error("Invalid JSON in migration backup manifest.");
182
+ }
183
+ const manifest = parseManifestObject(value);
184
+ const expectedBytes = serializeManifest(manifest.entries.map(({ originalPath, sha256, mode }) => ({ originalPath, sha256, mode })));
185
+ if (!bytes.equals(expectedBytes))
186
+ throw new Error("Migration backup manifest bytes are not in the fixed canonical encoding.");
187
+ return manifest;
188
+ }
189
+ function isDirectoryPath(value) {
190
+ return path.isAbsolute(value) && path.resolve(value) === value;
191
+ }
192
+ function isWithin(root, target) {
193
+ const relative = path.relative(root, target);
194
+ return relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
195
+ }
196
+ function stableDirectoryState(target, stat) {
197
+ return {
198
+ path: target,
199
+ dev: stat.dev,
200
+ ino: stat.ino,
201
+ mode: stat.mode,
202
+ size: stat.size,
203
+ mtimeNs: stat.mtimeNs,
204
+ ctimeNs: stat.ctimeNs,
205
+ };
206
+ }
207
+ function sameDirectoryState(left, right) {
208
+ return left.path === right.path && left.dev === right.dev && left.ino === right.ino && left.mode === right.mode &&
209
+ left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
210
+ }
211
+ function directoryIdentity(state) {
212
+ return { path: state.path, dev: state.dev, ino: state.ino, mode: state.mode };
213
+ }
214
+ async function canonicalDirectoryChain(root) {
215
+ if (!isDirectoryPath(root))
216
+ throw new Error("Migration backup library root must be an absolute canonical path.");
217
+ const identities = [];
218
+ const rootName = path.parse(root).root;
219
+ let current = rootName;
220
+ const rootStat = await lstat(current, { bigint: true });
221
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || await realpath(current) !== current) {
222
+ throw new Error("Migration backup library root has an unsafe filesystem root.");
223
+ }
224
+ identities.push({ path: current, dev: rootStat.dev, ino: rootStat.ino, mode: rootStat.mode });
225
+ const segments = root.slice(rootName.length).split(path.sep).filter((segment) => segment.length > 0);
226
+ for (const segment of segments) {
227
+ current = path.join(current, segment);
228
+ const stat = await lstat(current, { bigint: true });
229
+ if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(current) !== current) {
230
+ throw new Error("Migration backup library root or an ancestor is not a canonical real directory.");
231
+ }
232
+ identities.push({ path: current, dev: stat.dev, ino: stat.ino, mode: stat.mode });
233
+ }
234
+ return identities;
235
+ }
236
+ async function assertDirectoryIdentities(states) {
237
+ for (const expected of states) {
238
+ const current = await lstat(expected.path, { bigint: true });
239
+ if (!current.isDirectory() || current.isSymbolicLink() || current.dev !== expected.dev || current.ino !== expected.ino ||
240
+ current.mode !== expected.mode || await realpath(expected.path) !== expected.path) {
241
+ throw new Error("Migration backup directory identity or canonical path changed during verification.");
242
+ }
243
+ }
244
+ }
245
+ async function checkedPrivateDirectory(target, libraryRoot, expectedMode) {
246
+ if (!isWithin(libraryRoot, target))
247
+ throw new Error("Migration backup directory escapes its canonical library root.");
248
+ const before = await lstat(target, { bigint: true });
249
+ if (!before.isDirectory() || before.isSymbolicLink() || (before.mode & 4095n) !== BigInt(expectedMode) || await realpath(target) !== target) {
250
+ throw new Error("Migration backup contains a noncanonical directory or one with the wrong private mode.");
251
+ }
252
+ return stableDirectoryState(target, before);
253
+ }
254
+ async function assertDirectoryUnchanged(expected) {
255
+ const [after, canonical] = await Promise.all([lstat(expected.path, { bigint: true }), realpath(expected.path)]);
256
+ if (!after.isDirectory() || after.isSymbolicLink() || canonical !== expected.path ||
257
+ !sameDirectoryState(expected, stableDirectoryState(expected.path, after))) {
258
+ throw new Error("Migration backup directory changed while it was being enumerated.");
259
+ }
260
+ }
261
+ function sameFileState(left, right) {
262
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs &&
263
+ left.ctimeNs === right.ctimeNs && left.mode === right.mode;
264
+ }
265
+ async function readPrivateFile(target, libraryRoot, expectedAncestors) {
266
+ const before = await lstat(target, { bigint: true });
267
+ if (!before.isFile() || before.isSymbolicLink() || (before.mode & 4095n) !== BigInt(privateFileMode) || await realpath(target) !== target ||
268
+ !isWithin(libraryRoot, target)) {
269
+ throw new Error("Migration backup contains a noncanonical, nonregular, or nonprivate file.");
270
+ }
271
+ const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
272
+ try {
273
+ const opened = await handle.stat({ bigint: true });
274
+ if (!opened.isFile() || !sameFileState(before, opened))
275
+ throw new Error("Migration backup file changed while it was opened.");
276
+ const bytes = await handle.readFile();
277
+ const [afterHandle, afterPath, canonicalAfter] = await Promise.all([
278
+ handle.stat({ bigint: true }),
279
+ lstat(target, { bigint: true }),
280
+ realpath(target),
281
+ ]);
282
+ if (!afterPath.isFile() || afterPath.isSymbolicLink() || canonicalAfter !== target ||
283
+ !sameFileState(opened, afterHandle) || !sameFileState(opened, afterPath)) {
284
+ throw new Error("Migration backup file identity, mode, or bytes changed while being read.");
285
+ }
286
+ await assertDirectoryIdentities(expectedAncestors);
287
+ return bytes;
288
+ }
289
+ finally {
290
+ await handle.close();
291
+ }
292
+ }
293
+ function expectedTree(manifest) {
294
+ const children = new Map();
295
+ const ensure = (directory) => {
296
+ let value = children.get(directory);
297
+ if (!value) {
298
+ value = { directories: new Set(), files: new Set() };
299
+ children.set(directory, value);
300
+ }
301
+ return value;
302
+ };
303
+ ensure(".");
304
+ ensure("files");
305
+ ensure(".").files.add(manifestName);
306
+ ensure(".").directories.add("files");
307
+ for (const entry of manifest.entries) {
308
+ const relative = entry.backupPath;
309
+ const parts = relative.split("/");
310
+ let current = "";
311
+ for (const part of parts.slice(0, -1)) {
312
+ const parent = current || ".";
313
+ current = current ? `${current}/${part}` : part;
314
+ ensure(parent).directories.add(part);
315
+ ensure(current);
316
+ }
317
+ ensure(current || ".").files.add(parts.at(-1));
318
+ }
319
+ return children;
320
+ }
321
+ export async function verifyMigrationBackup(expectedValue) {
322
+ const expected = snapshotOwnDataRecord(expectedValue, ["libraryRoot", "backupDir", "manifestBytes", "manifestSha256", "entries"]);
323
+ if (!expected || typeof expected.libraryRoot !== "string" || typeof expected.backupDir !== "string" ||
324
+ isProxyObject(expected.manifestBytes) || !Buffer.isBuffer(expected.manifestBytes) || typeof expected.manifestSha256 !== "string" ||
325
+ !sha256Pattern.test(expected.manifestSha256)) {
326
+ throw new Error("Invalid fixed migration backup verification expectation; Proxy objects and non-own-data fields are not accepted.");
327
+ }
328
+ const libraryRoot = expected.libraryRoot;
329
+ const backupDir = expected.backupDir;
330
+ if (!backupUuidPattern.test(backupDir) || path.posix.normalize(backupDir) !== backupDir) {
331
+ throw new Error("Migration backup directory must be the canonical relative .migration-backups UUID path.");
332
+ }
333
+ const manifestBytes = Buffer.from(expected.manifestBytes);
334
+ const manifestSha256 = expected.manifestSha256;
335
+ const inputEntries = normalizeInputEntries(expected.entries);
336
+ if (digest(manifestBytes) !== manifestSha256)
337
+ throw new Error("Fixed migration backup manifest bytes do not match their expected SHA-256.");
338
+ const expectedManifest = parseMigrationBackupManifest(manifestBytes);
339
+ if (!buildMigrationBackupManifest(inputEntries).equals(manifestBytes) ||
340
+ expectedManifest.entries.length !== inputEntries.length) {
341
+ throw new Error("Fixed migration backup manifest does not exactly cover the expected input entries.");
342
+ }
343
+ for (let index = 0; index < inputEntries.length; index += 1) {
344
+ const input = inputEntries[index];
345
+ const manifestEntry = expectedManifest.entries[index];
346
+ if (input.originalPath !== manifestEntry.originalPath || input.sha256 !== manifestEntry.sha256 || input.mode !== manifestEntry.mode) {
347
+ throw new Error("Fixed migration backup manifest entry differs from the expected input coverage.");
348
+ }
349
+ }
350
+ const rootIdentities = await canonicalDirectoryChain(libraryRoot);
351
+ const backupParent = path.join(libraryRoot, ".migration-backups");
352
+ const backupParentState = await checkedPrivateDirectory(backupParent, libraryRoot, privateDirectoryMode);
353
+ const backupRoot = path.join(libraryRoot, ...backupDir.split("/"));
354
+ const children = expectedTree(expectedManifest);
355
+ const expectedFileEntries = new Map();
356
+ for (const entry of expectedManifest.entries)
357
+ expectedFileEntries.set(entry.backupPath, entry);
358
+ const visit = async (relative, ancestors) => {
359
+ const target = relative === "." ? backupRoot : path.join(backupRoot, ...relative.split("/"));
360
+ const state = await checkedPrivateDirectory(target, libraryRoot, privateDirectoryMode);
361
+ const expectedChildren = children.get(relative);
362
+ if (!expectedChildren)
363
+ throw new Error("Unexpected directory in migration backup.");
364
+ const names = (await readdir(target)).sort(compareStrings);
365
+ const expectedNames = [...expectedChildren.directories, ...expectedChildren.files].sort(compareStrings);
366
+ if (names.length !== expectedNames.length || names.some((name, index) => name !== expectedNames[index])) {
367
+ throw new Error("Migration backup has missing or unknown extra filesystem entries.");
368
+ }
369
+ const currentAncestors = [...ancestors, directoryIdentity(state)];
370
+ for (const name of names) {
371
+ const childRelative = relative === "." ? name : `${relative}/${name}`;
372
+ if (expectedChildren.directories.has(name)) {
373
+ await visit(childRelative, currentAncestors);
374
+ continue;
375
+ }
376
+ const bytes = await readPrivateFile(path.join(target, name), libraryRoot, [...rootIdentities, ...currentAncestors]);
377
+ if (childRelative === manifestName) {
378
+ if (!bytes.equals(manifestBytes) || digest(bytes) !== manifestSha256) {
379
+ throw new Error("Stored migration backup manifest differs from the fixed manifest bytes or hash.");
380
+ }
381
+ const parsed = parseMigrationBackupManifest(bytes);
382
+ if (parsed.entries.length !== expectedManifest.entries.length)
383
+ throw new Error("Stored migration backup manifest coverage changed.");
384
+ continue;
385
+ }
386
+ const manifestEntry = expectedFileEntries.get(childRelative);
387
+ if (!manifestEntry || digest(bytes) !== manifestEntry.sha256) {
388
+ throw new Error("Migration backup data is missing its fixed entry or does not match the expected SHA-256.");
389
+ }
390
+ }
391
+ const finalNames = (await readdir(target)).sort(compareStrings);
392
+ await assertDirectoryUnchanged(state);
393
+ if (names.length !== finalNames.length || names.some((name, index) => name !== finalNames[index])) {
394
+ throw new Error("Migration backup directory entries changed during verification.");
395
+ }
396
+ };
397
+ await visit(".", [
398
+ ...rootIdentities,
399
+ directoryIdentity(backupParentState),
400
+ ]);
401
+ await assertDirectoryIdentities(rootIdentities);
402
+ await assertDirectoryUnchanged(backupParentState);
403
+ }
@@ -0,0 +1,9 @@
1
+ import type { MigrationStartInventory } from "./storage-journal.ts";
2
+ export interface MigrationInventoryScanOptions {
3
+ readonly excludedPrefixes?: readonly string[];
4
+ readonly rootScope?: "library" | "subtree";
5
+ }
6
+ export declare function isManagedMigrationRootName(name: string): boolean;
7
+ export declare function listManagedMigrationRootEntries(root: string): Promise<string[]>;
8
+ export declare function projectMigrationInventory(inventory: MigrationStartInventory, excludedPrefixes: readonly string[], omittedProofPaths?: readonly string[]): MigrationStartInventory;
9
+ export declare function scanMigrationInventory(libraryDir: string, options?: MigrationInventoryScanOptions): Promise<MigrationStartInventory>;
@@ -0,0 +1,237 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, open, readdir, realpath } from "node:fs/promises";
4
+ import path from "node:path";
5
+ const sha256 = (value) => createHash("sha256").update(value).digest("hex");
6
+ const compareUtf8 = (left, right) => Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8"));
7
+ function canonicalJson(value) {
8
+ if (Array.isArray(value))
9
+ return `[${value.map(canonicalJson).join(",")}]`;
10
+ if (value && typeof value === "object") {
11
+ const record = value;
12
+ const keys = Object.keys(record).sort(compareUtf8);
13
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
14
+ }
15
+ const encoded = JSON.stringify(value);
16
+ if (encoded === undefined)
17
+ throw new Error("Migration inventory contains a non-JSON value.");
18
+ return encoded;
19
+ }
20
+ function sameNodeState(left, right) {
21
+ return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.size === right.size &&
22
+ left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
23
+ }
24
+ function fileMode(stat) {
25
+ return Number(stat.mode & 4095n);
26
+ }
27
+ function decodeName(name) {
28
+ let decoded;
29
+ try {
30
+ decoded = new TextDecoder("utf-8", { fatal: true }).decode(name);
31
+ }
32
+ catch {
33
+ throw new Error("Migration inventory encountered a non-UTF-8 filesystem name; refusing to omit it.");
34
+ }
35
+ if (!decoded || decoded === "." || decoded === ".." || decoded.includes("/") || decoded.includes("\\") ||
36
+ decoded.includes("\0") || !Buffer.from(decoded, "utf8").equals(name)) {
37
+ throw new Error("Migration inventory encountered a noncanonical filesystem name.");
38
+ }
39
+ return decoded;
40
+ }
41
+ const managedRootNames = new Set([
42
+ "catalog.json", "items", "sources", ".migration-backups", ".copi-transaction", ".copi-transaction-recovery",
43
+ ]);
44
+ const resolutionPrefixBytes = Buffer.from(".copi-migration-resolution", "ascii");
45
+ function managedRootName(raw) {
46
+ if (!raw.subarray(0, resolutionPrefixBytes.length).equals(resolutionPrefixBytes)) {
47
+ const exact = [...managedRootNames].find((name) => raw.equals(Buffer.from(name, "ascii")));
48
+ if (!exact)
49
+ return undefined;
50
+ }
51
+ return decodeName(raw);
52
+ }
53
+ export function isManagedMigrationRootName(name) {
54
+ if (typeof name !== "string" || !name || name.includes("/") || name.includes("\\") || name.includes("\0"))
55
+ return false;
56
+ if (managedRootNames.has(name) || name.startsWith(".copi-migration-resolution"))
57
+ return true;
58
+ return false;
59
+ }
60
+ export async function listManagedMigrationRootEntries(root) {
61
+ if (typeof root !== "string" || !path.isAbsolute(root) || path.resolve(root) !== root || await realpath(root) !== root) {
62
+ throw new Error("Managed migration root listing requires a canonical absolute library root.");
63
+ }
64
+ const names = await readdir(root, { encoding: "buffer" });
65
+ return names.map(managedRootName).filter((name) => name !== undefined).sort(compareUtf8);
66
+ }
67
+ function sameRootState(left, right) {
68
+ return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode &&
69
+ left.isDirectory() && right.isDirectory() && !left.isSymbolicLink() && !right.isSymbolicLink();
70
+ }
71
+ function selectedNames(rawNames, relative, rootScope, excludedPrefixes) {
72
+ const selected = [];
73
+ for (const raw of rawNames) {
74
+ const name = relative === "." && rootScope === "library" ? managedRootName(raw) : decodeName(raw);
75
+ if (name === undefined)
76
+ continue;
77
+ const childPath = relative === "." ? name : `${relative}/${name}`;
78
+ if (excludedPrefixes.some((prefix) => childPath === prefix || childPath.startsWith(`${prefix}/`)))
79
+ continue;
80
+ selected.push({ raw, name });
81
+ }
82
+ selected.sort((left, right) => Buffer.compare(left.raw, right.raw));
83
+ return selected;
84
+ }
85
+ async function scanOnce(root, excludedPrefixes, rootScope) {
86
+ const rootBefore = await lstat(root, { bigint: true });
87
+ if (!rootBefore.isDirectory() || rootBefore.isSymbolicLink() || await realpath(root) !== root) {
88
+ throw new Error("Migration inventory root must be a canonical real directory.");
89
+ }
90
+ const nodes = new Map();
91
+ const transient = [];
92
+ const visit = async (absolute, relative, expected) => {
93
+ const before = await lstat(absolute, { bigint: true });
94
+ if (before.isSymbolicLink())
95
+ throw new Error(`Migration inventory encountered a symbolic link: ${relative}`);
96
+ if (expected && !(relative === "." ? sameRootState(expected, before) : sameNodeState(expected, before))) {
97
+ throw new Error(`Migration inventory directory changed while scanning: ${relative}`);
98
+ }
99
+ const kind = before.isDirectory() ? "directory" : before.isFile() ? "file" : undefined;
100
+ if (!kind)
101
+ throw new Error(`Migration inventory encountered a special filesystem node: ${relative}`);
102
+ if (await realpath(absolute) !== absolute)
103
+ throw new Error(`Migration inventory path is not canonical: ${relative}`);
104
+ const base = { path: relative, kind, mode: fileMode(before), dev: before.dev.toString(), ino: before.ino.toString() };
105
+ transient.push(relative === "."
106
+ ? `${relative}:${before.dev}:${before.ino}:${before.mode}`
107
+ : `${relative}:${before.size}:${before.mtimeNs}:${before.ctimeNs}`);
108
+ if (kind === "file") {
109
+ const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
110
+ try {
111
+ const opened = await handle.stat({ bigint: true });
112
+ if (!opened.isFile() || !sameNodeState(before, opened))
113
+ throw new Error(`Migration inventory file changed while opening: ${relative}`);
114
+ const bytes = await handle.readFile();
115
+ const [afterHandle, afterPath, canonical] = await Promise.all([
116
+ handle.stat({ bigint: true }), lstat(absolute, { bigint: true }), realpath(absolute),
117
+ ]);
118
+ if (!afterPath.isFile() || afterPath.isSymbolicLink() || canonical !== absolute ||
119
+ !sameNodeState(opened, afterHandle) || !sameNodeState(opened, afterPath)) {
120
+ throw new Error(`Migration inventory file changed while reading: ${relative}`);
121
+ }
122
+ nodes.set(relative, { ...base, bytesHash: sha256(bytes) });
123
+ }
124
+ finally {
125
+ await handle.close();
126
+ }
127
+ return;
128
+ }
129
+ const beforeNames = selectedNames(await readdir(absolute, { encoding: "buffer" }), relative, rootScope, excludedPrefixes);
130
+ if (beforeNames.some((entry, index) => index > 0 && Buffer.compare(beforeNames[index - 1].raw, entry.raw) === 0)) {
131
+ throw new Error(`Migration inventory encountered duplicate directory names: ${relative}`);
132
+ }
133
+ const children = [];
134
+ for (const entry of beforeNames) {
135
+ const childPath = relative === "." ? entry.name : `${relative}/${entry.name}`;
136
+ const childAbsolute = path.join(absolute, entry.name);
137
+ const childStat = await lstat(childAbsolute, { bigint: true });
138
+ if (childStat.isSymbolicLink())
139
+ throw new Error(`Migration inventory encountered a symbolic link: ${childPath}`);
140
+ const childKind = childStat.isDirectory() ? "directory" : childStat.isFile() ? "file" : undefined;
141
+ if (!childKind)
142
+ throw new Error(`Migration inventory encountered a special filesystem node: ${childPath}`);
143
+ children.push({ name: entry.name, kind: childKind });
144
+ await visit(childAbsolute, childPath, childStat);
145
+ }
146
+ const [after, rawAfterNames, canonical] = await Promise.all([
147
+ lstat(absolute, { bigint: true }), readdir(absolute, { encoding: "buffer" }), realpath(absolute),
148
+ ]);
149
+ const afterNames = selectedNames(rawAfterNames, relative, rootScope, excludedPrefixes);
150
+ const directoryStateMatches = relative === "." ? sameRootState(before, after) : sameNodeState(before, after);
151
+ if (!directoryStateMatches || canonical !== absolute || afterNames.length !== beforeNames.length ||
152
+ beforeNames.some((entry, index) => !entry.raw.equals(afterNames[index].raw))) {
153
+ throw new Error(`Migration inventory directory changed while scanning: ${relative}`);
154
+ }
155
+ nodes.set(relative, { ...base, children });
156
+ };
157
+ await visit(root, ".", rootBefore);
158
+ const rootAfter = await lstat(root, { bigint: true });
159
+ if (!sameRootState(rootBefore, rootAfter) || await realpath(root) !== root) {
160
+ throw new Error("Migration library root changed while capturing its inventory.");
161
+ }
162
+ const identities = new Set();
163
+ const inventoryNodes = [];
164
+ for (const node of [...nodes.values()].sort((left, right) => compareUtf8(left.path, right.path))) {
165
+ const identity = `${node.dev}:${node.ino}`;
166
+ if (identities.has(identity))
167
+ throw new Error(`Migration inventory contains an unsafe hardlink alias: ${node.path}`);
168
+ identities.add(identity);
169
+ const bytesHash = node.kind === "file"
170
+ ? node.bytesHash
171
+ : sha256(Buffer.concat([
172
+ Buffer.from("copi-directory-v1\0", "utf8"),
173
+ Buffer.from(canonicalJson((node.children ?? []).map(({ name, kind }) => [name, kind])), "utf8"),
174
+ ]));
175
+ inventoryNodes.push({ path: node.path, kind: node.kind, mode: node.mode, dev: node.dev, ino: node.ino, bytesHash });
176
+ }
177
+ const inventory = {
178
+ version: 1,
179
+ nodes: inventoryNodes,
180
+ hash: sha256(Buffer.concat([
181
+ Buffer.from("copi-inventory-v1\0", "utf8"),
182
+ Buffer.from(canonicalJson(inventoryNodes), "utf8"),
183
+ ])),
184
+ };
185
+ return { inventory, transientState: transient.sort(compareUtf8).join("\n") };
186
+ }
187
+ export function projectMigrationInventory(inventory, excludedPrefixes, omittedProofPaths = []) {
188
+ const excluded = (nodePath) => excludedPrefixes.some((prefix) => nodePath === prefix || nodePath.startsWith(`${prefix}/`));
189
+ const proofOmissions = new Set(omittedProofPaths);
190
+ const allNodes = inventory.nodes.filter((node) => !excluded(node.path));
191
+ const nodes = allNodes.filter((node) => !proofOmissions.has(node.path)).map((node) => {
192
+ if (node.kind !== "directory")
193
+ return { ...node };
194
+ // Omitted proofs are absent from the projected namespace, including the directory child-list commitment.
195
+ const children = allNodes.filter((candidate) => candidate.path !== node.path && !proofOmissions.has(candidate.path) &&
196
+ path.posix.dirname(candidate.path) === node.path)
197
+ .map((candidate) => [path.posix.basename(candidate.path), candidate.kind])
198
+ .sort(([left], [right]) => compareUtf8(left, right));
199
+ return {
200
+ ...node,
201
+ bytesHash: sha256(Buffer.concat([
202
+ Buffer.from("copi-directory-v1\0", "utf8"),
203
+ Buffer.from(canonicalJson(children), "utf8"),
204
+ ])),
205
+ };
206
+ });
207
+ return {
208
+ version: 1,
209
+ nodes,
210
+ hash: sha256(Buffer.concat([
211
+ Buffer.from("copi-inventory-v1\0", "utf8"),
212
+ Buffer.from(canonicalJson(nodes), "utf8"),
213
+ ])),
214
+ };
215
+ }
216
+ export async function scanMigrationInventory(libraryDir, options = {}) {
217
+ if (typeof libraryDir !== "string" || !path.isAbsolute(libraryDir) || path.resolve(libraryDir) !== libraryDir ||
218
+ await realpath(libraryDir) !== libraryDir) {
219
+ throw new Error("Migration inventory requires a canonical absolute library root.");
220
+ }
221
+ const excludedPrefixes = options.excludedPrefixes ?? [];
222
+ const rootScope = options.rootScope ?? "library";
223
+ if (rootScope !== "library" && rootScope !== "subtree")
224
+ throw new Error("Migration inventory root scope must be library or subtree.");
225
+ if (!Array.isArray(excludedPrefixes) || excludedPrefixes.some((prefix) => typeof prefix !== "string" ||
226
+ !prefix || prefix.startsWith("/") || prefix.includes("\\") || prefix.includes("\0") ||
227
+ prefix.split("/").some((part) => part === "" || part === "." || part === ".."))) {
228
+ throw new Error("Migration inventory exclusion prefixes must be canonical library-relative paths.");
229
+ }
230
+ const first = await scanOnce(libraryDir, excludedPrefixes, rootScope);
231
+ const second = await scanOnce(libraryDir, excludedPrefixes, rootScope);
232
+ if (first.inventory.hash !== second.inventory.hash || first.transientState !== second.transientState ||
233
+ canonicalJson(first.inventory.nodes) !== canonicalJson(second.inventory.nodes)) {
234
+ throw new Error("Migration inventory changed between the required complete scans.");
235
+ }
236
+ return structuredClone(first.inventory);
237
+ }