@catmint-fs/core 0.0.0-prealpha.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.
- package/LICENSE +339 -0
- package/README.md +146 -0
- package/dist/index.d.ts +297 -0
- package/dist/index.js +1835 -0
- package/package.json +51 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1835 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var TransactionError = class extends Error {
|
|
3
|
+
rootCause;
|
|
4
|
+
sourceError;
|
|
5
|
+
rollbackErrors;
|
|
6
|
+
constructor(message, rootCause, sourceError, rollbackErrors) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "TransactionError";
|
|
9
|
+
this.rootCause = rootCause;
|
|
10
|
+
this.sourceError = sourceError;
|
|
11
|
+
this.rollbackErrors = rollbackErrors;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// src/errors.ts
|
|
16
|
+
var FsError = class extends Error {
|
|
17
|
+
code;
|
|
18
|
+
constructor(code, message) {
|
|
19
|
+
super(`${code}: ${message}`);
|
|
20
|
+
this.name = "FsError";
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// src/ledger.ts
|
|
26
|
+
var DEFAULT_FILE_MODE = 438;
|
|
27
|
+
var DEFAULT_DIR_MODE = 511;
|
|
28
|
+
var Ledger = class {
|
|
29
|
+
records = /* @__PURE__ */ new Map();
|
|
30
|
+
// When chmod and chown coexist on the same path, we track both
|
|
31
|
+
// using a separate map keyed by path to hold the secondary operation.
|
|
32
|
+
// E.g., if chmod is the primary, chown data is stored in chmodChownPairs.
|
|
33
|
+
chmodChownPairs = /* @__PURE__ */ new Map();
|
|
34
|
+
nextOrder = 0;
|
|
35
|
+
caseSensitive;
|
|
36
|
+
constructor(caseSensitive = true) {
|
|
37
|
+
this.caseSensitive = caseSensitive;
|
|
38
|
+
}
|
|
39
|
+
normalizePath(p) {
|
|
40
|
+
return this.caseSensitive ? p : p.toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
has(path2) {
|
|
43
|
+
return this.records.has(this.normalizePath(path2));
|
|
44
|
+
}
|
|
45
|
+
get(path2) {
|
|
46
|
+
return this.records.get(this.normalizePath(path2))?.change;
|
|
47
|
+
}
|
|
48
|
+
getDetail(path2) {
|
|
49
|
+
const key = this.normalizePath(path2);
|
|
50
|
+
const rec = this.records.get(key);
|
|
51
|
+
if (!rec) return null;
|
|
52
|
+
return this.recordToDetail(rec, key);
|
|
53
|
+
}
|
|
54
|
+
recordToDetail(rec, key) {
|
|
55
|
+
const { change } = rec;
|
|
56
|
+
switch (change.type) {
|
|
57
|
+
case "create": {
|
|
58
|
+
if (change.entryType === "directory") {
|
|
59
|
+
return {
|
|
60
|
+
type: "create",
|
|
61
|
+
entryType: "directory",
|
|
62
|
+
path: change.path,
|
|
63
|
+
mode: rec.mode ?? DEFAULT_DIR_MODE,
|
|
64
|
+
uid: rec.uid ?? 0,
|
|
65
|
+
gid: rec.gid ?? 0
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
type: "create",
|
|
70
|
+
entryType: "file",
|
|
71
|
+
path: change.path,
|
|
72
|
+
content: rec.content ?? new Uint8Array(0),
|
|
73
|
+
mode: rec.mode ?? DEFAULT_FILE_MODE,
|
|
74
|
+
uid: rec.uid ?? 0,
|
|
75
|
+
gid: rec.gid ?? 0
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
case "update":
|
|
79
|
+
return {
|
|
80
|
+
type: "update",
|
|
81
|
+
path: change.path,
|
|
82
|
+
content: rec.content ?? new Uint8Array(0),
|
|
83
|
+
mode: rec.mode ?? DEFAULT_FILE_MODE,
|
|
84
|
+
uid: rec.uid ?? 0,
|
|
85
|
+
gid: rec.gid ?? 0
|
|
86
|
+
};
|
|
87
|
+
case "delete":
|
|
88
|
+
return {
|
|
89
|
+
type: "delete",
|
|
90
|
+
entryType: change.entryType,
|
|
91
|
+
path: change.path
|
|
92
|
+
};
|
|
93
|
+
case "rename":
|
|
94
|
+
return {
|
|
95
|
+
type: "rename",
|
|
96
|
+
from: change.from,
|
|
97
|
+
to: change.to
|
|
98
|
+
};
|
|
99
|
+
case "chmod": {
|
|
100
|
+
const pair = this.chmodChownPairs.get(key);
|
|
101
|
+
if (pair && pair.uid !== void 0 && pair.gid !== void 0) {
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
type: "chmod",
|
|
105
|
+
path: change.path,
|
|
106
|
+
mode: change.mode
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
case "chown": {
|
|
110
|
+
const pair = this.chmodChownPairs.get(key);
|
|
111
|
+
if (pair && pair.mode !== void 0) {
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
type: "chown",
|
|
115
|
+
path: change.path,
|
|
116
|
+
uid: change.uid,
|
|
117
|
+
gid: change.gid
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
case "symlink":
|
|
121
|
+
return {
|
|
122
|
+
type: "symlink",
|
|
123
|
+
path: change.path,
|
|
124
|
+
target: change.target
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
getAll() {
|
|
129
|
+
const entries = [];
|
|
130
|
+
const sorted = [...this.records.values()].sort(
|
|
131
|
+
(a, b) => a.order - b.order
|
|
132
|
+
);
|
|
133
|
+
for (const r of sorted) {
|
|
134
|
+
entries.push(r.change);
|
|
135
|
+
}
|
|
136
|
+
return entries;
|
|
137
|
+
}
|
|
138
|
+
getAllDetails() {
|
|
139
|
+
const sorted = [...this.records.entries()].sort(
|
|
140
|
+
([, a], [, b]) => a.order - b.order
|
|
141
|
+
);
|
|
142
|
+
return sorted.map(([key, rec]) => this.recordToDetail(rec, key));
|
|
143
|
+
}
|
|
144
|
+
recordCreate(path2, entryType, content, mode, uid, gid) {
|
|
145
|
+
const key = this.normalizePath(path2);
|
|
146
|
+
const existing = this.records.get(key);
|
|
147
|
+
if (existing && existing.change.type === "delete") {
|
|
148
|
+
this.records.set(key, {
|
|
149
|
+
order: this.nextOrder++,
|
|
150
|
+
change: { type: "update", path: path2 },
|
|
151
|
+
content,
|
|
152
|
+
mode,
|
|
153
|
+
uid,
|
|
154
|
+
gid
|
|
155
|
+
});
|
|
156
|
+
this.chmodChownPairs.delete(key);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (existing && (existing.change.type === "create" || existing.change.type === "update")) {
|
|
160
|
+
existing.order = this.nextOrder++;
|
|
161
|
+
if (content !== void 0) existing.content = content;
|
|
162
|
+
if (mode !== void 0) existing.mode = mode;
|
|
163
|
+
if (uid !== void 0) existing.uid = uid;
|
|
164
|
+
if (gid !== void 0) existing.gid = gid;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
this.records.set(key, {
|
|
168
|
+
order: this.nextOrder++,
|
|
169
|
+
change: { type: "create", path: path2, entryType },
|
|
170
|
+
content,
|
|
171
|
+
mode,
|
|
172
|
+
uid,
|
|
173
|
+
gid
|
|
174
|
+
});
|
|
175
|
+
this.chmodChownPairs.delete(key);
|
|
176
|
+
}
|
|
177
|
+
recordUpdate(path2, content, mode, uid, gid) {
|
|
178
|
+
const key = this.normalizePath(path2);
|
|
179
|
+
const existing = this.records.get(key);
|
|
180
|
+
if (existing && existing.change.type === "create") {
|
|
181
|
+
existing.order = this.nextOrder++;
|
|
182
|
+
if (content !== void 0) existing.content = content;
|
|
183
|
+
if (mode !== void 0) existing.mode = mode;
|
|
184
|
+
if (uid !== void 0) existing.uid = uid;
|
|
185
|
+
if (gid !== void 0) existing.gid = gid;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (existing && existing.change.type === "update") {
|
|
189
|
+
existing.order = this.nextOrder++;
|
|
190
|
+
if (content !== void 0) existing.content = content;
|
|
191
|
+
if (mode !== void 0) existing.mode = mode;
|
|
192
|
+
if (uid !== void 0) existing.uid = uid;
|
|
193
|
+
if (gid !== void 0) existing.gid = gid;
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
this.records.set(key, {
|
|
197
|
+
order: this.nextOrder++,
|
|
198
|
+
change: { type: "update", path: path2 },
|
|
199
|
+
content,
|
|
200
|
+
mode,
|
|
201
|
+
uid,
|
|
202
|
+
gid
|
|
203
|
+
});
|
|
204
|
+
this.chmodChownPairs.delete(key);
|
|
205
|
+
}
|
|
206
|
+
recordDelete(path2, entryType = "file") {
|
|
207
|
+
const key = this.normalizePath(path2);
|
|
208
|
+
const existing = this.records.get(key);
|
|
209
|
+
if (existing && existing.change.type === "create") {
|
|
210
|
+
this.records.delete(key);
|
|
211
|
+
this.chmodChownPairs.delete(key);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (existing && existing.change.type === "symlink") {
|
|
215
|
+
this.records.delete(key);
|
|
216
|
+
this.chmodChownPairs.delete(key);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
this.records.set(key, {
|
|
220
|
+
order: this.nextOrder++,
|
|
221
|
+
change: { type: "delete", entryType, path: path2 }
|
|
222
|
+
});
|
|
223
|
+
this.chmodChownPairs.delete(key);
|
|
224
|
+
}
|
|
225
|
+
recordRename(from, to) {
|
|
226
|
+
const fromKey = this.normalizePath(from);
|
|
227
|
+
const toKey = this.normalizePath(to);
|
|
228
|
+
const existing = this.records.get(fromKey);
|
|
229
|
+
if (existing && existing.change.type === "create") {
|
|
230
|
+
this.records.delete(fromKey);
|
|
231
|
+
this.chmodChownPairs.delete(fromKey);
|
|
232
|
+
this.records.set(toKey, {
|
|
233
|
+
order: this.nextOrder++,
|
|
234
|
+
change: { type: "create", path: to, entryType: existing.change.entryType },
|
|
235
|
+
content: existing.content,
|
|
236
|
+
mode: existing.mode,
|
|
237
|
+
uid: existing.uid,
|
|
238
|
+
gid: existing.gid,
|
|
239
|
+
target: existing.target
|
|
240
|
+
});
|
|
241
|
+
this.chmodChownPairs.delete(toKey);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (existing && existing.change.type === "symlink") {
|
|
245
|
+
this.records.delete(fromKey);
|
|
246
|
+
this.chmodChownPairs.delete(fromKey);
|
|
247
|
+
this.records.set(toKey, {
|
|
248
|
+
order: this.nextOrder++,
|
|
249
|
+
change: { type: "symlink", path: to, target: existing.change.target },
|
|
250
|
+
target: existing.change.target
|
|
251
|
+
});
|
|
252
|
+
this.chmodChownPairs.delete(toKey);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
this.records.set(fromKey, {
|
|
256
|
+
order: this.nextOrder++,
|
|
257
|
+
change: { type: "delete", entryType: "file", path: from }
|
|
258
|
+
});
|
|
259
|
+
this.chmodChownPairs.delete(fromKey);
|
|
260
|
+
this.records.set(toKey, {
|
|
261
|
+
order: this.nextOrder++,
|
|
262
|
+
change: { type: "rename", from, to }
|
|
263
|
+
});
|
|
264
|
+
this.chmodChownPairs.delete(toKey);
|
|
265
|
+
}
|
|
266
|
+
recordChmod(path2, mode) {
|
|
267
|
+
const key = this.normalizePath(path2);
|
|
268
|
+
const existing = this.records.get(key);
|
|
269
|
+
if (existing && (existing.change.type === "create" || existing.change.type === "update" || existing.change.type === "symlink")) {
|
|
270
|
+
existing.mode = mode;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (existing && existing.change.type === "chmod") {
|
|
274
|
+
existing.change = { type: "chmod", path: path2, mode };
|
|
275
|
+
existing.mode = mode;
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (existing && existing.change.type === "chown") {
|
|
279
|
+
const chownData = {
|
|
280
|
+
uid: existing.change.uid,
|
|
281
|
+
gid: existing.change.gid
|
|
282
|
+
};
|
|
283
|
+
this.records.set(key, {
|
|
284
|
+
order: this.nextOrder++,
|
|
285
|
+
change: { type: "chmod", path: path2, mode },
|
|
286
|
+
mode,
|
|
287
|
+
uid: existing.uid,
|
|
288
|
+
gid: existing.gid
|
|
289
|
+
});
|
|
290
|
+
this.chmodChownPairs.set(key, chownData);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
this.records.set(key, {
|
|
294
|
+
order: this.nextOrder++,
|
|
295
|
+
change: { type: "chmod", path: path2, mode },
|
|
296
|
+
mode
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
recordChown(path2, uid, gid) {
|
|
300
|
+
const key = this.normalizePath(path2);
|
|
301
|
+
const existing = this.records.get(key);
|
|
302
|
+
if (existing && (existing.change.type === "create" || existing.change.type === "update" || existing.change.type === "symlink")) {
|
|
303
|
+
existing.uid = uid;
|
|
304
|
+
existing.gid = gid;
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (existing && existing.change.type === "chown") {
|
|
308
|
+
existing.change = { type: "chown", path: path2, uid, gid };
|
|
309
|
+
existing.uid = uid;
|
|
310
|
+
existing.gid = gid;
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (existing && existing.change.type === "chmod") {
|
|
314
|
+
const chmodData = { mode: existing.change.mode };
|
|
315
|
+
this.records.set(key, {
|
|
316
|
+
order: this.nextOrder++,
|
|
317
|
+
change: { type: "chown", path: path2, uid, gid },
|
|
318
|
+
mode: existing.mode,
|
|
319
|
+
uid,
|
|
320
|
+
gid
|
|
321
|
+
});
|
|
322
|
+
this.chmodChownPairs.set(key, chmodData);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
this.records.set(key, {
|
|
326
|
+
order: this.nextOrder++,
|
|
327
|
+
change: { type: "chown", path: path2, uid, gid },
|
|
328
|
+
uid,
|
|
329
|
+
gid
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
recordSymlink(path2, target) {
|
|
333
|
+
const key = this.normalizePath(path2);
|
|
334
|
+
this.records.set(key, {
|
|
335
|
+
order: this.nextOrder++,
|
|
336
|
+
change: { type: "symlink", path: path2, target },
|
|
337
|
+
target
|
|
338
|
+
});
|
|
339
|
+
this.chmodChownPairs.delete(key);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Returns the virtual mode for a path if it has been chmod'd (or created
|
|
343
|
+
* with a mode) in this ledger.
|
|
344
|
+
*/
|
|
345
|
+
getVirtualMode(path2) {
|
|
346
|
+
const rec = this.records.get(this.normalizePath(path2));
|
|
347
|
+
if (!rec) return void 0;
|
|
348
|
+
return rec.mode;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Returns virtual ownership for a path if it has been chown'd (or created
|
|
352
|
+
* with ownership) in this ledger.
|
|
353
|
+
*/
|
|
354
|
+
getVirtualOwnership(path2) {
|
|
355
|
+
const rec = this.records.get(this.normalizePath(path2));
|
|
356
|
+
if (!rec) return void 0;
|
|
357
|
+
if (rec.uid !== void 0 && rec.gid !== void 0) {
|
|
358
|
+
return { uid: rec.uid, gid: rec.gid };
|
|
359
|
+
}
|
|
360
|
+
return void 0;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Returns the content stored for a path, if any.
|
|
364
|
+
*/
|
|
365
|
+
getContent(path2) {
|
|
366
|
+
const rec = this.records.get(this.normalizePath(path2));
|
|
367
|
+
if (!rec) return void 0;
|
|
368
|
+
return rec.content;
|
|
369
|
+
}
|
|
370
|
+
clear() {
|
|
371
|
+
this.records.clear();
|
|
372
|
+
this.chmodChownPairs.clear();
|
|
373
|
+
this.nextOrder = 0;
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// src/permissions.ts
|
|
378
|
+
async function checkPermission(adapter, ledger, capabilities, path2, op) {
|
|
379
|
+
if (!capabilities.permissions) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const virtualMode = ledger.getVirtualMode(path2);
|
|
383
|
+
if (virtualMode !== void 0) {
|
|
384
|
+
const ownership = ledger.getVirtualOwnership(path2);
|
|
385
|
+
checkModePermission(
|
|
386
|
+
virtualMode,
|
|
387
|
+
op,
|
|
388
|
+
ownership?.uid,
|
|
389
|
+
ownership?.gid
|
|
390
|
+
);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (ledger.has(path2)) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
await adapter.checkPermission(path2, op);
|
|
397
|
+
}
|
|
398
|
+
function checkModePermission(mode, op, fileUid, fileGid) {
|
|
399
|
+
let bit;
|
|
400
|
+
switch (op) {
|
|
401
|
+
case "read":
|
|
402
|
+
bit = 4;
|
|
403
|
+
break;
|
|
404
|
+
case "write":
|
|
405
|
+
bit = 2;
|
|
406
|
+
break;
|
|
407
|
+
case "execute":
|
|
408
|
+
bit = 1;
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
const ownerBits = mode >> 6 & 7;
|
|
412
|
+
const groupBits = mode >> 3 & 7;
|
|
413
|
+
const otherBits = mode & 7;
|
|
414
|
+
if ((otherBits & bit) !== 0) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if ((groupBits & bit) !== 0) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if ((ownerBits & bit) !== 0) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const opName = op === "read" ? "reading" : op === "write" ? "writing" : "executing";
|
|
424
|
+
throw new FsError("EACCES", `permission denied for ${opName}`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// src/layer.ts
|
|
428
|
+
var encoder = new TextEncoder();
|
|
429
|
+
var MAX_SYMLINK_DEPTH = 40;
|
|
430
|
+
var DEFAULT_FILE_MODE2 = 438;
|
|
431
|
+
var DEFAULT_DIR_MODE2 = 511;
|
|
432
|
+
var Layer = class {
|
|
433
|
+
adapter;
|
|
434
|
+
ledger;
|
|
435
|
+
capabilities;
|
|
436
|
+
root;
|
|
437
|
+
disposed = false;
|
|
438
|
+
constructor(root, adapter, capabilities) {
|
|
439
|
+
this.root = root;
|
|
440
|
+
this.adapter = adapter;
|
|
441
|
+
this.capabilities = capabilities;
|
|
442
|
+
this.ledger = new Ledger(capabilities.caseSensitive);
|
|
443
|
+
}
|
|
444
|
+
assertNotDisposed() {
|
|
445
|
+
if (this.disposed) {
|
|
446
|
+
throw new FsError("DISPOSED", "layer has been disposed");
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Enforce ENOSYS for operations that require symlink support.
|
|
451
|
+
*/
|
|
452
|
+
assertSymlinksSupported() {
|
|
453
|
+
if (!this.capabilities.symlinks) {
|
|
454
|
+
throw new FsError("ENOSYS", "operation not supported: symlinks are disabled");
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
resolvePath(p) {
|
|
458
|
+
if (p.startsWith("/")) return p;
|
|
459
|
+
return this.root + (this.root.endsWith("/") ? "" : "/") + p;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Follow symlinks to resolve the final path. Used by operations that
|
|
463
|
+
* follow symlinks (readFile, stat, writeFile, chmod, chown, exists).
|
|
464
|
+
*/
|
|
465
|
+
async resolveSymlinks(path2, depth = 0) {
|
|
466
|
+
if (depth > MAX_SYMLINK_DEPTH) {
|
|
467
|
+
throw new FsError("ELOOP", `too many levels of symbolic links: ${path2}`);
|
|
468
|
+
}
|
|
469
|
+
const change = this.ledger.get(path2);
|
|
470
|
+
if (change && change.type === "symlink") {
|
|
471
|
+
const target = change.target;
|
|
472
|
+
const resolved = target.startsWith("/") ? target : this.resolveRelativeTarget(path2, target);
|
|
473
|
+
return this.resolveSymlinks(resolved, depth + 1);
|
|
474
|
+
}
|
|
475
|
+
if (change && change.type === "delete") {
|
|
476
|
+
throw new FsError("ENOENT", `no such file or directory: ${path2}`);
|
|
477
|
+
}
|
|
478
|
+
if (change && (change.type === "create" || change.type === "update")) {
|
|
479
|
+
return path2;
|
|
480
|
+
}
|
|
481
|
+
if (change && change.type === "rename") {
|
|
482
|
+
return path2;
|
|
483
|
+
}
|
|
484
|
+
try {
|
|
485
|
+
const lstatResult = await this.adapter.lstat(path2);
|
|
486
|
+
if (lstatResult.isSymbolicLink()) {
|
|
487
|
+
const target = await this.adapter.readlink(path2);
|
|
488
|
+
const resolved = target.startsWith("/") ? target : this.resolveRelativeTarget(path2, target);
|
|
489
|
+
return this.resolveSymlinks(resolved, depth + 1);
|
|
490
|
+
}
|
|
491
|
+
return path2;
|
|
492
|
+
} catch {
|
|
493
|
+
return path2;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
resolveRelativeTarget(linkPath, target) {
|
|
497
|
+
const dir = linkPath.substring(0, linkPath.lastIndexOf("/")) || "/";
|
|
498
|
+
const parts = dir.split("/").filter(Boolean);
|
|
499
|
+
const targetParts = target.split("/");
|
|
500
|
+
for (const part of targetParts) {
|
|
501
|
+
if (part === "..") {
|
|
502
|
+
parts.pop();
|
|
503
|
+
} else if (part !== "." && part !== "") {
|
|
504
|
+
parts.push(part);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return "/" + parts.join("/");
|
|
508
|
+
}
|
|
509
|
+
// --- Reading ---
|
|
510
|
+
async readFile(path2) {
|
|
511
|
+
this.assertNotDisposed();
|
|
512
|
+
const absPath = this.resolvePath(path2);
|
|
513
|
+
const resolved = await this.resolveSymlinks(absPath);
|
|
514
|
+
await checkPermission(
|
|
515
|
+
this.adapter,
|
|
516
|
+
this.ledger,
|
|
517
|
+
this.capabilities,
|
|
518
|
+
resolved,
|
|
519
|
+
"read"
|
|
520
|
+
);
|
|
521
|
+
const change = this.ledger.get(resolved);
|
|
522
|
+
if (change) {
|
|
523
|
+
if (change.type === "delete") {
|
|
524
|
+
throw new FsError(
|
|
525
|
+
"ENOENT",
|
|
526
|
+
`no such file or directory: ${resolved}`
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
if (change.type === "create" && change.entryType === "directory") {
|
|
530
|
+
throw new FsError("EISDIR", `illegal operation on a directory: ${resolved}`);
|
|
531
|
+
}
|
|
532
|
+
const content = this.ledger.getContent(resolved);
|
|
533
|
+
if (content !== void 0) {
|
|
534
|
+
return content;
|
|
535
|
+
}
|
|
536
|
+
if (change.type === "rename") {
|
|
537
|
+
return this.adapter.readFile(change.from);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return this.adapter.readFile(resolved);
|
|
541
|
+
}
|
|
542
|
+
createReadStream(path2) {
|
|
543
|
+
this.assertNotDisposed();
|
|
544
|
+
const absPath = this.resolvePath(path2);
|
|
545
|
+
const self = this;
|
|
546
|
+
return new ReadableStream({
|
|
547
|
+
async start(controller) {
|
|
548
|
+
try {
|
|
549
|
+
const resolved = await self.resolveSymlinks(absPath);
|
|
550
|
+
await checkPermission(
|
|
551
|
+
self.adapter,
|
|
552
|
+
self.ledger,
|
|
553
|
+
self.capabilities,
|
|
554
|
+
resolved,
|
|
555
|
+
"read"
|
|
556
|
+
);
|
|
557
|
+
const change = self.ledger.get(resolved);
|
|
558
|
+
if (change) {
|
|
559
|
+
if (change.type === "delete") {
|
|
560
|
+
controller.error(
|
|
561
|
+
new FsError(
|
|
562
|
+
"ENOENT",
|
|
563
|
+
`no such file or directory: ${resolved}`
|
|
564
|
+
)
|
|
565
|
+
);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (change.type === "create" && change.entryType === "directory") {
|
|
569
|
+
controller.error(
|
|
570
|
+
new FsError("EISDIR", `illegal operation on a directory: ${resolved}`)
|
|
571
|
+
);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const content = self.ledger.getContent(resolved);
|
|
575
|
+
if (content !== void 0) {
|
|
576
|
+
controller.enqueue(content);
|
|
577
|
+
controller.close();
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (change.type === "rename") {
|
|
581
|
+
const reader2 = self.adapter.createReadStream(change.from).getReader();
|
|
582
|
+
try {
|
|
583
|
+
while (true) {
|
|
584
|
+
const { done, value } = await reader2.read();
|
|
585
|
+
if (done) break;
|
|
586
|
+
controller.enqueue(value);
|
|
587
|
+
}
|
|
588
|
+
controller.close();
|
|
589
|
+
} catch (err) {
|
|
590
|
+
controller.error(err);
|
|
591
|
+
}
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
const reader = self.adapter.createReadStream(resolved).getReader();
|
|
596
|
+
try {
|
|
597
|
+
while (true) {
|
|
598
|
+
const { done, value } = await reader.read();
|
|
599
|
+
if (done) break;
|
|
600
|
+
controller.enqueue(value);
|
|
601
|
+
}
|
|
602
|
+
controller.close();
|
|
603
|
+
} catch (err) {
|
|
604
|
+
controller.error(err);
|
|
605
|
+
}
|
|
606
|
+
} catch (err) {
|
|
607
|
+
controller.error(err);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
async readdir(path2) {
|
|
613
|
+
this.assertNotDisposed();
|
|
614
|
+
const absPath = this.resolvePath(path2);
|
|
615
|
+
const resolved = await this.resolveSymlinks(absPath);
|
|
616
|
+
const change = this.ledger.get(resolved);
|
|
617
|
+
if (change && change.type === "delete") {
|
|
618
|
+
throw new FsError(
|
|
619
|
+
"ENOENT",
|
|
620
|
+
`no such file or directory: ${resolved}`
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
let entries = [];
|
|
624
|
+
let isVirtualDir = false;
|
|
625
|
+
if (change && change.type === "create" && change.entryType === "directory") {
|
|
626
|
+
isVirtualDir = true;
|
|
627
|
+
} else {
|
|
628
|
+
try {
|
|
629
|
+
entries = await this.adapter.readdir(resolved);
|
|
630
|
+
} catch (err) {
|
|
631
|
+
if (!isVirtualDir) throw err;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
const entryMap = /* @__PURE__ */ new Map();
|
|
635
|
+
for (const entry of entries) {
|
|
636
|
+
const key = this.capabilities.caseSensitive ? entry.name : entry.name.toLowerCase();
|
|
637
|
+
entryMap.set(key, entry);
|
|
638
|
+
}
|
|
639
|
+
const allChanges = this.ledger.getAll();
|
|
640
|
+
const dirPrefix = resolved.endsWith("/") ? resolved : resolved + "/";
|
|
641
|
+
for (const ch of allChanges) {
|
|
642
|
+
const changePath = this.getChangePath(ch);
|
|
643
|
+
if (!changePath) continue;
|
|
644
|
+
if (!changePath.startsWith(dirPrefix)) continue;
|
|
645
|
+
const relative = changePath.substring(dirPrefix.length);
|
|
646
|
+
if (relative.includes("/")) continue;
|
|
647
|
+
const name = relative;
|
|
648
|
+
const key = this.capabilities.caseSensitive ? name : name.toLowerCase();
|
|
649
|
+
if (ch.type === "delete") {
|
|
650
|
+
entryMap.delete(key);
|
|
651
|
+
} else if (ch.type === "create") {
|
|
652
|
+
const entryType = ch.entryType;
|
|
653
|
+
entryMap.set(key, {
|
|
654
|
+
name,
|
|
655
|
+
isFile: () => entryType === "file",
|
|
656
|
+
isDirectory: () => entryType === "directory",
|
|
657
|
+
isSymbolicLink: () => false
|
|
658
|
+
});
|
|
659
|
+
} else if (ch.type === "symlink") {
|
|
660
|
+
entryMap.set(key, {
|
|
661
|
+
name,
|
|
662
|
+
isFile: () => false,
|
|
663
|
+
isDirectory: () => false,
|
|
664
|
+
isSymbolicLink: () => true
|
|
665
|
+
});
|
|
666
|
+
} else if (ch.type === "update" || ch.type === "rename") {
|
|
667
|
+
if (!entryMap.has(key)) {
|
|
668
|
+
entryMap.set(key, {
|
|
669
|
+
name,
|
|
670
|
+
isFile: () => true,
|
|
671
|
+
isDirectory: () => false,
|
|
672
|
+
isSymbolicLink: () => false
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return [...entryMap.values()];
|
|
678
|
+
}
|
|
679
|
+
getChangePath(ch) {
|
|
680
|
+
switch (ch.type) {
|
|
681
|
+
case "create":
|
|
682
|
+
case "update":
|
|
683
|
+
case "delete":
|
|
684
|
+
case "chmod":
|
|
685
|
+
case "chown":
|
|
686
|
+
case "symlink":
|
|
687
|
+
return ch.path;
|
|
688
|
+
case "rename":
|
|
689
|
+
return ch.to;
|
|
690
|
+
default:
|
|
691
|
+
return void 0;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
async stat(path2) {
|
|
695
|
+
this.assertNotDisposed();
|
|
696
|
+
const absPath = this.resolvePath(path2);
|
|
697
|
+
const resolved = await this.resolveSymlinks(absPath);
|
|
698
|
+
return this.statResolved(resolved, true);
|
|
699
|
+
}
|
|
700
|
+
async lstat(path2) {
|
|
701
|
+
this.assertNotDisposed();
|
|
702
|
+
this.assertSymlinksSupported();
|
|
703
|
+
const absPath = this.resolvePath(path2);
|
|
704
|
+
return this.statResolved(absPath, false);
|
|
705
|
+
}
|
|
706
|
+
async statResolved(absPath, followSymlinks) {
|
|
707
|
+
const change = this.ledger.get(absPath);
|
|
708
|
+
if (change) {
|
|
709
|
+
if (change.type === "delete") {
|
|
710
|
+
throw new FsError(
|
|
711
|
+
"ENOENT",
|
|
712
|
+
`no such file or directory: ${absPath}`
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
if (change.type === "create") {
|
|
716
|
+
const entryType = change.entryType;
|
|
717
|
+
const now = Date.now();
|
|
718
|
+
const detail = this.ledger.getDetail(absPath);
|
|
719
|
+
const content = this.ledger.getContent(absPath);
|
|
720
|
+
const size = entryType === "file" ? content?.byteLength ?? 0 : 0;
|
|
721
|
+
return {
|
|
722
|
+
mode: detail && "mode" in detail ? detail.mode : entryType === "directory" ? DEFAULT_DIR_MODE2 : DEFAULT_FILE_MODE2,
|
|
723
|
+
uid: detail && "uid" in detail ? detail.uid : 0,
|
|
724
|
+
gid: detail && "gid" in detail ? detail.gid : 0,
|
|
725
|
+
size,
|
|
726
|
+
atimeMs: now,
|
|
727
|
+
mtimeMs: now,
|
|
728
|
+
ctimeMs: now,
|
|
729
|
+
birthtimeMs: now,
|
|
730
|
+
isFile: () => entryType === "file",
|
|
731
|
+
isDirectory: () => entryType === "directory",
|
|
732
|
+
isSymbolicLink: () => false
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
if (change.type === "symlink") {
|
|
736
|
+
const now = Date.now();
|
|
737
|
+
return {
|
|
738
|
+
mode: 511,
|
|
739
|
+
uid: 0,
|
|
740
|
+
gid: 0,
|
|
741
|
+
size: change.target.length,
|
|
742
|
+
atimeMs: now,
|
|
743
|
+
mtimeMs: now,
|
|
744
|
+
ctimeMs: now,
|
|
745
|
+
birthtimeMs: now,
|
|
746
|
+
isFile: () => false,
|
|
747
|
+
isDirectory: () => false,
|
|
748
|
+
isSymbolicLink: () => true
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
if (change.type === "update" || change.type === "rename") {
|
|
752
|
+
const detail = this.ledger.getDetail(absPath);
|
|
753
|
+
const content = this.ledger.getContent(absPath);
|
|
754
|
+
const virtualMode = this.ledger.getVirtualMode(absPath);
|
|
755
|
+
const virtualOwnership = this.ledger.getVirtualOwnership(absPath);
|
|
756
|
+
let baseStat;
|
|
757
|
+
try {
|
|
758
|
+
if (change.type === "rename") {
|
|
759
|
+
baseStat = followSymlinks ? await this.adapter.stat(change.from) : await this.adapter.lstat(change.from);
|
|
760
|
+
} else {
|
|
761
|
+
baseStat = followSymlinks ? await this.adapter.stat(absPath) : await this.adapter.lstat(absPath);
|
|
762
|
+
}
|
|
763
|
+
} catch {
|
|
764
|
+
const now = Date.now();
|
|
765
|
+
baseStat = {
|
|
766
|
+
mode: DEFAULT_FILE_MODE2,
|
|
767
|
+
uid: 0,
|
|
768
|
+
gid: 0,
|
|
769
|
+
size: 0,
|
|
770
|
+
atimeMs: now,
|
|
771
|
+
mtimeMs: now,
|
|
772
|
+
ctimeMs: now,
|
|
773
|
+
birthtimeMs: now,
|
|
774
|
+
isFile: () => true,
|
|
775
|
+
isDirectory: () => false,
|
|
776
|
+
isSymbolicLink: () => false
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
return {
|
|
780
|
+
...baseStat,
|
|
781
|
+
mode: virtualMode ?? baseStat.mode,
|
|
782
|
+
uid: virtualOwnership?.uid ?? baseStat.uid,
|
|
783
|
+
gid: virtualOwnership?.gid ?? baseStat.gid,
|
|
784
|
+
size: content?.byteLength ?? baseStat.size,
|
|
785
|
+
isFile: baseStat.isFile,
|
|
786
|
+
isDirectory: baseStat.isDirectory,
|
|
787
|
+
isSymbolicLink: baseStat.isSymbolicLink
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
if (change.type === "chmod" || change.type === "chown") {
|
|
791
|
+
const virtualMode = this.ledger.getVirtualMode(absPath);
|
|
792
|
+
const virtualOwnership = this.ledger.getVirtualOwnership(absPath);
|
|
793
|
+
const baseStat = followSymlinks ? await this.adapter.stat(absPath) : await this.adapter.lstat(absPath);
|
|
794
|
+
return {
|
|
795
|
+
...baseStat,
|
|
796
|
+
mode: virtualMode ?? baseStat.mode,
|
|
797
|
+
uid: virtualOwnership?.uid ?? baseStat.uid,
|
|
798
|
+
gid: virtualOwnership?.gid ?? baseStat.gid,
|
|
799
|
+
isFile: baseStat.isFile,
|
|
800
|
+
isDirectory: baseStat.isDirectory,
|
|
801
|
+
isSymbolicLink: baseStat.isSymbolicLink
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return followSymlinks ? this.adapter.stat(absPath) : this.adapter.lstat(absPath);
|
|
806
|
+
}
|
|
807
|
+
async readlink(path2) {
|
|
808
|
+
this.assertNotDisposed();
|
|
809
|
+
this.assertSymlinksSupported();
|
|
810
|
+
const absPath = this.resolvePath(path2);
|
|
811
|
+
const change = this.ledger.get(absPath);
|
|
812
|
+
if (change) {
|
|
813
|
+
if (change.type === "delete") {
|
|
814
|
+
throw new FsError(
|
|
815
|
+
"ENOENT",
|
|
816
|
+
`no such file or directory: ${absPath}`
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
if (change.type === "symlink") {
|
|
820
|
+
return change.target;
|
|
821
|
+
}
|
|
822
|
+
throw new FsError("EINVAL", `invalid argument: ${absPath} is not a symlink`);
|
|
823
|
+
}
|
|
824
|
+
return this.adapter.readlink(absPath);
|
|
825
|
+
}
|
|
826
|
+
async exists(path2) {
|
|
827
|
+
this.assertNotDisposed();
|
|
828
|
+
const absPath = this.resolvePath(path2);
|
|
829
|
+
try {
|
|
830
|
+
const resolved = await this.resolveSymlinks(absPath);
|
|
831
|
+
const change = this.ledger.get(resolved);
|
|
832
|
+
if (change) {
|
|
833
|
+
return change.type !== "delete";
|
|
834
|
+
}
|
|
835
|
+
return this.adapter.exists(resolved);
|
|
836
|
+
} catch {
|
|
837
|
+
return false;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
// --- Writing ---
|
|
841
|
+
async writeFile(path2, data, options) {
|
|
842
|
+
this.assertNotDisposed();
|
|
843
|
+
const absPath = this.resolvePath(path2);
|
|
844
|
+
const parentDir = absPath.substring(0, absPath.lastIndexOf("/")) || "/";
|
|
845
|
+
await checkPermission(
|
|
846
|
+
this.adapter,
|
|
847
|
+
this.ledger,
|
|
848
|
+
this.capabilities,
|
|
849
|
+
parentDir,
|
|
850
|
+
"write"
|
|
851
|
+
);
|
|
852
|
+
let resolved;
|
|
853
|
+
try {
|
|
854
|
+
resolved = await this.resolveSymlinks(absPath);
|
|
855
|
+
} catch (err) {
|
|
856
|
+
if (err instanceof FsError && err.code === "ENOENT") {
|
|
857
|
+
resolved = absPath;
|
|
858
|
+
} else {
|
|
859
|
+
throw err;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
const content = typeof data === "string" ? encoder.encode(data) : data;
|
|
863
|
+
const change = this.ledger.get(resolved);
|
|
864
|
+
if (change) {
|
|
865
|
+
if (change.type === "create" && change.entryType === "directory") {
|
|
866
|
+
throw new FsError(
|
|
867
|
+
"EISDIR",
|
|
868
|
+
`illegal operation on a directory: ${resolved}`
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
if (change.type === "delete") {
|
|
872
|
+
this.ledger.recordCreate(
|
|
873
|
+
resolved,
|
|
874
|
+
"file",
|
|
875
|
+
content,
|
|
876
|
+
options?.mode,
|
|
877
|
+
options?.uid,
|
|
878
|
+
options?.gid
|
|
879
|
+
);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
this.ledger.recordUpdate(
|
|
883
|
+
resolved,
|
|
884
|
+
content,
|
|
885
|
+
options?.mode,
|
|
886
|
+
options?.uid,
|
|
887
|
+
options?.gid
|
|
888
|
+
);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
const hostExists = await this.adapter.exists(resolved);
|
|
892
|
+
if (hostExists) {
|
|
893
|
+
try {
|
|
894
|
+
const s = await this.adapter.stat(resolved);
|
|
895
|
+
if (s.isDirectory()) {
|
|
896
|
+
throw new FsError(
|
|
897
|
+
"EISDIR",
|
|
898
|
+
`illegal operation on a directory: ${resolved}`
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
} catch (err) {
|
|
902
|
+
if (err instanceof FsError) throw err;
|
|
903
|
+
}
|
|
904
|
+
this.ledger.recordUpdate(
|
|
905
|
+
resolved,
|
|
906
|
+
content,
|
|
907
|
+
options?.mode,
|
|
908
|
+
options?.uid,
|
|
909
|
+
options?.gid
|
|
910
|
+
);
|
|
911
|
+
} else {
|
|
912
|
+
this.ledger.recordCreate(
|
|
913
|
+
resolved,
|
|
914
|
+
"file",
|
|
915
|
+
content,
|
|
916
|
+
options?.mode,
|
|
917
|
+
options?.uid,
|
|
918
|
+
options?.gid
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
async mkdir(path2, options) {
|
|
923
|
+
this.assertNotDisposed();
|
|
924
|
+
const absPath = this.resolvePath(path2);
|
|
925
|
+
if (options?.recursive) {
|
|
926
|
+
const parts = absPath.split("/").filter(Boolean);
|
|
927
|
+
let current = "";
|
|
928
|
+
for (const part of parts) {
|
|
929
|
+
current += "/" + part;
|
|
930
|
+
const change = this.ledger.get(current);
|
|
931
|
+
const existsOnHost = await this.adapter.exists(current);
|
|
932
|
+
if (change) {
|
|
933
|
+
if (change.type === "delete") {
|
|
934
|
+
this.ledger.recordCreate(
|
|
935
|
+
current,
|
|
936
|
+
"directory",
|
|
937
|
+
void 0,
|
|
938
|
+
options?.mode,
|
|
939
|
+
options?.uid,
|
|
940
|
+
options?.gid
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
if (!existsOnHost) {
|
|
946
|
+
this.ledger.recordCreate(
|
|
947
|
+
current,
|
|
948
|
+
"directory",
|
|
949
|
+
void 0,
|
|
950
|
+
options?.mode,
|
|
951
|
+
options?.uid,
|
|
952
|
+
options?.gid
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
} else {
|
|
957
|
+
const parentDir = absPath.substring(0, absPath.lastIndexOf("/")) || "/";
|
|
958
|
+
const parentChange = this.ledger.get(parentDir);
|
|
959
|
+
let parentExists = false;
|
|
960
|
+
if (parentChange) {
|
|
961
|
+
parentExists = parentChange.type !== "delete";
|
|
962
|
+
} else {
|
|
963
|
+
parentExists = await this.adapter.exists(parentDir);
|
|
964
|
+
}
|
|
965
|
+
if (!parentExists) {
|
|
966
|
+
throw new FsError(
|
|
967
|
+
"ENOENT",
|
|
968
|
+
`no such file or directory: ${parentDir}`
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
await checkPermission(
|
|
972
|
+
this.adapter,
|
|
973
|
+
this.ledger,
|
|
974
|
+
this.capabilities,
|
|
975
|
+
parentDir,
|
|
976
|
+
"write"
|
|
977
|
+
);
|
|
978
|
+
const change = this.ledger.get(absPath);
|
|
979
|
+
if (change && change.type !== "delete") {
|
|
980
|
+
throw new FsError("EEXIST", `file already exists: ${absPath}`);
|
|
981
|
+
}
|
|
982
|
+
const existsOnHost = !change && await this.adapter.exists(absPath);
|
|
983
|
+
if (existsOnHost) {
|
|
984
|
+
throw new FsError("EEXIST", `file already exists: ${absPath}`);
|
|
985
|
+
}
|
|
986
|
+
this.ledger.recordCreate(
|
|
987
|
+
absPath,
|
|
988
|
+
"directory",
|
|
989
|
+
void 0,
|
|
990
|
+
options?.mode,
|
|
991
|
+
options?.uid,
|
|
992
|
+
options?.gid
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
// --- Deleting ---
|
|
997
|
+
async rm(path2, options) {
|
|
998
|
+
this.assertNotDisposed();
|
|
999
|
+
const absPath = this.resolvePath(path2);
|
|
1000
|
+
const change = this.ledger.get(absPath);
|
|
1001
|
+
if (change && change.type === "delete") {
|
|
1002
|
+
if (options?.force) return;
|
|
1003
|
+
throw new FsError(
|
|
1004
|
+
"ENOENT",
|
|
1005
|
+
`no such file or directory: ${absPath}`
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
const existsOnHost = !change && await this.adapter.exists(absPath);
|
|
1009
|
+
if (!change && !existsOnHost) {
|
|
1010
|
+
if (options?.force) return;
|
|
1011
|
+
throw new FsError(
|
|
1012
|
+
"ENOENT",
|
|
1013
|
+
`no such file or directory: ${absPath}`
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
const parentDir = absPath.substring(0, absPath.lastIndexOf("/")) || "/";
|
|
1017
|
+
await checkPermission(
|
|
1018
|
+
this.adapter,
|
|
1019
|
+
this.ledger,
|
|
1020
|
+
this.capabilities,
|
|
1021
|
+
parentDir,
|
|
1022
|
+
"write"
|
|
1023
|
+
);
|
|
1024
|
+
let entryType = "file";
|
|
1025
|
+
let isDir = false;
|
|
1026
|
+
if (change) {
|
|
1027
|
+
if (change.type === "create" && change.entryType === "directory") {
|
|
1028
|
+
isDir = true;
|
|
1029
|
+
entryType = "directory";
|
|
1030
|
+
} else if (change.type === "symlink") {
|
|
1031
|
+
entryType = "symlink";
|
|
1032
|
+
}
|
|
1033
|
+
} else if (existsOnHost) {
|
|
1034
|
+
try {
|
|
1035
|
+
const s = await this.adapter.lstat(absPath);
|
|
1036
|
+
if (s.isDirectory()) {
|
|
1037
|
+
isDir = true;
|
|
1038
|
+
entryType = "directory";
|
|
1039
|
+
} else if (s.isSymbolicLink()) {
|
|
1040
|
+
entryType = "symlink";
|
|
1041
|
+
}
|
|
1042
|
+
} catch {
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
if (isDir && !options?.recursive) {
|
|
1046
|
+
throw new FsError("EISDIR", `is a directory: ${absPath}`);
|
|
1047
|
+
}
|
|
1048
|
+
if (isDir && options?.recursive) {
|
|
1049
|
+
const allChanges = this.ledger.getAll();
|
|
1050
|
+
const dirPrefix = absPath + "/";
|
|
1051
|
+
for (const ch of allChanges) {
|
|
1052
|
+
const chPath = this.getChangePath(ch);
|
|
1053
|
+
if (chPath && chPath.startsWith(dirPrefix)) {
|
|
1054
|
+
let childType = "file";
|
|
1055
|
+
if (ch.type === "create" && ch.entryType === "directory") {
|
|
1056
|
+
childType = "directory";
|
|
1057
|
+
} else if (ch.type === "symlink") {
|
|
1058
|
+
childType = "symlink";
|
|
1059
|
+
}
|
|
1060
|
+
this.ledger.recordDelete(chPath, childType);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
this.ledger.recordDelete(absPath, entryType);
|
|
1065
|
+
}
|
|
1066
|
+
async rmdir(path2) {
|
|
1067
|
+
this.assertNotDisposed();
|
|
1068
|
+
const absPath = this.resolvePath(path2);
|
|
1069
|
+
const change = this.ledger.get(absPath);
|
|
1070
|
+
if (change && change.type === "delete") {
|
|
1071
|
+
throw new FsError(
|
|
1072
|
+
"ENOENT",
|
|
1073
|
+
`no such file or directory: ${absPath}`
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
let isDir = false;
|
|
1077
|
+
if (change) {
|
|
1078
|
+
isDir = change.type === "create" && change.entryType === "directory";
|
|
1079
|
+
} else {
|
|
1080
|
+
try {
|
|
1081
|
+
const s = await this.adapter.lstat(absPath);
|
|
1082
|
+
isDir = s.isDirectory();
|
|
1083
|
+
} catch {
|
|
1084
|
+
throw new FsError(
|
|
1085
|
+
"ENOENT",
|
|
1086
|
+
`no such file or directory: ${absPath}`
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
if (!isDir) {
|
|
1091
|
+
throw new FsError("ENOTDIR", `not a directory: ${absPath}`);
|
|
1092
|
+
}
|
|
1093
|
+
const entries = await this.readdir(absPath);
|
|
1094
|
+
if (entries.length > 0) {
|
|
1095
|
+
throw new FsError("ENOTEMPTY", `directory not empty: ${absPath}`);
|
|
1096
|
+
}
|
|
1097
|
+
this.ledger.recordDelete(absPath, "directory");
|
|
1098
|
+
}
|
|
1099
|
+
// --- Renaming ---
|
|
1100
|
+
async rename(from, to) {
|
|
1101
|
+
this.assertNotDisposed();
|
|
1102
|
+
const absFrom = this.resolvePath(from);
|
|
1103
|
+
const absTo = this.resolvePath(to);
|
|
1104
|
+
const srcChange = this.ledger.get(absFrom);
|
|
1105
|
+
if (srcChange && srcChange.type === "delete") {
|
|
1106
|
+
throw new FsError(
|
|
1107
|
+
"ENOENT",
|
|
1108
|
+
`no such file or directory: ${absFrom}`
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
if (!srcChange) {
|
|
1112
|
+
const srcExists = await this.adapter.exists(absFrom);
|
|
1113
|
+
if (!srcExists) {
|
|
1114
|
+
throw new FsError(
|
|
1115
|
+
"ENOENT",
|
|
1116
|
+
`no such file or directory: ${absFrom}`
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
const destChange = this.ledger.get(absTo);
|
|
1121
|
+
if (destChange && destChange.type !== "delete") {
|
|
1122
|
+
let srcIsDir = false;
|
|
1123
|
+
let destIsDir = false;
|
|
1124
|
+
if (srcChange && srcChange.type === "create") {
|
|
1125
|
+
srcIsDir = srcChange.entryType === "directory";
|
|
1126
|
+
} else if (!srcChange) {
|
|
1127
|
+
try {
|
|
1128
|
+
const s = await this.adapter.lstat(absFrom);
|
|
1129
|
+
srcIsDir = s.isDirectory();
|
|
1130
|
+
} catch {
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
if (destChange.type === "create") {
|
|
1134
|
+
destIsDir = destChange.entryType === "directory";
|
|
1135
|
+
}
|
|
1136
|
+
if (srcIsDir !== destIsDir) {
|
|
1137
|
+
if (destIsDir) {
|
|
1138
|
+
throw new FsError("EISDIR", `cannot overwrite directory with non-directory: ${absTo}`);
|
|
1139
|
+
} else {
|
|
1140
|
+
throw new FsError("ENOTDIR", `cannot overwrite non-directory with directory: ${absTo}`);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
this.ledger.recordRename(absFrom, absTo);
|
|
1145
|
+
}
|
|
1146
|
+
// --- Symlinks ---
|
|
1147
|
+
async symlink(target, path2) {
|
|
1148
|
+
this.assertNotDisposed();
|
|
1149
|
+
this.assertSymlinksSupported();
|
|
1150
|
+
const absPath = this.resolvePath(path2);
|
|
1151
|
+
const parentDir = absPath.substring(0, absPath.lastIndexOf("/")) || "/";
|
|
1152
|
+
await checkPermission(
|
|
1153
|
+
this.adapter,
|
|
1154
|
+
this.ledger,
|
|
1155
|
+
this.capabilities,
|
|
1156
|
+
parentDir,
|
|
1157
|
+
"write"
|
|
1158
|
+
);
|
|
1159
|
+
const change = this.ledger.get(absPath);
|
|
1160
|
+
if (change && change.type !== "delete") {
|
|
1161
|
+
throw new FsError("EEXIST", `file already exists: ${absPath}`);
|
|
1162
|
+
}
|
|
1163
|
+
if (!change) {
|
|
1164
|
+
const existsOnHost = await this.adapter.exists(absPath);
|
|
1165
|
+
if (existsOnHost) {
|
|
1166
|
+
throw new FsError("EEXIST", `file already exists: ${absPath}`);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
this.ledger.recordSymlink(absPath, target);
|
|
1170
|
+
}
|
|
1171
|
+
// --- Permissions ---
|
|
1172
|
+
async chmod(path2, mode) {
|
|
1173
|
+
this.assertNotDisposed();
|
|
1174
|
+
const absPath = this.resolvePath(path2);
|
|
1175
|
+
const resolved = await this.resolveSymlinks(absPath);
|
|
1176
|
+
const change = this.ledger.get(resolved);
|
|
1177
|
+
if (change && change.type === "delete") {
|
|
1178
|
+
throw new FsError(
|
|
1179
|
+
"ENOENT",
|
|
1180
|
+
`no such file or directory: ${resolved}`
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
if (!change) {
|
|
1184
|
+
const existsOnHost = await this.adapter.exists(resolved);
|
|
1185
|
+
if (!existsOnHost) {
|
|
1186
|
+
throw new FsError(
|
|
1187
|
+
"ENOENT",
|
|
1188
|
+
`no such file or directory: ${resolved}`
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
this.ledger.recordChmod(resolved, mode);
|
|
1193
|
+
}
|
|
1194
|
+
async chown(path2, uid, gid) {
|
|
1195
|
+
this.assertNotDisposed();
|
|
1196
|
+
const absPath = this.resolvePath(path2);
|
|
1197
|
+
const resolved = await this.resolveSymlinks(absPath);
|
|
1198
|
+
const change = this.ledger.get(resolved);
|
|
1199
|
+
if (change && change.type === "delete") {
|
|
1200
|
+
throw new FsError(
|
|
1201
|
+
"ENOENT",
|
|
1202
|
+
`no such file or directory: ${resolved}`
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
if (!change) {
|
|
1206
|
+
const existsOnHost = await this.adapter.exists(resolved);
|
|
1207
|
+
if (!existsOnHost) {
|
|
1208
|
+
throw new FsError(
|
|
1209
|
+
"ENOENT",
|
|
1210
|
+
`no such file or directory: ${resolved}`
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
this.ledger.recordChown(resolved, uid, gid);
|
|
1215
|
+
}
|
|
1216
|
+
async lchown(path2, uid, gid) {
|
|
1217
|
+
this.assertNotDisposed();
|
|
1218
|
+
this.assertSymlinksSupported();
|
|
1219
|
+
const absPath = this.resolvePath(path2);
|
|
1220
|
+
const change = this.ledger.get(absPath);
|
|
1221
|
+
if (change && change.type === "delete") {
|
|
1222
|
+
throw new FsError(
|
|
1223
|
+
"ENOENT",
|
|
1224
|
+
`no such file or directory: ${absPath}`
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
if (!change) {
|
|
1228
|
+
const existsOnHost = await this.adapter.exists(absPath);
|
|
1229
|
+
if (!existsOnHost) {
|
|
1230
|
+
throw new FsError(
|
|
1231
|
+
"ENOENT",
|
|
1232
|
+
`no such file or directory: ${absPath}`
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
this.ledger.recordChown(absPath, uid, gid);
|
|
1237
|
+
}
|
|
1238
|
+
async getOwner(path2) {
|
|
1239
|
+
this.assertNotDisposed();
|
|
1240
|
+
const absPath = this.resolvePath(path2);
|
|
1241
|
+
const resolved = await this.resolveSymlinks(absPath);
|
|
1242
|
+
const ownership = this.ledger.getVirtualOwnership(resolved);
|
|
1243
|
+
if (ownership) return ownership;
|
|
1244
|
+
const s = await this.adapter.stat(resolved);
|
|
1245
|
+
return { uid: s.uid, gid: s.gid };
|
|
1246
|
+
}
|
|
1247
|
+
// --- Changes ---
|
|
1248
|
+
getChanges() {
|
|
1249
|
+
this.assertNotDisposed();
|
|
1250
|
+
return this.ledger.getAll();
|
|
1251
|
+
}
|
|
1252
|
+
async getChangeDetail(path2) {
|
|
1253
|
+
this.assertNotDisposed();
|
|
1254
|
+
const absPath = this.resolvePath(path2);
|
|
1255
|
+
return this.ledger.getDetail(absPath);
|
|
1256
|
+
}
|
|
1257
|
+
// --- Apply ---
|
|
1258
|
+
async apply(options) {
|
|
1259
|
+
this.assertNotDisposed();
|
|
1260
|
+
const transaction = options?.transaction ?? false;
|
|
1261
|
+
const allDetails = this.ledger.getAllDetails();
|
|
1262
|
+
if (allDetails.length === 0) {
|
|
1263
|
+
return { applied: 0, errors: [] };
|
|
1264
|
+
}
|
|
1265
|
+
const dirCreates = [];
|
|
1266
|
+
const fileCreatesUpdates = [];
|
|
1267
|
+
const renames = [];
|
|
1268
|
+
const permChanges = [];
|
|
1269
|
+
const fileSymlinkDeletes = [];
|
|
1270
|
+
const dirDeletes = [];
|
|
1271
|
+
for (const detail of allDetails) {
|
|
1272
|
+
switch (detail.type) {
|
|
1273
|
+
case "create":
|
|
1274
|
+
if (detail.entryType === "directory") {
|
|
1275
|
+
dirCreates.push(detail);
|
|
1276
|
+
} else {
|
|
1277
|
+
fileCreatesUpdates.push(detail);
|
|
1278
|
+
}
|
|
1279
|
+
break;
|
|
1280
|
+
case "update":
|
|
1281
|
+
fileCreatesUpdates.push(detail);
|
|
1282
|
+
break;
|
|
1283
|
+
case "rename":
|
|
1284
|
+
renames.push(detail);
|
|
1285
|
+
break;
|
|
1286
|
+
case "chmod":
|
|
1287
|
+
case "chown":
|
|
1288
|
+
permChanges.push(detail);
|
|
1289
|
+
break;
|
|
1290
|
+
case "delete":
|
|
1291
|
+
if (detail.entryType === "directory") {
|
|
1292
|
+
dirDeletes.push(detail);
|
|
1293
|
+
} else {
|
|
1294
|
+
fileSymlinkDeletes.push(detail);
|
|
1295
|
+
}
|
|
1296
|
+
break;
|
|
1297
|
+
case "symlink":
|
|
1298
|
+
fileCreatesUpdates.push(detail);
|
|
1299
|
+
break;
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
dirCreates.sort((a, b) => {
|
|
1303
|
+
const aPath = a.type === "create" ? a.path : "";
|
|
1304
|
+
const bPath = b.type === "create" ? b.path : "";
|
|
1305
|
+
return aPath.split("/").length - bPath.split("/").length;
|
|
1306
|
+
});
|
|
1307
|
+
dirDeletes.sort((a, b) => {
|
|
1308
|
+
const aPath = a.type === "delete" ? a.path : "";
|
|
1309
|
+
const bPath = b.type === "delete" ? b.path : "";
|
|
1310
|
+
return bPath.split("/").length - aPath.split("/").length;
|
|
1311
|
+
});
|
|
1312
|
+
const plan = [
|
|
1313
|
+
...dirCreates,
|
|
1314
|
+
...fileCreatesUpdates,
|
|
1315
|
+
...renames,
|
|
1316
|
+
...permChanges,
|
|
1317
|
+
...fileSymlinkDeletes,
|
|
1318
|
+
...dirDeletes
|
|
1319
|
+
];
|
|
1320
|
+
if (transaction) {
|
|
1321
|
+
return this.applyTransaction(plan);
|
|
1322
|
+
} else {
|
|
1323
|
+
return this.applyBestEffort(plan);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
async applyBestEffort(plan) {
|
|
1327
|
+
let applied = 0;
|
|
1328
|
+
const errors = [];
|
|
1329
|
+
for (const detail of plan) {
|
|
1330
|
+
try {
|
|
1331
|
+
await this.applyOne(detail);
|
|
1332
|
+
applied++;
|
|
1333
|
+
} catch (err) {
|
|
1334
|
+
errors.push({
|
|
1335
|
+
change: this.detailToChangeEntry(detail),
|
|
1336
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
this.ledger.clear();
|
|
1341
|
+
return { applied, errors };
|
|
1342
|
+
}
|
|
1343
|
+
async applyTransaction(plan) {
|
|
1344
|
+
const originals = [];
|
|
1345
|
+
let applied = 0;
|
|
1346
|
+
for (const detail of plan) {
|
|
1347
|
+
const path2 = this.getDetailPath(detail);
|
|
1348
|
+
if (path2) {
|
|
1349
|
+
try {
|
|
1350
|
+
const existed = await this.adapter.exists(path2);
|
|
1351
|
+
let content;
|
|
1352
|
+
let stat2;
|
|
1353
|
+
if (existed) {
|
|
1354
|
+
try {
|
|
1355
|
+
stat2 = await this.adapter.lstat(path2);
|
|
1356
|
+
if (stat2.isFile()) {
|
|
1357
|
+
content = await this.adapter.readFile(path2);
|
|
1358
|
+
}
|
|
1359
|
+
} catch {
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
originals.push({
|
|
1363
|
+
detail,
|
|
1364
|
+
backup: { existed, content, stat: stat2 }
|
|
1365
|
+
});
|
|
1366
|
+
} catch {
|
|
1367
|
+
originals.push({
|
|
1368
|
+
detail,
|
|
1369
|
+
backup: { existed: false }
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
try {
|
|
1374
|
+
await this.applyOne(detail);
|
|
1375
|
+
applied++;
|
|
1376
|
+
} catch (err) {
|
|
1377
|
+
const rollbackErrors = [];
|
|
1378
|
+
for (let i = originals.length - 2; i >= 0; i--) {
|
|
1379
|
+
const orig = originals[i];
|
|
1380
|
+
try {
|
|
1381
|
+
await this.rollbackOne(orig.detail, orig.backup);
|
|
1382
|
+
} catch (rbErr) {
|
|
1383
|
+
rollbackErrors.push({
|
|
1384
|
+
change: this.detailToChangeEntry(orig.detail),
|
|
1385
|
+
error: rbErr instanceof Error ? rbErr : new Error(String(rbErr))
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
throw new TransactionError(
|
|
1390
|
+
`Transaction failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1391
|
+
this.detailToChangeEntry(detail),
|
|
1392
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
1393
|
+
rollbackErrors
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
this.ledger.clear();
|
|
1398
|
+
return { applied, errors: [] };
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Convert a flat ChangeDetail back to a ChangeEntry for error reporting.
|
|
1402
|
+
*/
|
|
1403
|
+
detailToChangeEntry(detail) {
|
|
1404
|
+
switch (detail.type) {
|
|
1405
|
+
case "create":
|
|
1406
|
+
return { type: "create", entryType: detail.entryType, path: detail.path };
|
|
1407
|
+
case "update":
|
|
1408
|
+
return { type: "update", path: detail.path };
|
|
1409
|
+
case "delete":
|
|
1410
|
+
return { type: "delete", entryType: detail.entryType, path: detail.path };
|
|
1411
|
+
case "rename":
|
|
1412
|
+
return { type: "rename", from: detail.from, to: detail.to };
|
|
1413
|
+
case "chmod":
|
|
1414
|
+
return { type: "chmod", path: detail.path, mode: detail.mode };
|
|
1415
|
+
case "chown":
|
|
1416
|
+
return { type: "chown", path: detail.path, uid: detail.uid, gid: detail.gid };
|
|
1417
|
+
case "symlink":
|
|
1418
|
+
return { type: "symlink", path: detail.path, target: detail.target };
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Get the primary path from a ChangeDetail.
|
|
1423
|
+
*/
|
|
1424
|
+
getDetailPath(detail) {
|
|
1425
|
+
switch (detail.type) {
|
|
1426
|
+
case "create":
|
|
1427
|
+
case "update":
|
|
1428
|
+
case "delete":
|
|
1429
|
+
case "chmod":
|
|
1430
|
+
case "chown":
|
|
1431
|
+
case "symlink":
|
|
1432
|
+
return detail.path;
|
|
1433
|
+
case "rename":
|
|
1434
|
+
return detail.to;
|
|
1435
|
+
default:
|
|
1436
|
+
return void 0;
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
async rollbackOne(detail, backup) {
|
|
1440
|
+
const path2 = this.getDetailPath(detail);
|
|
1441
|
+
if (!path2) return;
|
|
1442
|
+
if (backup.existed) {
|
|
1443
|
+
if (backup.content !== void 0) {
|
|
1444
|
+
await this.adapter.writeFile(path2, backup.content, {
|
|
1445
|
+
mode: backup.stat?.mode
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
if (backup.stat) {
|
|
1449
|
+
try {
|
|
1450
|
+
await this.adapter.chmod(path2, backup.stat.mode);
|
|
1451
|
+
} catch {
|
|
1452
|
+
}
|
|
1453
|
+
try {
|
|
1454
|
+
await this.adapter.chown(path2, backup.stat.uid, backup.stat.gid);
|
|
1455
|
+
} catch {
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
} else {
|
|
1459
|
+
try {
|
|
1460
|
+
await this.adapter.rm(path2, { recursive: true, force: true });
|
|
1461
|
+
} catch {
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
async applyOne(detail) {
|
|
1466
|
+
switch (detail.type) {
|
|
1467
|
+
case "create": {
|
|
1468
|
+
if (detail.entryType === "directory") {
|
|
1469
|
+
await this.adapter.mkdir(detail.path, {
|
|
1470
|
+
recursive: true,
|
|
1471
|
+
mode: detail.mode,
|
|
1472
|
+
uid: detail.uid,
|
|
1473
|
+
gid: detail.gid
|
|
1474
|
+
});
|
|
1475
|
+
} else {
|
|
1476
|
+
await this.adapter.writeFile(
|
|
1477
|
+
detail.path,
|
|
1478
|
+
detail.content,
|
|
1479
|
+
{
|
|
1480
|
+
mode: detail.mode,
|
|
1481
|
+
uid: detail.uid,
|
|
1482
|
+
gid: detail.gid
|
|
1483
|
+
}
|
|
1484
|
+
);
|
|
1485
|
+
}
|
|
1486
|
+
break;
|
|
1487
|
+
}
|
|
1488
|
+
case "update":
|
|
1489
|
+
await this.adapter.writeFile(
|
|
1490
|
+
detail.path,
|
|
1491
|
+
detail.content,
|
|
1492
|
+
{
|
|
1493
|
+
mode: detail.mode,
|
|
1494
|
+
uid: detail.uid,
|
|
1495
|
+
gid: detail.gid
|
|
1496
|
+
}
|
|
1497
|
+
);
|
|
1498
|
+
break;
|
|
1499
|
+
case "delete":
|
|
1500
|
+
await this.adapter.rm(detail.path, {
|
|
1501
|
+
recursive: true,
|
|
1502
|
+
force: true
|
|
1503
|
+
});
|
|
1504
|
+
break;
|
|
1505
|
+
case "rename":
|
|
1506
|
+
await this.adapter.rename(detail.from, detail.to);
|
|
1507
|
+
break;
|
|
1508
|
+
case "chmod":
|
|
1509
|
+
await this.adapter.chmod(detail.path, detail.mode);
|
|
1510
|
+
break;
|
|
1511
|
+
case "chown":
|
|
1512
|
+
await this.adapter.chown(detail.path, detail.uid, detail.gid);
|
|
1513
|
+
break;
|
|
1514
|
+
case "symlink":
|
|
1515
|
+
await this.adapter.symlink(detail.target, detail.path);
|
|
1516
|
+
break;
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
// --- Lifecycle ---
|
|
1520
|
+
dispose() {
|
|
1521
|
+
this.disposed = true;
|
|
1522
|
+
this.ledger.clear();
|
|
1523
|
+
}
|
|
1524
|
+
reset() {
|
|
1525
|
+
this.assertNotDisposed();
|
|
1526
|
+
this.ledger.clear();
|
|
1527
|
+
}
|
|
1528
|
+
};
|
|
1529
|
+
|
|
1530
|
+
// src/adapters/local.ts
|
|
1531
|
+
import * as fs from "fs/promises";
|
|
1532
|
+
import * as nodeFs from "fs";
|
|
1533
|
+
import * as path from "path";
|
|
1534
|
+
var LocalAdapter = class {
|
|
1535
|
+
_capabilities = {
|
|
1536
|
+
permissions: true,
|
|
1537
|
+
symlinks: true,
|
|
1538
|
+
caseSensitive: true
|
|
1539
|
+
};
|
|
1540
|
+
async initialize(root) {
|
|
1541
|
+
const testName = `.catmint-case-probe-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1542
|
+
const testPath = path.join(root, testName);
|
|
1543
|
+
const testPathUpper = path.join(root, testName.toUpperCase());
|
|
1544
|
+
try {
|
|
1545
|
+
await fs.writeFile(testPath, "");
|
|
1546
|
+
let caseSensitive = true;
|
|
1547
|
+
try {
|
|
1548
|
+
await fs.access(testPathUpper);
|
|
1549
|
+
caseSensitive = false;
|
|
1550
|
+
} catch {
|
|
1551
|
+
caseSensitive = true;
|
|
1552
|
+
}
|
|
1553
|
+
this._capabilities = {
|
|
1554
|
+
permissions: true,
|
|
1555
|
+
symlinks: true,
|
|
1556
|
+
caseSensitive
|
|
1557
|
+
};
|
|
1558
|
+
} finally {
|
|
1559
|
+
try {
|
|
1560
|
+
await fs.unlink(testPath);
|
|
1561
|
+
} catch {
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
capabilities() {
|
|
1566
|
+
return this._capabilities;
|
|
1567
|
+
}
|
|
1568
|
+
async readFile(filePath) {
|
|
1569
|
+
try {
|
|
1570
|
+
const buffer = await fs.readFile(filePath);
|
|
1571
|
+
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
1572
|
+
} catch (err) {
|
|
1573
|
+
throw this.mapError(err, filePath);
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
createReadStream(filePath) {
|
|
1577
|
+
const nodeStream = nodeFs.createReadStream(filePath);
|
|
1578
|
+
return new ReadableStream({
|
|
1579
|
+
start(controller) {
|
|
1580
|
+
nodeStream.on("data", (chunk) => {
|
|
1581
|
+
controller.enqueue(
|
|
1582
|
+
new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)
|
|
1583
|
+
);
|
|
1584
|
+
});
|
|
1585
|
+
nodeStream.on("end", () => {
|
|
1586
|
+
controller.close();
|
|
1587
|
+
});
|
|
1588
|
+
nodeStream.on("error", (err) => {
|
|
1589
|
+
controller.error(err);
|
|
1590
|
+
});
|
|
1591
|
+
},
|
|
1592
|
+
cancel() {
|
|
1593
|
+
nodeStream.destroy();
|
|
1594
|
+
}
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
async readdir(dirPath) {
|
|
1598
|
+
try {
|
|
1599
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
1600
|
+
return entries.map((entry) => ({
|
|
1601
|
+
name: entry.name,
|
|
1602
|
+
isFile: () => entry.isFile(),
|
|
1603
|
+
isDirectory: () => entry.isDirectory(),
|
|
1604
|
+
isSymbolicLink: () => entry.isSymbolicLink()
|
|
1605
|
+
}));
|
|
1606
|
+
} catch (err) {
|
|
1607
|
+
throw this.mapError(err, dirPath);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
async stat(filePath) {
|
|
1611
|
+
try {
|
|
1612
|
+
const s = await fs.stat(filePath);
|
|
1613
|
+
return {
|
|
1614
|
+
mode: s.mode,
|
|
1615
|
+
uid: s.uid,
|
|
1616
|
+
gid: s.gid,
|
|
1617
|
+
size: s.size,
|
|
1618
|
+
atimeMs: s.atimeMs,
|
|
1619
|
+
mtimeMs: s.mtimeMs,
|
|
1620
|
+
ctimeMs: s.ctimeMs,
|
|
1621
|
+
birthtimeMs: s.birthtimeMs,
|
|
1622
|
+
isFile: () => s.isFile(),
|
|
1623
|
+
isDirectory: () => s.isDirectory(),
|
|
1624
|
+
isSymbolicLink: () => s.isSymbolicLink()
|
|
1625
|
+
};
|
|
1626
|
+
} catch (err) {
|
|
1627
|
+
throw this.mapError(err, filePath);
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
async lstat(filePath) {
|
|
1631
|
+
try {
|
|
1632
|
+
const s = await fs.lstat(filePath);
|
|
1633
|
+
return {
|
|
1634
|
+
mode: s.mode,
|
|
1635
|
+
uid: s.uid,
|
|
1636
|
+
gid: s.gid,
|
|
1637
|
+
size: s.size,
|
|
1638
|
+
atimeMs: s.atimeMs,
|
|
1639
|
+
mtimeMs: s.mtimeMs,
|
|
1640
|
+
ctimeMs: s.ctimeMs,
|
|
1641
|
+
birthtimeMs: s.birthtimeMs,
|
|
1642
|
+
isFile: () => s.isFile(),
|
|
1643
|
+
isDirectory: () => s.isDirectory(),
|
|
1644
|
+
isSymbolicLink: () => s.isSymbolicLink()
|
|
1645
|
+
};
|
|
1646
|
+
} catch (err) {
|
|
1647
|
+
throw this.mapError(err, filePath);
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
async readlink(filePath) {
|
|
1651
|
+
try {
|
|
1652
|
+
return await fs.readlink(filePath);
|
|
1653
|
+
} catch (err) {
|
|
1654
|
+
throw this.mapError(err, filePath);
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
async exists(filePath) {
|
|
1658
|
+
try {
|
|
1659
|
+
await fs.access(filePath);
|
|
1660
|
+
return true;
|
|
1661
|
+
} catch {
|
|
1662
|
+
return false;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
async writeFile(filePath, data, options) {
|
|
1666
|
+
try {
|
|
1667
|
+
await fs.writeFile(filePath, data, { mode: options?.mode });
|
|
1668
|
+
if (options?.uid !== void 0 && options?.gid !== void 0 && (options.uid !== process.getuid?.() || options.gid !== process.getgid?.())) {
|
|
1669
|
+
try {
|
|
1670
|
+
await fs.chown(filePath, options.uid, options.gid);
|
|
1671
|
+
} catch {
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
} catch (err) {
|
|
1675
|
+
throw this.mapError(err, filePath);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
async mkdir(dirPath, options) {
|
|
1679
|
+
try {
|
|
1680
|
+
await fs.mkdir(dirPath, {
|
|
1681
|
+
recursive: options?.recursive,
|
|
1682
|
+
mode: options?.mode
|
|
1683
|
+
});
|
|
1684
|
+
if (options?.uid !== void 0 && options?.gid !== void 0) {
|
|
1685
|
+
await fs.chown(dirPath, options.uid, options.gid);
|
|
1686
|
+
}
|
|
1687
|
+
} catch (err) {
|
|
1688
|
+
throw this.mapError(err, dirPath);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
async rm(filePath, options) {
|
|
1692
|
+
try {
|
|
1693
|
+
await fs.rm(filePath, {
|
|
1694
|
+
recursive: options?.recursive,
|
|
1695
|
+
force: options?.force
|
|
1696
|
+
});
|
|
1697
|
+
} catch (err) {
|
|
1698
|
+
throw this.mapError(err, filePath);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
async rmdir(dirPath) {
|
|
1702
|
+
try {
|
|
1703
|
+
await fs.rmdir(dirPath);
|
|
1704
|
+
} catch (err) {
|
|
1705
|
+
throw this.mapError(err, dirPath);
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
async rename(from, to) {
|
|
1709
|
+
try {
|
|
1710
|
+
await fs.rename(from, to);
|
|
1711
|
+
} catch (err) {
|
|
1712
|
+
throw this.mapError(err, from);
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
async symlink(target, linkPath) {
|
|
1716
|
+
try {
|
|
1717
|
+
await fs.symlink(target, linkPath);
|
|
1718
|
+
} catch (err) {
|
|
1719
|
+
throw this.mapError(err, linkPath);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
async chmod(filePath, mode) {
|
|
1723
|
+
try {
|
|
1724
|
+
await fs.chmod(filePath, mode);
|
|
1725
|
+
} catch (err) {
|
|
1726
|
+
throw this.mapError(err, filePath);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
async chown(filePath, uid, gid) {
|
|
1730
|
+
try {
|
|
1731
|
+
await fs.chown(filePath, uid, gid);
|
|
1732
|
+
} catch (err) {
|
|
1733
|
+
throw this.mapError(err, filePath);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
async lchown(filePath, uid, gid) {
|
|
1737
|
+
try {
|
|
1738
|
+
await fs.lchown(filePath, uid, gid);
|
|
1739
|
+
} catch (err) {
|
|
1740
|
+
throw this.mapError(err, filePath);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
async checkPermission(filePath, op) {
|
|
1744
|
+
let mode;
|
|
1745
|
+
switch (op) {
|
|
1746
|
+
case "read":
|
|
1747
|
+
mode = nodeFs.constants.R_OK;
|
|
1748
|
+
break;
|
|
1749
|
+
case "write":
|
|
1750
|
+
mode = nodeFs.constants.W_OK;
|
|
1751
|
+
break;
|
|
1752
|
+
case "execute":
|
|
1753
|
+
mode = nodeFs.constants.X_OK;
|
|
1754
|
+
break;
|
|
1755
|
+
}
|
|
1756
|
+
try {
|
|
1757
|
+
await fs.access(filePath, mode);
|
|
1758
|
+
} catch (err) {
|
|
1759
|
+
throw this.mapError(err, filePath);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
mapError(err, filePath) {
|
|
1763
|
+
if (err instanceof FsError) return err;
|
|
1764
|
+
const nodeErr = err;
|
|
1765
|
+
const code = nodeErr?.code;
|
|
1766
|
+
switch (code) {
|
|
1767
|
+
case "ENOENT":
|
|
1768
|
+
return new FsError(
|
|
1769
|
+
"ENOENT",
|
|
1770
|
+
`no such file or directory: ${filePath}`
|
|
1771
|
+
);
|
|
1772
|
+
case "EACCES":
|
|
1773
|
+
return new FsError("EACCES", `permission denied: ${filePath}`);
|
|
1774
|
+
case "EPERM":
|
|
1775
|
+
return new FsError(
|
|
1776
|
+
"EPERM",
|
|
1777
|
+
`operation not permitted: ${filePath}`
|
|
1778
|
+
);
|
|
1779
|
+
case "EEXIST":
|
|
1780
|
+
return new FsError(
|
|
1781
|
+
"EEXIST",
|
|
1782
|
+
`file already exists: ${filePath}`
|
|
1783
|
+
);
|
|
1784
|
+
case "ENOTDIR":
|
|
1785
|
+
return new FsError("ENOTDIR", `not a directory: ${filePath}`);
|
|
1786
|
+
case "EISDIR":
|
|
1787
|
+
return new FsError(
|
|
1788
|
+
"EISDIR",
|
|
1789
|
+
`is a directory: ${filePath}`
|
|
1790
|
+
);
|
|
1791
|
+
case "ELOOP":
|
|
1792
|
+
return new FsError(
|
|
1793
|
+
"ELOOP",
|
|
1794
|
+
`too many symbolic links: ${filePath}`
|
|
1795
|
+
);
|
|
1796
|
+
case "EINVAL":
|
|
1797
|
+
return new FsError(
|
|
1798
|
+
"EINVAL",
|
|
1799
|
+
`invalid argument: ${filePath}`
|
|
1800
|
+
);
|
|
1801
|
+
case "ENOTEMPTY":
|
|
1802
|
+
return new FsError(
|
|
1803
|
+
"ENOTEMPTY",
|
|
1804
|
+
`directory not empty: ${filePath}`
|
|
1805
|
+
);
|
|
1806
|
+
default:
|
|
1807
|
+
return new FsError(
|
|
1808
|
+
"ENOSYS",
|
|
1809
|
+
`${code ?? "unknown error"}: ${filePath}: ${nodeErr?.message ?? ""}`
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
};
|
|
1814
|
+
|
|
1815
|
+
// src/index.ts
|
|
1816
|
+
async function createLayer(options) {
|
|
1817
|
+
const { root, adapter: userAdapter } = options;
|
|
1818
|
+
if (!root.startsWith("/")) {
|
|
1819
|
+
throw new FsError("EINVAL", "root must be an absolute path");
|
|
1820
|
+
}
|
|
1821
|
+
const adapter = userAdapter ?? new LocalAdapter();
|
|
1822
|
+
if (adapter.initialize) {
|
|
1823
|
+
await adapter.initialize(root);
|
|
1824
|
+
}
|
|
1825
|
+
const capabilities = adapter.capabilities();
|
|
1826
|
+
return new Layer(root, adapter, capabilities);
|
|
1827
|
+
}
|
|
1828
|
+
export {
|
|
1829
|
+
FsError,
|
|
1830
|
+
Layer,
|
|
1831
|
+
Ledger,
|
|
1832
|
+
LocalAdapter,
|
|
1833
|
+
TransactionError,
|
|
1834
|
+
createLayer
|
|
1835
|
+
};
|