@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.
- package/LICENSE.unicode +39 -0
- package/README.md +57 -5
- package/dist/native/rename-exclusive-arm64 +0 -0
- package/dist/native/rename-exclusive-arm64.manifest.json +12 -0
- package/dist/src/apply.d.ts +9 -0
- package/dist/src/apply.js +110 -9
- package/dist/src/catalog.d.ts +21 -4
- package/dist/src/catalog.js +265 -133
- package/dist/src/cli.d.ts +2 -1
- package/dist/src/cli.js +73 -3
- package/dist/src/groups.js +8 -3
- package/dist/src/inspect.d.ts +3 -1
- package/dist/src/inspect.js +16 -6
- package/dist/src/instruction-reference.d.ts +6 -0
- package/dist/src/instruction-reference.js +21 -0
- package/dist/src/manage.d.ts +2 -0
- package/dist/src/manage.js +329 -0
- package/dist/src/migrate.d.ts +25 -0
- package/dist/src/migrate.js +171 -0
- package/dist/src/migration-backup.d.ts +22 -0
- package/dist/src/migration-backup.js +403 -0
- package/dist/src/migration-inventory.d.ts +9 -0
- package/dist/src/migration-inventory.js +237 -0
- package/dist/src/migration-resolution.d.ts +28 -0
- package/dist/src/migration-resolution.js +1618 -0
- package/dist/src/migration.d.ts +49 -0
- package/dist/src/migration.js +1004 -0
- package/dist/src/names.d.ts +11 -0
- package/dist/src/names.js +40 -0
- package/dist/src/native-placement.d.ts +16 -0
- package/dist/src/native-placement.js +557 -0
- package/dist/src/refresh.d.ts +20 -0
- package/dist/src/refresh.js +143 -0
- package/dist/src/resource-mutations.d.ts +45 -0
- package/dist/src/resource-mutations.js +225 -0
- package/dist/src/source-content.d.ts +4 -0
- package/dist/src/source-content.js +208 -0
- package/dist/src/storage-journal.d.ts +111 -0
- package/dist/src/storage-journal.js +613 -0
- package/dist/src/storage-root-lease.d.ts +7 -0
- package/dist/src/storage-root-lease.js +92 -0
- package/dist/src/storage-transaction.d.ts +100 -0
- package/dist/src/storage-transaction.js +3951 -0
- package/dist/src/terminal-editor.d.ts +8 -0
- package/dist/src/terminal-editor.js +391 -0
- package/dist/src/types.d.ts +18 -2
- package/dist/src/unicode-case-folding-data.d.ts +1 -0
- package/dist/src/unicode-case-folding-data.js +1596 -0
- package/dist/src/unicode-case-folding.d.ts +2 -0
- package/dist/src/unicode-case-folding.js +9 -0
- package/dist/src/wizard.d.ts +12 -3
- package/dist/src/wizard.js +287 -19
- package/package.json +5 -3
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { types as utilTypes } from "node:util";
|
|
4
|
+
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;
|
|
5
|
+
const lowercaseUuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
6
|
+
const sha256Pattern = /^[0-9a-f]{64}$/;
|
|
7
|
+
const excludedNames = new Set([
|
|
8
|
+
".git", ".cache", ".npm", "auth", "node_modules", "cache", "caches", "history", "sessions",
|
|
9
|
+
"auth.json", "mcp-auth.json", "oauth.json",
|
|
10
|
+
]);
|
|
11
|
+
function isRecord(value) {
|
|
12
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
function hasExactKeys(value, keys) {
|
|
15
|
+
const ownKeys = Object.keys(value);
|
|
16
|
+
return ownKeys.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
17
|
+
}
|
|
18
|
+
function isSha256(value) {
|
|
19
|
+
return typeof value === "string" && sha256Pattern.test(value);
|
|
20
|
+
}
|
|
21
|
+
function isOptionalSha256(value) {
|
|
22
|
+
return value === null || isSha256(value);
|
|
23
|
+
}
|
|
24
|
+
function isStableSourcesCommitment(value) {
|
|
25
|
+
return value === "absent" || isSha256(value);
|
|
26
|
+
}
|
|
27
|
+
function isStableItemsCommitment(value) {
|
|
28
|
+
return value === "absent" || isSha256(value);
|
|
29
|
+
}
|
|
30
|
+
function isMigrationBackupDir(value) {
|
|
31
|
+
if (typeof value !== "string" || path.posix.normalize(value) !== value)
|
|
32
|
+
return false;
|
|
33
|
+
const parts = value.split("/");
|
|
34
|
+
return parts.length === 2 && parts[0] === ".migration-backups" &&
|
|
35
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(parts[1]);
|
|
36
|
+
}
|
|
37
|
+
function isUint64Decimal(value) {
|
|
38
|
+
if (typeof value !== "string" || !/^(?:0|[1-9][0-9]*)$/.test(value))
|
|
39
|
+
return false;
|
|
40
|
+
try {
|
|
41
|
+
return BigInt(value) <= 18446744073709551615n;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function isSafeSourcesTarget(value) {
|
|
48
|
+
if (!value || value.includes("\\") || value.includes("\0") || path.posix.isAbsolute(value))
|
|
49
|
+
return false;
|
|
50
|
+
const parts = value.split("/");
|
|
51
|
+
if (parts.length < 2 || parts[0] !== "sources" || parts.some((part) => part === "" || part === "." || part === ".."))
|
|
52
|
+
return false;
|
|
53
|
+
if (path.posix.normalize(value) !== value)
|
|
54
|
+
return false;
|
|
55
|
+
return !parts.slice(1).some((part) => {
|
|
56
|
+
const lower = part.toLowerCase();
|
|
57
|
+
return excludedNames.has(lower) || lower === ".env" || lower.startsWith(".env");
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function assertCommonJournal(value, expected) {
|
|
61
|
+
if (typeof expected.root !== "string" || typeof expected.token !== "string" ||
|
|
62
|
+
typeof value.root !== "string" || value.root !== expected.root ||
|
|
63
|
+
typeof value.token !== "string" || value.token !== expected.token ||
|
|
64
|
+
!isOptionalSha256(value.oldCatalog) || !isSha256(value.newCatalog) || !Array.isArray(value.operations)) {
|
|
65
|
+
throw new Error("Invalid storage journal envelope or root/token/catalog commitment.");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function assertItemId(value) {
|
|
69
|
+
if (typeof value !== "string" || !itemPattern.test(value))
|
|
70
|
+
throw new Error("Invalid storage journal item UUID.");
|
|
71
|
+
}
|
|
72
|
+
function assertUniqueItemIds(ids) {
|
|
73
|
+
if (new Set(ids).size !== ids.length)
|
|
74
|
+
throw new Error("Duplicate item UUID in storage journal.");
|
|
75
|
+
}
|
|
76
|
+
function assertNoTargetOverlap(targets) {
|
|
77
|
+
for (let index = 0; index < targets.length; index += 1) {
|
|
78
|
+
const left = targets[index];
|
|
79
|
+
for (const right of targets.slice(index + 1)) {
|
|
80
|
+
if (left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)) {
|
|
81
|
+
throw new Error("Duplicate or overlapping sources targets in storage journal.");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function hasStrictExactKeys(value, keys) {
|
|
87
|
+
if (utilTypes.isProxy(value))
|
|
88
|
+
return false;
|
|
89
|
+
try {
|
|
90
|
+
const prototype = Object.getPrototypeOf(value);
|
|
91
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
92
|
+
return false;
|
|
93
|
+
const ownKeys = Reflect.ownKeys(value);
|
|
94
|
+
if (ownKeys.length !== keys.length || ownKeys.some((key) => typeof key !== "string" || !keys.includes(key)))
|
|
95
|
+
return false;
|
|
96
|
+
return keys.every((key) => {
|
|
97
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
98
|
+
return descriptor !== undefined && Object.hasOwn(descriptor, "value") && descriptor.enumerable === true;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function isStrictDenseArray(value) {
|
|
106
|
+
if (utilTypes.isProxy(value) || !Array.isArray(value))
|
|
107
|
+
return false;
|
|
108
|
+
try {
|
|
109
|
+
const ownKeys = Reflect.ownKeys(value);
|
|
110
|
+
const length = Object.getOwnPropertyDescriptor(value, "length");
|
|
111
|
+
if (!length || !Object.hasOwn(length, "value") || length.enumerable !== false ||
|
|
112
|
+
typeof length.value !== "number" || !Number.isSafeInteger(length.value) || length.value < 0 ||
|
|
113
|
+
ownKeys.length !== length.value + 1)
|
|
114
|
+
return false;
|
|
115
|
+
for (let index = 0; index < length.value; index += 1) {
|
|
116
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
117
|
+
if (!descriptor || !Object.hasOwn(descriptor, "value") || descriptor.enumerable !== true)
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
return ownKeys.every((key) => key === "length" || typeof key === "string" &&
|
|
121
|
+
Number.isSafeInteger(Number(key)) && String(Number(key)) === key && Number(key) >= 0 && Number(key) < length.value);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function isCanonicalRelativePath(value) {
|
|
128
|
+
if (typeof value !== "string" || value.includes("\\") || value.includes("\0") || path.posix.isAbsolute(value) ||
|
|
129
|
+
Buffer.from(value, "utf8").toString("utf8") !== value)
|
|
130
|
+
return false;
|
|
131
|
+
if (value === ".")
|
|
132
|
+
return true;
|
|
133
|
+
return value.length > 0 && path.posix.normalize(value) === value &&
|
|
134
|
+
value.split("/").every((part) => part !== "" && part !== "." && part !== "..");
|
|
135
|
+
}
|
|
136
|
+
function canonicalJson(value) {
|
|
137
|
+
if (Array.isArray(value))
|
|
138
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
139
|
+
if (isRecord(value)) {
|
|
140
|
+
const keys = Object.keys(value).sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
|
141
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
|
|
142
|
+
}
|
|
143
|
+
const encoded = JSON.stringify(value);
|
|
144
|
+
if (encoded === undefined)
|
|
145
|
+
throw new Error("V5 canonical JSON cannot encode an undefined value.");
|
|
146
|
+
return encoded;
|
|
147
|
+
}
|
|
148
|
+
export function canonicalStorageJournalJson(value) {
|
|
149
|
+
return canonicalJson(value);
|
|
150
|
+
}
|
|
151
|
+
function inventoryHash(nodes) {
|
|
152
|
+
return createHash("sha256").update("copi-inventory-v1\0", "utf8").update(canonicalJson(nodes), "utf8").digest("hex");
|
|
153
|
+
}
|
|
154
|
+
function validateStartInventory(value) {
|
|
155
|
+
if (!isRecord(value) || !hasStrictExactKeys(value, ["version", "nodes", "hash"]) || value.version !== 1 ||
|
|
156
|
+
!isStrictDenseArray(value.nodes) || !isSha256(value.hash)) {
|
|
157
|
+
throw new Error("Invalid v5 start inventory envelope.");
|
|
158
|
+
}
|
|
159
|
+
const nodes = [];
|
|
160
|
+
const seenPaths = new Set();
|
|
161
|
+
const seenIdentities = new Set();
|
|
162
|
+
let previousPath;
|
|
163
|
+
for (const node of value.nodes) {
|
|
164
|
+
if (!isRecord(node) || !hasStrictExactKeys(node, ["path", "kind", "mode", "dev", "ino", "bytesHash"]) ||
|
|
165
|
+
!isCanonicalRelativePath(node.path) || (node.kind !== "file" && node.kind !== "directory") ||
|
|
166
|
+
typeof node.mode !== "number" || !Number.isInteger(node.mode) || node.mode < 0 || node.mode > 0o7777 ||
|
|
167
|
+
!isUint64Decimal(node.dev) || !isUint64Decimal(node.ino) || !isSha256(node.bytesHash)) {
|
|
168
|
+
throw new Error("Invalid v5 inventory node.");
|
|
169
|
+
}
|
|
170
|
+
if (previousPath !== undefined && Buffer.compare(Buffer.from(previousPath), Buffer.from(node.path)) >= 0) {
|
|
171
|
+
throw new Error("V5 inventory nodes must be uniquely ordered by UTF-8 path bytes.");
|
|
172
|
+
}
|
|
173
|
+
if (seenPaths.has(node.path))
|
|
174
|
+
throw new Error("Duplicate path in v5 start inventory.");
|
|
175
|
+
const identity = `${node.dev}:${node.ino}`;
|
|
176
|
+
if (seenIdentities.has(identity))
|
|
177
|
+
throw new Error("V5 start inventory contains an unsafe hardlink alias.");
|
|
178
|
+
seenPaths.add(node.path);
|
|
179
|
+
seenIdentities.add(identity);
|
|
180
|
+
previousPath = node.path;
|
|
181
|
+
nodes.push({ path: node.path, kind: node.kind, mode: node.mode, dev: node.dev, ino: node.ino, bytesHash: node.bytesHash });
|
|
182
|
+
}
|
|
183
|
+
const byPath = new Map(nodes.map((node) => [node.path, node]));
|
|
184
|
+
const rootNode = byPath.get(".");
|
|
185
|
+
if (!rootNode || rootNode.kind !== "directory")
|
|
186
|
+
throw new Error("V5 start inventory must include its root directory.");
|
|
187
|
+
const children = new Map();
|
|
188
|
+
for (const node of nodes) {
|
|
189
|
+
if (node.path === ".")
|
|
190
|
+
continue;
|
|
191
|
+
const parent = path.posix.dirname(node.path);
|
|
192
|
+
const parentNode = byPath.get(parent);
|
|
193
|
+
if (!parentNode || parentNode.kind !== "directory")
|
|
194
|
+
throw new Error("V5 inventory node has no recorded directory parent.");
|
|
195
|
+
const list = children.get(parent) ?? [];
|
|
196
|
+
list.push([path.posix.basename(node.path), node.kind]);
|
|
197
|
+
children.set(parent, list);
|
|
198
|
+
}
|
|
199
|
+
for (const node of nodes) {
|
|
200
|
+
if (node.kind !== "directory")
|
|
201
|
+
continue;
|
|
202
|
+
const listing = children.get(node.path) ?? [];
|
|
203
|
+
listing.sort(([left], [right]) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
|
204
|
+
const expectedHash = createHash("sha256").update("copi-directory-v1\0", "utf8")
|
|
205
|
+
.update(canonicalJson(listing), "utf8").digest("hex");
|
|
206
|
+
if (node.bytesHash !== expectedHash)
|
|
207
|
+
throw new Error(`V5 directory listing commitment is invalid for ${node.path}.`);
|
|
208
|
+
}
|
|
209
|
+
if (value.hash !== inventoryHash(nodes))
|
|
210
|
+
throw new Error("V5 start inventory hash does not match its canonical nodes.");
|
|
211
|
+
return { version: 1, nodes, hash: value.hash };
|
|
212
|
+
}
|
|
213
|
+
export function parseMigrationStartInventory(value) {
|
|
214
|
+
return validateStartInventory(value);
|
|
215
|
+
}
|
|
216
|
+
function parseLegacyMigrationJournalV5(value, expected) {
|
|
217
|
+
const keys = [
|
|
218
|
+
"version", "mode", "root", "token", "oldCatalog", "newCatalog", "stableItemsTree", "stableSourcesTree",
|
|
219
|
+
"stableSourcesTreeNext", "backupDir", "backupManifest", "operations", "ownedDirectories", "startInventory",
|
|
220
|
+
];
|
|
221
|
+
if (!hasStrictExactKeys(value, keys) || value.mode !== "legacy-migration" || !isStrictDenseArray(value.operations) ||
|
|
222
|
+
value.operations.length !== 0 || !isStrictDenseArray(value.ownedDirectories) || value.ownedDirectories.length !== 0) {
|
|
223
|
+
throw new Error("Invalid v5 adopt-only legacy-migration journal object.");
|
|
224
|
+
}
|
|
225
|
+
assertCommonJournal(value, expected);
|
|
226
|
+
if (value.oldCatalog === null || !lowercaseUuidPattern.test(value.token) ||
|
|
227
|
+
!isStableItemsCommitment(value.stableItemsTree) || !isStableSourcesCommitment(value.stableSourcesTree) ||
|
|
228
|
+
value.stableSourcesTreeNext !== value.stableSourcesTree || !isMigrationBackupDir(value.backupDir) ||
|
|
229
|
+
!isSha256(value.backupManifest) || !path.isAbsolute(value.root) || path.resolve(value.root) !== value.root) {
|
|
230
|
+
throw new Error("Invalid v5 catalog, root, token, tree, or backup commitment.");
|
|
231
|
+
}
|
|
232
|
+
const startInventory = validateStartInventory(value.startInventory);
|
|
233
|
+
const catalog = startInventory.nodes.find((node) => node.path === "catalog.json");
|
|
234
|
+
if (!catalog || catalog.kind !== "file" || catalog.bytesHash !== value.oldCatalog) {
|
|
235
|
+
throw new Error("V5 start inventory catalog proof does not match the old catalog commitment.");
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
version: 5,
|
|
239
|
+
mode: "legacy-migration",
|
|
240
|
+
root: value.root,
|
|
241
|
+
token: value.token,
|
|
242
|
+
oldCatalog: value.oldCatalog,
|
|
243
|
+
newCatalog: value.newCatalog,
|
|
244
|
+
stableItemsTree: value.stableItemsTree,
|
|
245
|
+
stableSourcesTree: value.stableSourcesTree,
|
|
246
|
+
stableSourcesTreeNext: value.stableSourcesTreeNext,
|
|
247
|
+
backupDir: value.backupDir,
|
|
248
|
+
backupManifest: value.backupManifest,
|
|
249
|
+
operations: [],
|
|
250
|
+
ownedDirectories: [],
|
|
251
|
+
startInventory,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function parseLegacyMigrationJournal(value, expected) {
|
|
255
|
+
const hasNextSourcesTree = Object.hasOwn(value, "nextSourcesTree");
|
|
256
|
+
const requiredKeys = [
|
|
257
|
+
"version", "mode", "root", "token", "oldCatalog", "newCatalog", "stableItemsTree", "stableSourcesTree",
|
|
258
|
+
"stableSourcesTreeNext", "backupDir", "backupManifest", "operations", "ownedDirectories",
|
|
259
|
+
];
|
|
260
|
+
if (!hasExactKeys(value, hasNextSourcesTree ? [...requiredKeys, "nextSourcesTree"] : requiredKeys) ||
|
|
261
|
+
value.mode !== "legacy-migration" || !Array.isArray(value.ownedDirectories)) {
|
|
262
|
+
throw new Error("Invalid legacy-migration v4 storage journal object.");
|
|
263
|
+
}
|
|
264
|
+
assertCommonJournal(value, expected);
|
|
265
|
+
if (value.oldCatalog === null || !isStableItemsCommitment(value.stableItemsTree) ||
|
|
266
|
+
!isStableSourcesCommitment(value.stableSourcesTree) || !isStableSourcesCommitment(value.stableSourcesTreeNext) ||
|
|
267
|
+
!isMigrationBackupDir(value.backupDir) || !isSha256(value.backupManifest)) {
|
|
268
|
+
throw new Error("Invalid v4 catalog, tree, backup directory, or manifest commitment.");
|
|
269
|
+
}
|
|
270
|
+
if (!path.isAbsolute(expected.root) || path.resolve(expected.root) !== expected.root ||
|
|
271
|
+
!path.isAbsolute(value.root) || path.resolve(value.root) !== value.root) {
|
|
272
|
+
throw new Error("V4 storage journal root must be canonical and absolute.");
|
|
273
|
+
}
|
|
274
|
+
const operations = [];
|
|
275
|
+
for (const operation of value.operations) {
|
|
276
|
+
if (!isRecord(operation) || !hasExactKeys(operation, ["itemId", "target", "kind", "oldTree", "newTree"])) {
|
|
277
|
+
throw new Error("Invalid legacy-migration v4 operation.");
|
|
278
|
+
}
|
|
279
|
+
assertItemId(operation.itemId);
|
|
280
|
+
if (typeof operation.target !== "string" || !isSafeSourcesTarget(operation.target) ||
|
|
281
|
+
(operation.kind !== "file" && operation.kind !== "tree") || operation.oldTree !== null || !isSha256(operation.newTree)) {
|
|
282
|
+
throw new Error("V4 migration operations must add only safe sources targets with null old-tree commitments.");
|
|
283
|
+
}
|
|
284
|
+
operations.push({ itemId: operation.itemId, target: operation.target, kind: operation.kind, oldTree: null, newTree: operation.newTree });
|
|
285
|
+
}
|
|
286
|
+
assertUniqueItemIds(operations.map((operation) => operation.itemId));
|
|
287
|
+
assertNoTargetOverlap(operations.map((operation) => operation.target));
|
|
288
|
+
if (operations.length > 0 && value.stableItemsTree === "absent") {
|
|
289
|
+
throw new Error("V4 source additions require the fixed legacy items tree commitment.");
|
|
290
|
+
}
|
|
291
|
+
const ownedDirectories = [];
|
|
292
|
+
const ownedPaths = new Set();
|
|
293
|
+
const ownedIdentities = new Set();
|
|
294
|
+
for (const entry of value.ownedDirectories) {
|
|
295
|
+
if (!isRecord(entry) || !hasExactKeys(entry, ["path", "stage", "dev", "ino"]) ||
|
|
296
|
+
typeof entry.path !== "string" || (entry.path !== "sources" && !/^sources\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(entry.path)) ||
|
|
297
|
+
typeof entry.stage !== "string" || !/^next-(?:sources|(?:0|[1-9][0-9]*))$/.test(entry.stage) ||
|
|
298
|
+
!isUint64Decimal(entry.dev) || !isUint64Decimal(entry.ino) || ownedPaths.has(entry.path)) {
|
|
299
|
+
throw new Error("Invalid or duplicate v4 owned source directory identity.");
|
|
300
|
+
}
|
|
301
|
+
const identity = `${entry.dev}:${entry.ino}`;
|
|
302
|
+
if (ownedIdentities.has(identity))
|
|
303
|
+
throw new Error("V4 owned source directories must have distinct recorded identities.");
|
|
304
|
+
ownedPaths.add(entry.path);
|
|
305
|
+
ownedIdentities.add(identity);
|
|
306
|
+
ownedDirectories.push({ path: entry.path, stage: entry.stage, dev: entry.dev, ino: entry.ino });
|
|
307
|
+
}
|
|
308
|
+
const beforeSources = value.stableSourcesTree;
|
|
309
|
+
const afterSources = value.stableSourcesTreeNext;
|
|
310
|
+
const hasNextSourcesTreeValue = isSha256(value.nextSourcesTree);
|
|
311
|
+
if (operations.length === 0) {
|
|
312
|
+
if (afterSources !== beforeSources || ownedDirectories.length !== 0 || hasNextSourcesTree) {
|
|
313
|
+
throw new Error("An adopt-only v4 journal must preserve the sources tree and contain no staged ownership.");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
if (afterSources === "absent" || afterSources === beforeSources ||
|
|
318
|
+
(beforeSources === "absent" ? !hasNextSourcesTree || !hasNextSourcesTreeValue : hasNextSourcesTree)) {
|
|
319
|
+
throw new Error("V4 new-source operations require changed after-state and exact absent-root stage metadata.");
|
|
320
|
+
}
|
|
321
|
+
const expectedOwned = new Map();
|
|
322
|
+
if (beforeSources === "absent")
|
|
323
|
+
expectedOwned.set("sources", "next-sources");
|
|
324
|
+
for (const [index, operation] of operations.entries()) {
|
|
325
|
+
if (operation.kind !== "file")
|
|
326
|
+
continue;
|
|
327
|
+
expectedOwned.set(`sources/${operation.itemId}`, beforeSources === "absent" ? "next-sources" : `next-${index}`);
|
|
328
|
+
}
|
|
329
|
+
if (ownedDirectories.length !== expectedOwned.size ||
|
|
330
|
+
ownedDirectories.some((directory) => expectedOwned.get(directory.path) !== directory.stage)) {
|
|
331
|
+
throw new Error("V4 new-source operations are missing exact root or new-file-parent stage custody.");
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
version: 4,
|
|
336
|
+
mode: "legacy-migration",
|
|
337
|
+
root: value.root,
|
|
338
|
+
token: value.token,
|
|
339
|
+
oldCatalog: value.oldCatalog,
|
|
340
|
+
newCatalog: value.newCatalog,
|
|
341
|
+
stableItemsTree: value.stableItemsTree,
|
|
342
|
+
stableSourcesTree: value.stableSourcesTree,
|
|
343
|
+
stableSourcesTreeNext: value.stableSourcesTreeNext,
|
|
344
|
+
backupDir: value.backupDir,
|
|
345
|
+
backupManifest: value.backupManifest,
|
|
346
|
+
operations,
|
|
347
|
+
ownedDirectories,
|
|
348
|
+
...(hasNextSourcesTree ? { nextSourcesTree: value.nextSourcesTree } : {}),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
export function parseStorageJournal(value, expected) {
|
|
352
|
+
if (!isRecord(value) ||
|
|
353
|
+
(value.version !== 1 && value.version !== 2 && value.version !== 3 && value.version !== 4 && value.version !== 5)) {
|
|
354
|
+
throw new Error("Unsupported or invalid storage journal version.");
|
|
355
|
+
}
|
|
356
|
+
if (value.version === 5)
|
|
357
|
+
return parseLegacyMigrationJournalV5(value, expected);
|
|
358
|
+
if (value.version === 4)
|
|
359
|
+
return parseLegacyMigrationJournal(value, expected);
|
|
360
|
+
if (value.version === 3) {
|
|
361
|
+
const catalogOnlyKeys = [
|
|
362
|
+
"version", "mode", "root", "token", "oldCatalog", "newCatalog", "stableSourcesTree", "operations",
|
|
363
|
+
];
|
|
364
|
+
if (!hasExactKeys(value, catalogOnlyKeys) || value.mode !== "catalog-only") {
|
|
365
|
+
throw new Error("Invalid catalog-only v3 storage journal object.");
|
|
366
|
+
}
|
|
367
|
+
assertCommonJournal(value, expected);
|
|
368
|
+
if (!isStableSourcesCommitment(value.stableSourcesTree) || value.operations.length !== 0) {
|
|
369
|
+
throw new Error("Invalid catalog-only v3 tree commitment or operations.");
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
version: 3,
|
|
373
|
+
mode: "catalog-only",
|
|
374
|
+
root: value.root,
|
|
375
|
+
token: value.token,
|
|
376
|
+
oldCatalog: value.oldCatalog,
|
|
377
|
+
newCatalog: value.newCatalog,
|
|
378
|
+
stableSourcesTree: value.stableSourcesTree,
|
|
379
|
+
operations: [],
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
const commonKeys = ["version", "root", "token", "oldCatalog", "newCatalog", "operations"];
|
|
383
|
+
const hasAdditionMetadata = Object.hasOwn(value, "stableSourcesTreeNext") || Object.hasOwn(value, "nextSourcesTree") || Object.hasOwn(value, "ownedDirectories");
|
|
384
|
+
const completeAdditionMetadata = ["stableSourcesTreeNext", "nextSourcesTree", "ownedDirectories"].every((key) => Object.hasOwn(value, key));
|
|
385
|
+
const expectedKeys = value.version === 1 ? commonKeys : [...commonKeys, "stableSourcesTree", ...(hasAdditionMetadata ? ["stableSourcesTreeNext", "nextSourcesTree", "ownedDirectories"] : [])];
|
|
386
|
+
if (!hasExactKeys(value, expectedKeys) || (hasAdditionMetadata && !completeAdditionMetadata))
|
|
387
|
+
throw new Error("Invalid storage journal object.");
|
|
388
|
+
assertCommonJournal(value, expected);
|
|
389
|
+
if (value.version === 1) {
|
|
390
|
+
const operations = [];
|
|
391
|
+
for (const valueOperation of value.operations) {
|
|
392
|
+
if (!isRecord(valueOperation) || !hasExactKeys(valueOperation, ["itemId", "oldTree", "newTree"])) {
|
|
393
|
+
throw new Error("Invalid legacy storage journal operation.");
|
|
394
|
+
}
|
|
395
|
+
assertItemId(valueOperation.itemId);
|
|
396
|
+
if (!isOptionalSha256(valueOperation.oldTree) || !isSha256(valueOperation.newTree)) {
|
|
397
|
+
throw new Error("Invalid legacy storage journal tree commitment.");
|
|
398
|
+
}
|
|
399
|
+
operations.push({ itemId: valueOperation.itemId, oldTree: valueOperation.oldTree, newTree: valueOperation.newTree });
|
|
400
|
+
}
|
|
401
|
+
assertUniqueItemIds(operations.map((operation) => operation.itemId));
|
|
402
|
+
return {
|
|
403
|
+
version: 1,
|
|
404
|
+
root: value.root,
|
|
405
|
+
token: value.token,
|
|
406
|
+
oldCatalog: value.oldCatalog,
|
|
407
|
+
newCatalog: value.newCatalog,
|
|
408
|
+
operations,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
if (!isStableSourcesCommitment(value.stableSourcesTree))
|
|
412
|
+
throw new Error("Invalid stable sources tree commitment in v2 storage journal.");
|
|
413
|
+
const operations = [];
|
|
414
|
+
for (const valueOperation of value.operations) {
|
|
415
|
+
if (!isRecord(valueOperation) || !hasExactKeys(valueOperation, ["itemId", "target", "kind", "oldTree", "newTree"])) {
|
|
416
|
+
throw new Error("Invalid sources storage journal operation.");
|
|
417
|
+
}
|
|
418
|
+
assertItemId(valueOperation.itemId);
|
|
419
|
+
if (typeof valueOperation.target !== "string" || !isSafeSourcesTarget(valueOperation.target) ||
|
|
420
|
+
(valueOperation.kind !== "file" && valueOperation.kind !== "tree") ||
|
|
421
|
+
!isOptionalSha256(valueOperation.oldTree) || !isSha256(valueOperation.newTree)) {
|
|
422
|
+
throw new Error("Invalid sources storage journal target, kind, or tree commitment.");
|
|
423
|
+
}
|
|
424
|
+
operations.push({
|
|
425
|
+
itemId: valueOperation.itemId,
|
|
426
|
+
target: valueOperation.target,
|
|
427
|
+
kind: valueOperation.kind,
|
|
428
|
+
oldTree: valueOperation.oldTree,
|
|
429
|
+
newTree: valueOperation.newTree,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
assertUniqueItemIds(operations.map((operation) => operation.itemId));
|
|
433
|
+
assertNoTargetOverlap(operations.map((operation) => operation.target));
|
|
434
|
+
const hasNewTargets = operations.some((operation) => operation.oldTree === null);
|
|
435
|
+
if (hasAdditionMetadata !== hasNewTargets) {
|
|
436
|
+
throw new Error("V2 new-target journal metadata does not match its operations.");
|
|
437
|
+
}
|
|
438
|
+
if (!hasAdditionMetadata) {
|
|
439
|
+
if (value.stableSourcesTree === "absent")
|
|
440
|
+
throw new Error("Legacy v2 journal cannot start from an absent sources root.");
|
|
441
|
+
return {
|
|
442
|
+
version: 2,
|
|
443
|
+
root: value.root,
|
|
444
|
+
token: value.token,
|
|
445
|
+
oldCatalog: value.oldCatalog,
|
|
446
|
+
newCatalog: value.newCatalog,
|
|
447
|
+
stableSourcesTree: value.stableSourcesTree,
|
|
448
|
+
operations,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
if (!isSha256(value.stableSourcesTreeNext) || !isSha256(value.nextSourcesTree) || !Array.isArray(value.ownedDirectories)) {
|
|
452
|
+
throw new Error("Invalid v2 new-target tree or directory ownership commitment.");
|
|
453
|
+
}
|
|
454
|
+
const ownedDirectories = [];
|
|
455
|
+
const ownedPaths = new Set();
|
|
456
|
+
for (const entry of value.ownedDirectories) {
|
|
457
|
+
if (!isRecord(entry) || !hasExactKeys(entry, ["path", "stage", "dev", "ino"]) ||
|
|
458
|
+
typeof entry.path !== "string" || (entry.path !== "sources" && !isSafeSourcesTarget(entry.path)) ||
|
|
459
|
+
typeof entry.stage !== "string" || !/^next-(?:sources|(?:0|[1-9][0-9]*))$/.test(entry.stage) ||
|
|
460
|
+
typeof entry.dev !== "number" || !Number.isSafeInteger(entry.dev) || entry.dev < 0 ||
|
|
461
|
+
typeof entry.ino !== "number" || !Number.isSafeInteger(entry.ino) || entry.ino < 0 || ownedPaths.has(entry.path)) {
|
|
462
|
+
throw new Error("Invalid or duplicate v2 owned source directory identity.");
|
|
463
|
+
}
|
|
464
|
+
const stageIndex = entry.stage === "next-sources" ? undefined : Number(entry.stage.slice("next-".length));
|
|
465
|
+
if (entry.path === "sources") {
|
|
466
|
+
if (entry.stage !== "next-sources" || value.stableSourcesTree !== "absent")
|
|
467
|
+
throw new Error("New sources root ownership must come from its private stage when the root was absent.");
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
const match = /^sources\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i.exec(entry.path);
|
|
471
|
+
const operationIndex = match ? operations.findIndex((operation) => operation.itemId === match[1]) : -1;
|
|
472
|
+
const operation = operations[operationIndex];
|
|
473
|
+
if (!match || !operation || operation.oldTree !== null || operation.kind !== "file" ||
|
|
474
|
+
(entry.stage !== "next-sources" && (stageIndex !== operationIndex || entry.stage !== `next-${operationIndex}`))) {
|
|
475
|
+
throw new Error("Owned source directory is not bound to a new file item operation.");
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
ownedPaths.add(entry.path);
|
|
479
|
+
ownedDirectories.push({ path: entry.path, stage: entry.stage, dev: entry.dev, ino: entry.ino });
|
|
480
|
+
}
|
|
481
|
+
if (value.stableSourcesTree === "absent" && !ownedPaths.has("sources")) {
|
|
482
|
+
throw new Error("Absent v2 sources root has no recorded private creation stage.");
|
|
483
|
+
}
|
|
484
|
+
for (const [index, operation] of operations.entries()) {
|
|
485
|
+
if (operation.kind !== "file" || operation.oldTree !== null)
|
|
486
|
+
continue;
|
|
487
|
+
const parent = ownedDirectories.find((directory) => directory.path === `sources/${operation.itemId}`);
|
|
488
|
+
const expectedStage = value.stableSourcesTree === "absent" ? "next-sources" : `next-${index}`;
|
|
489
|
+
if (!parent || parent.stage !== expectedStage)
|
|
490
|
+
throw new Error("New file operation is missing its exact private parent-directory custody record.");
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
version: 2,
|
|
494
|
+
root: value.root,
|
|
495
|
+
token: value.token,
|
|
496
|
+
oldCatalog: value.oldCatalog,
|
|
497
|
+
newCatalog: value.newCatalog,
|
|
498
|
+
stableSourcesTree: value.stableSourcesTree,
|
|
499
|
+
stableSourcesTreeNext: value.stableSourcesTreeNext,
|
|
500
|
+
nextSourcesTree: value.nextSourcesTree,
|
|
501
|
+
ownedDirectories,
|
|
502
|
+
operations,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
function assertCatalogItems(catalog, label) {
|
|
506
|
+
if (!catalog || catalog.version !== 2 || !Array.isArray(catalog.items)) {
|
|
507
|
+
throw new Error(`Invalid ${label} sources catalog.`);
|
|
508
|
+
}
|
|
509
|
+
const items = new Map();
|
|
510
|
+
const targets = [];
|
|
511
|
+
for (const item of catalog.items) {
|
|
512
|
+
if (!item || typeof item.id !== "string" || items.has(item.id) || !item.content ||
|
|
513
|
+
(item.content.kind !== "file" && item.content.kind !== "tree") ||
|
|
514
|
+
typeof item.content.path !== "string" || !isSafeSourcesTarget(item.content.path) ||
|
|
515
|
+
item.content.kind !== (item.kind === "skill" ? "tree" : "file")) {
|
|
516
|
+
throw new Error(`Invalid or duplicate content target in ${label} sources catalog.`);
|
|
517
|
+
}
|
|
518
|
+
items.set(item.id, item);
|
|
519
|
+
targets.push(item.content.path);
|
|
520
|
+
}
|
|
521
|
+
assertNoTargetOverlap(targets);
|
|
522
|
+
return items;
|
|
523
|
+
}
|
|
524
|
+
export function validateLegacyMigrationJournalTargets(journal, previous, next) {
|
|
525
|
+
if (!journal || (journal.version !== 4 && journal.version !== 5) || journal.mode !== "legacy-migration" || journal.oldCatalog === null ||
|
|
526
|
+
!Array.isArray(journal.operations) || !previous || previous.version !== 1 || !Array.isArray(previous.items) ||
|
|
527
|
+
!Array.isArray(previous.groups) || !next || next.version !== 2 || !Array.isArray(next.groups)) {
|
|
528
|
+
throw new Error("Legacy migration target validation requires its fixed v4 journal, v1 catalog, and v2 catalog.");
|
|
529
|
+
}
|
|
530
|
+
const nextItems = assertCatalogItems(next, "legacy migration next");
|
|
531
|
+
for (const id of nextItems.keys())
|
|
532
|
+
assertItemId(id);
|
|
533
|
+
const previousItems = new Map();
|
|
534
|
+
for (const item of previous.items) {
|
|
535
|
+
if (!item || typeof item.id !== "string" || previousItems.has(item.id))
|
|
536
|
+
throw new Error("Invalid or duplicate item in the legacy migration source catalog.");
|
|
537
|
+
assertItemId(item.id);
|
|
538
|
+
previousItems.set(item.id, item);
|
|
539
|
+
}
|
|
540
|
+
if (previous.items.length !== next.items.length || JSON.stringify(previous.groups) !== JSON.stringify(next.groups)) {
|
|
541
|
+
throw new Error("Legacy migration must preserve v1 item membership and group relationships exactly.");
|
|
542
|
+
}
|
|
543
|
+
for (const [index, oldItem] of previous.items.entries()) {
|
|
544
|
+
const migrated = next.items[index];
|
|
545
|
+
if (!migrated || migrated.id !== oldItem.id)
|
|
546
|
+
throw new Error("Legacy migration must preserve catalog item order and identity.");
|
|
547
|
+
const { content: _content, ...migratedLegacy } = migrated;
|
|
548
|
+
if (JSON.stringify(migratedLegacy) !== JSON.stringify(oldItem)) {
|
|
549
|
+
throw new Error("Legacy migration may add source content locations but must preserve all v1 item metadata and descriptors.");
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
const operationIds = [];
|
|
553
|
+
const operationTargets = [];
|
|
554
|
+
if (journal.operations.length > 0 && journal.stableItemsTree === "absent") {
|
|
555
|
+
throw new Error("Legacy migration cannot stage source additions without the fixed v1 items tree.");
|
|
556
|
+
}
|
|
557
|
+
for (const operation of journal.operations) {
|
|
558
|
+
assertItemId(operation.itemId);
|
|
559
|
+
const oldItem = previousItems.get(operation.itemId);
|
|
560
|
+
const nextItem = nextItems.get(operation.itemId);
|
|
561
|
+
if (!oldItem || !nextItem || operation.oldTree !== null || !isSha256(operation.newTree) ||
|
|
562
|
+
operation.target !== nextItem.content.path || operation.kind !== nextItem.content.kind) {
|
|
563
|
+
throw new Error("Legacy migration operation target or kind is not bound to the corresponding v1 and v2 catalog item.");
|
|
564
|
+
}
|
|
565
|
+
const targetParts = operation.target.split("/");
|
|
566
|
+
if ((operation.kind === "tree" && (operation.target !== `sources/${operation.itemId}` || nextItem.kind !== "skill" ||
|
|
567
|
+
!nextItem.files.some((file) => file.path === "SKILL.md"))) ||
|
|
568
|
+
(operation.kind === "file" && (targetParts.length !== 3 || targetParts[0] !== "sources" ||
|
|
569
|
+
targetParts[1] !== operation.itemId || nextItem.files.length !== 1 || nextItem.files[0]?.path !== targetParts[2]))) {
|
|
570
|
+
throw new Error("Legacy migration new target does not match the v2 item root and R2 placement contract.");
|
|
571
|
+
}
|
|
572
|
+
operationIds.push(operation.itemId);
|
|
573
|
+
operationTargets.push(operation.target);
|
|
574
|
+
}
|
|
575
|
+
assertUniqueItemIds(operationIds);
|
|
576
|
+
assertNoTargetOverlap(operationTargets);
|
|
577
|
+
}
|
|
578
|
+
export function validateSourcesJournalTargets(journal, previous, next) {
|
|
579
|
+
if (!journal || journal.version !== 2 || !Array.isArray(journal.operations)) {
|
|
580
|
+
throw new Error("Sources journal target validation requires a v2 journal.");
|
|
581
|
+
}
|
|
582
|
+
const nextItems = assertCatalogItems(next, "next");
|
|
583
|
+
const previousItems = previous === null ? null : assertCatalogItems(previous, "previous");
|
|
584
|
+
const operationIds = [];
|
|
585
|
+
const operationTargets = [];
|
|
586
|
+
for (const operation of journal.operations) {
|
|
587
|
+
if (!operation || typeof operation !== "object" || Array.isArray(operation))
|
|
588
|
+
throw new Error("Invalid sources journal operation.");
|
|
589
|
+
assertItemId(operation.itemId);
|
|
590
|
+
if (typeof operation.target !== "string" || !isSafeSourcesTarget(operation.target) ||
|
|
591
|
+
(operation.kind !== "file" && operation.kind !== "tree") ||
|
|
592
|
+
!isOptionalSha256(operation.oldTree) || !isSha256(operation.newTree)) {
|
|
593
|
+
throw new Error("Invalid sources journal operation.");
|
|
594
|
+
}
|
|
595
|
+
const item = nextItems.get(operation.itemId);
|
|
596
|
+
if (!item || item.content.path !== operation.target || item.content.kind !== operation.kind) {
|
|
597
|
+
throw new Error("Sources journal target or kind does not match the next catalog item.");
|
|
598
|
+
}
|
|
599
|
+
const oldItem = previousItems?.get(operation.itemId);
|
|
600
|
+
if (oldItem) {
|
|
601
|
+
if (oldItem.content.path !== item.content.path || oldItem.content.kind !== item.content.kind || operation.oldTree === null) {
|
|
602
|
+
throw new Error("Existing sources item changed target or lacks its old tree commitment.");
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
else if (operation.oldTree !== null) {
|
|
606
|
+
throw new Error("New sources item must not have an old tree commitment.");
|
|
607
|
+
}
|
|
608
|
+
operationIds.push(operation.itemId);
|
|
609
|
+
operationTargets.push(operation.target);
|
|
610
|
+
}
|
|
611
|
+
assertUniqueItemIds(operationIds);
|
|
612
|
+
assertNoTargetOverlap(operationTargets);
|
|
613
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface StorageRootLease {
|
|
2
|
+
assertCurrent(): Promise<void>;
|
|
3
|
+
updateExpectedMode(mode: number): Promise<void>;
|
|
4
|
+
close(): Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
/** Holds a crash-released macOS O_EXLOCK on the library root directory descriptor. */
|
|
7
|
+
export declare function acquireRootLease(root: string, waitMilliseconds?: number): Promise<StorageRootLease>;
|