@componentor/fs 3.0.9 → 3.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1678 @@
1
+ // src/vfs/layout.ts
2
+ var VFS_MAGIC = 1447449377;
3
+ var VFS_VERSION = 1;
4
+ var DEFAULT_BLOCK_SIZE = 4096;
5
+ var DEFAULT_INODE_COUNT = 1e4;
6
+ var INODE_SIZE = 64;
7
+ var SUPERBLOCK = {
8
+ SIZE: 64,
9
+ MAGIC: 0,
10
+ // uint32 - 0x56465321
11
+ VERSION: 4,
12
+ // uint32
13
+ INODE_COUNT: 8,
14
+ // uint32 - total inodes allocated
15
+ BLOCK_SIZE: 12,
16
+ // uint32 - data block size (default 4096)
17
+ TOTAL_BLOCKS: 16,
18
+ // uint32 - total data blocks
19
+ FREE_BLOCKS: 20,
20
+ // uint32 - available data blocks
21
+ INODE_OFFSET: 24,
22
+ // float64 - byte offset to inode table
23
+ PATH_OFFSET: 32,
24
+ // float64 - byte offset to path table
25
+ DATA_OFFSET: 40,
26
+ // float64 - byte offset to data region
27
+ BITMAP_OFFSET: 48,
28
+ // float64 - byte offset to free block bitmap
29
+ PATH_USED: 56,
30
+ // uint32 - bytes used in path table
31
+ RESERVED: 60
32
+ // uint32
33
+ };
34
+ var INODE = {
35
+ TYPE: 0,
36
+ // uint8 - 0=free, 1=file, 2=directory, 3=symlink
37
+ FLAGS: 1,
38
+ // uint8[3] - reserved
39
+ PATH_OFFSET: 4,
40
+ // uint32 - byte offset into path table
41
+ PATH_LENGTH: 8,
42
+ // uint16 - length of path string
43
+ RESERVED_10: 10,
44
+ // uint16
45
+ MODE: 12,
46
+ // uint32 - permissions (e.g. 0o100644)
47
+ SIZE: 16,
48
+ // float64 - file content size in bytes (using f64 for >4GB)
49
+ FIRST_BLOCK: 24,
50
+ // uint32 - index of first data block
51
+ BLOCK_COUNT: 28,
52
+ // uint32 - number of contiguous data blocks
53
+ MTIME: 32,
54
+ // float64 - last modification time (ms since epoch)
55
+ CTIME: 40,
56
+ // float64 - creation/change time (ms since epoch)
57
+ ATIME: 48,
58
+ // float64 - last access time (ms since epoch)
59
+ UID: 56,
60
+ // uint32 - owner
61
+ GID: 60
62
+ // uint32 - group
63
+ };
64
+ var INODE_TYPE = {
65
+ FREE: 0,
66
+ FILE: 1,
67
+ DIRECTORY: 2,
68
+ SYMLINK: 3
69
+ };
70
+ var DEFAULT_FILE_MODE = 33188;
71
+ var DEFAULT_DIR_MODE = 16877;
72
+ var DEFAULT_SYMLINK_MODE = 41471;
73
+ var DEFAULT_UMASK = 18;
74
+ var S_IFMT = 61440;
75
+ var MAX_SYMLINK_DEPTH = 40;
76
+ var INITIAL_PATH_TABLE_SIZE = 256 * 1024;
77
+ var INITIAL_DATA_BLOCKS = 1024;
78
+ function calculateLayout(inodeCount = DEFAULT_INODE_COUNT, blockSize = DEFAULT_BLOCK_SIZE, totalBlocks = INITIAL_DATA_BLOCKS) {
79
+ const inodeTableOffset = SUPERBLOCK.SIZE;
80
+ const inodeTableSize = inodeCount * INODE_SIZE;
81
+ const pathTableOffset = inodeTableOffset + inodeTableSize;
82
+ const pathTableSize = INITIAL_PATH_TABLE_SIZE;
83
+ const bitmapOffset = pathTableOffset + pathTableSize;
84
+ const bitmapSize = Math.ceil(totalBlocks / 8);
85
+ const dataOffset = Math.ceil((bitmapOffset + bitmapSize) / blockSize) * blockSize;
86
+ const totalSize = dataOffset + totalBlocks * blockSize;
87
+ return {
88
+ inodeTableOffset,
89
+ inodeTableSize,
90
+ pathTableOffset,
91
+ pathTableSize,
92
+ bitmapOffset,
93
+ bitmapSize,
94
+ dataOffset,
95
+ totalSize,
96
+ totalBlocks
97
+ };
98
+ }
99
+
100
+ // src/errors.ts
101
+ var CODE_TO_STATUS = {
102
+ OK: 0,
103
+ ENOENT: 1,
104
+ EEXIST: 2,
105
+ EISDIR: 3,
106
+ ENOTDIR: 4,
107
+ ENOTEMPTY: 5,
108
+ EACCES: 6,
109
+ EINVAL: 7,
110
+ EBADF: 8,
111
+ ELOOP: 9,
112
+ ENOSPC: 10
113
+ };
114
+
115
+ // src/vfs/engine.ts
116
+ var encoder = new TextEncoder();
117
+ var decoder = new TextDecoder();
118
+ var VFSEngine = class {
119
+ handle;
120
+ pathIndex = /* @__PURE__ */ new Map();
121
+ // path → inode index
122
+ inodeCount = 0;
123
+ blockSize = DEFAULT_BLOCK_SIZE;
124
+ totalBlocks = 0;
125
+ freeBlocks = 0;
126
+ inodeTableOffset = 0;
127
+ pathTableOffset = 0;
128
+ pathTableUsed = 0;
129
+ pathTableSize = 0;
130
+ bitmapOffset = 0;
131
+ dataOffset = 0;
132
+ umask = DEFAULT_UMASK;
133
+ processUid = 0;
134
+ processGid = 0;
135
+ strictPermissions = false;
136
+ debug = false;
137
+ // File descriptor table
138
+ fdTable = /* @__PURE__ */ new Map();
139
+ nextFd = 3;
140
+ // 0=stdin, 1=stdout, 2=stderr reserved
141
+ // Reusable buffers to avoid allocations
142
+ inodeBuf = new Uint8Array(INODE_SIZE);
143
+ inodeView = new DataView(this.inodeBuf.buffer);
144
+ // In-memory inode cache — eliminates disk reads for hot inodes
145
+ inodeCache = /* @__PURE__ */ new Map();
146
+ superblockBuf = new Uint8Array(SUPERBLOCK.SIZE);
147
+ superblockView = new DataView(this.superblockBuf.buffer);
148
+ // In-memory bitmap cache — eliminates bitmap reads from OPFS
149
+ bitmap = null;
150
+ bitmapDirtyLo = Infinity;
151
+ // lowest dirty byte index
152
+ bitmapDirtyHi = -1;
153
+ // highest dirty byte index (inclusive)
154
+ superblockDirty = false;
155
+ // Free inode hint — skip O(n) scan
156
+ freeInodeHint = 0;
157
+ init(handle, opts) {
158
+ this.handle = handle;
159
+ this.processUid = opts?.uid ?? 0;
160
+ this.processGid = opts?.gid ?? 0;
161
+ this.umask = opts?.umask ?? DEFAULT_UMASK;
162
+ this.strictPermissions = opts?.strictPermissions ?? false;
163
+ this.debug = opts?.debug ?? false;
164
+ const size = handle.getSize();
165
+ if (size === 0) {
166
+ this.format();
167
+ } else {
168
+ this.mount();
169
+ }
170
+ }
171
+ /** Release the sync access handle (call on fatal error or shutdown) */
172
+ closeHandle() {
173
+ try {
174
+ this.handle?.close();
175
+ } catch (_) {
176
+ }
177
+ }
178
+ /** Format a fresh VFS */
179
+ format() {
180
+ const layout = calculateLayout(DEFAULT_INODE_COUNT, DEFAULT_BLOCK_SIZE, INITIAL_DATA_BLOCKS);
181
+ this.inodeCount = DEFAULT_INODE_COUNT;
182
+ this.blockSize = DEFAULT_BLOCK_SIZE;
183
+ this.totalBlocks = layout.totalBlocks;
184
+ this.freeBlocks = layout.totalBlocks;
185
+ this.inodeTableOffset = layout.inodeTableOffset;
186
+ this.pathTableOffset = layout.pathTableOffset;
187
+ this.pathTableSize = layout.pathTableSize;
188
+ this.pathTableUsed = 0;
189
+ this.bitmapOffset = layout.bitmapOffset;
190
+ this.dataOffset = layout.dataOffset;
191
+ this.handle.truncate(layout.totalSize);
192
+ this.writeSuperblock();
193
+ const zeroBuf = new Uint8Array(layout.inodeTableSize);
194
+ this.handle.write(zeroBuf, { at: this.inodeTableOffset });
195
+ this.bitmap = new Uint8Array(layout.bitmapSize);
196
+ this.handle.write(this.bitmap, { at: this.bitmapOffset });
197
+ this.createInode("/", INODE_TYPE.DIRECTORY, DEFAULT_DIR_MODE, 0);
198
+ this.writeSuperblock();
199
+ this.handle.flush();
200
+ }
201
+ /** Mount an existing VFS from disk — validates superblock integrity */
202
+ mount() {
203
+ const fileSize = this.handle.getSize();
204
+ if (fileSize < SUPERBLOCK.SIZE) {
205
+ throw new Error(`Corrupt VFS: file too small (${fileSize} bytes, need at least ${SUPERBLOCK.SIZE})`);
206
+ }
207
+ this.handle.read(this.superblockBuf, { at: 0 });
208
+ const v = this.superblockView;
209
+ const magic = v.getUint32(SUPERBLOCK.MAGIC, true);
210
+ if (magic !== VFS_MAGIC) {
211
+ throw new Error(`Corrupt VFS: bad magic 0x${magic.toString(16)} (expected 0x${VFS_MAGIC.toString(16)})`);
212
+ }
213
+ const version = v.getUint32(SUPERBLOCK.VERSION, true);
214
+ if (version !== VFS_VERSION) {
215
+ throw new Error(`Corrupt VFS: unsupported version ${version} (expected ${VFS_VERSION})`);
216
+ }
217
+ const inodeCount = v.getUint32(SUPERBLOCK.INODE_COUNT, true);
218
+ const blockSize = v.getUint32(SUPERBLOCK.BLOCK_SIZE, true);
219
+ const totalBlocks = v.getUint32(SUPERBLOCK.TOTAL_BLOCKS, true);
220
+ const freeBlocks = v.getUint32(SUPERBLOCK.FREE_BLOCKS, true);
221
+ const inodeTableOffset = v.getFloat64(SUPERBLOCK.INODE_OFFSET, true);
222
+ const pathTableOffset = v.getFloat64(SUPERBLOCK.PATH_OFFSET, true);
223
+ const dataOffset = v.getFloat64(SUPERBLOCK.DATA_OFFSET, true);
224
+ const bitmapOffset = v.getFloat64(SUPERBLOCK.BITMAP_OFFSET, true);
225
+ const pathUsed = v.getUint32(SUPERBLOCK.PATH_USED, true);
226
+ if (blockSize === 0 || (blockSize & blockSize - 1) !== 0) {
227
+ throw new Error(`Corrupt VFS: invalid block size ${blockSize} (must be power of 2)`);
228
+ }
229
+ if (inodeCount === 0) {
230
+ throw new Error("Corrupt VFS: inode count is 0");
231
+ }
232
+ if (freeBlocks > totalBlocks) {
233
+ throw new Error(`Corrupt VFS: free blocks (${freeBlocks}) exceeds total blocks (${totalBlocks})`);
234
+ }
235
+ if (inodeTableOffset !== SUPERBLOCK.SIZE) {
236
+ throw new Error(`Corrupt VFS: inode table offset ${inodeTableOffset} (expected ${SUPERBLOCK.SIZE})`);
237
+ }
238
+ const expectedPathOffset = inodeTableOffset + inodeCount * INODE_SIZE;
239
+ if (pathTableOffset !== expectedPathOffset) {
240
+ throw new Error(`Corrupt VFS: path table offset ${pathTableOffset} (expected ${expectedPathOffset})`);
241
+ }
242
+ if (bitmapOffset <= pathTableOffset) {
243
+ throw new Error(`Corrupt VFS: bitmap offset ${bitmapOffset} must be after path table ${pathTableOffset}`);
244
+ }
245
+ if (dataOffset <= bitmapOffset) {
246
+ throw new Error(`Corrupt VFS: data offset ${dataOffset} must be after bitmap ${bitmapOffset}`);
247
+ }
248
+ const pathTableSize = bitmapOffset - pathTableOffset;
249
+ if (pathUsed > pathTableSize) {
250
+ throw new Error(`Corrupt VFS: path used (${pathUsed}) exceeds path table size (${pathTableSize})`);
251
+ }
252
+ const expectedMinSize = dataOffset + totalBlocks * blockSize;
253
+ if (fileSize < expectedMinSize) {
254
+ throw new Error(`Corrupt VFS: file size ${fileSize} too small for layout (need ${expectedMinSize})`);
255
+ }
256
+ this.inodeCount = inodeCount;
257
+ this.blockSize = blockSize;
258
+ this.totalBlocks = totalBlocks;
259
+ this.freeBlocks = freeBlocks;
260
+ this.inodeTableOffset = inodeTableOffset;
261
+ this.pathTableOffset = pathTableOffset;
262
+ this.dataOffset = dataOffset;
263
+ this.bitmapOffset = bitmapOffset;
264
+ this.pathTableUsed = pathUsed;
265
+ this.pathTableSize = pathTableSize;
266
+ const bitmapSize = Math.ceil(this.totalBlocks / 8);
267
+ this.bitmap = new Uint8Array(bitmapSize);
268
+ this.handle.read(this.bitmap, { at: this.bitmapOffset });
269
+ this.rebuildIndex();
270
+ if (!this.pathIndex.has("/")) {
271
+ throw new Error('Corrupt VFS: root directory "/" not found in inode table');
272
+ }
273
+ }
274
+ writeSuperblock() {
275
+ const v = this.superblockView;
276
+ v.setUint32(SUPERBLOCK.MAGIC, VFS_MAGIC, true);
277
+ v.setUint32(SUPERBLOCK.VERSION, VFS_VERSION, true);
278
+ v.setUint32(SUPERBLOCK.INODE_COUNT, this.inodeCount, true);
279
+ v.setUint32(SUPERBLOCK.BLOCK_SIZE, this.blockSize, true);
280
+ v.setUint32(SUPERBLOCK.TOTAL_BLOCKS, this.totalBlocks, true);
281
+ v.setUint32(SUPERBLOCK.FREE_BLOCKS, this.freeBlocks, true);
282
+ v.setFloat64(SUPERBLOCK.INODE_OFFSET, this.inodeTableOffset, true);
283
+ v.setFloat64(SUPERBLOCK.PATH_OFFSET, this.pathTableOffset, true);
284
+ v.setFloat64(SUPERBLOCK.DATA_OFFSET, this.dataOffset, true);
285
+ v.setFloat64(SUPERBLOCK.BITMAP_OFFSET, this.bitmapOffset, true);
286
+ v.setUint32(SUPERBLOCK.PATH_USED, this.pathTableUsed, true);
287
+ this.handle.write(this.superblockBuf, { at: 0 });
288
+ }
289
+ /** Flush pending bitmap and superblock writes to disk (one write each) */
290
+ markBitmapDirty(lo, hi) {
291
+ if (lo < this.bitmapDirtyLo) this.bitmapDirtyLo = lo;
292
+ if (hi > this.bitmapDirtyHi) this.bitmapDirtyHi = hi;
293
+ }
294
+ commitPending() {
295
+ if (this.blocksFreedsinceTrim) {
296
+ this.trimTrailingBlocks();
297
+ this.blocksFreedsinceTrim = false;
298
+ }
299
+ if (this.bitmapDirtyHi >= 0) {
300
+ const lo = this.bitmapDirtyLo;
301
+ const hi = this.bitmapDirtyHi;
302
+ this.handle.write(this.bitmap.subarray(lo, hi + 1), { at: this.bitmapOffset + lo });
303
+ this.bitmapDirtyLo = Infinity;
304
+ this.bitmapDirtyHi = -1;
305
+ }
306
+ if (this.superblockDirty) {
307
+ this.writeSuperblock();
308
+ this.superblockDirty = false;
309
+ }
310
+ }
311
+ /** Shrink the OPFS file by removing trailing free blocks from the data region.
312
+ * Scans bitmap from end to find the last used block, then truncates. */
313
+ trimTrailingBlocks() {
314
+ const bitmap = this.bitmap;
315
+ let lastUsed = -1;
316
+ for (let byteIdx = Math.ceil(this.totalBlocks / 8) - 1; byteIdx >= 0; byteIdx--) {
317
+ if (bitmap[byteIdx] !== 0) {
318
+ for (let bit = 7; bit >= 0; bit--) {
319
+ const blockIdx = byteIdx * 8 + bit;
320
+ if (blockIdx < this.totalBlocks && bitmap[byteIdx] & 1 << bit) {
321
+ lastUsed = blockIdx;
322
+ break;
323
+ }
324
+ }
325
+ break;
326
+ }
327
+ }
328
+ const newTotal = Math.max(lastUsed + 1, INITIAL_DATA_BLOCKS);
329
+ if (newTotal >= this.totalBlocks) return;
330
+ this.handle.truncate(this.dataOffset + newTotal * this.blockSize);
331
+ const newBitmapSize = Math.ceil(newTotal / 8);
332
+ this.bitmap = bitmap.slice(0, newBitmapSize);
333
+ const trimmed = this.totalBlocks - newTotal;
334
+ this.freeBlocks -= trimmed;
335
+ this.totalBlocks = newTotal;
336
+ this.superblockDirty = true;
337
+ this.bitmapDirtyLo = 0;
338
+ this.bitmapDirtyHi = newBitmapSize - 1;
339
+ }
340
+ /** Rebuild in-memory path→inode index from disk.
341
+ * Bulk-reads the entire inode table + path table in 2 I/O calls,
342
+ * then parses in memory (avoids 10k+ individual reads). */
343
+ rebuildIndex() {
344
+ this.pathIndex.clear();
345
+ this.inodeCache.clear();
346
+ const inodeTableSize = this.inodeCount * INODE_SIZE;
347
+ const inodeBuf = new Uint8Array(inodeTableSize);
348
+ this.handle.read(inodeBuf, { at: this.inodeTableOffset });
349
+ const inodeView = new DataView(inodeBuf.buffer);
350
+ const pathBuf = this.pathTableUsed > 0 ? new Uint8Array(this.pathTableUsed) : null;
351
+ if (pathBuf) {
352
+ this.handle.read(pathBuf, { at: this.pathTableOffset });
353
+ }
354
+ for (let i = 0; i < this.inodeCount; i++) {
355
+ const off = i * INODE_SIZE;
356
+ const type = inodeView.getUint8(off + INODE.TYPE);
357
+ if (type === INODE_TYPE.FREE) continue;
358
+ if (type < INODE_TYPE.FILE || type > INODE_TYPE.SYMLINK) {
359
+ throw new Error(`Corrupt VFS: inode ${i} has invalid type ${type}`);
360
+ }
361
+ const pathOffset = inodeView.getUint32(off + INODE.PATH_OFFSET, true);
362
+ const pathLength = inodeView.getUint16(off + INODE.PATH_LENGTH, true);
363
+ const size = inodeView.getFloat64(off + INODE.SIZE, true);
364
+ const firstBlock = inodeView.getUint32(off + INODE.FIRST_BLOCK, true);
365
+ const blockCount = inodeView.getUint32(off + INODE.BLOCK_COUNT, true);
366
+ if (pathLength === 0 || pathOffset + pathLength > this.pathTableUsed) {
367
+ throw new Error(`Corrupt VFS: inode ${i} path out of bounds (offset=${pathOffset}, len=${pathLength}, tableUsed=${this.pathTableUsed})`);
368
+ }
369
+ if (type !== INODE_TYPE.DIRECTORY) {
370
+ if (size < 0 || !isFinite(size)) {
371
+ throw new Error(`Corrupt VFS: inode ${i} has invalid size ${size}`);
372
+ }
373
+ if (blockCount > 0 && firstBlock + blockCount > this.totalBlocks) {
374
+ throw new Error(`Corrupt VFS: inode ${i} data blocks out of range (first=${firstBlock}, count=${blockCount}, total=${this.totalBlocks})`);
375
+ }
376
+ }
377
+ const inode = {
378
+ type,
379
+ pathOffset,
380
+ pathLength,
381
+ mode: inodeView.getUint32(off + INODE.MODE, true),
382
+ size,
383
+ firstBlock,
384
+ blockCount,
385
+ mtime: inodeView.getFloat64(off + INODE.MTIME, true),
386
+ ctime: inodeView.getFloat64(off + INODE.CTIME, true),
387
+ atime: inodeView.getFloat64(off + INODE.ATIME, true),
388
+ uid: inodeView.getUint32(off + INODE.UID, true),
389
+ gid: inodeView.getUint32(off + INODE.GID, true)
390
+ };
391
+ this.inodeCache.set(i, inode);
392
+ let path;
393
+ if (pathBuf) {
394
+ path = decoder.decode(pathBuf.subarray(inode.pathOffset, inode.pathOffset + inode.pathLength));
395
+ } else {
396
+ path = this.readPath(inode.pathOffset, inode.pathLength);
397
+ }
398
+ if (!path.startsWith("/") || path.includes("\0")) {
399
+ throw new Error(`Corrupt VFS: inode ${i} has invalid path "${path.substring(0, 50)}"`);
400
+ }
401
+ this.pathIndex.set(path, i);
402
+ }
403
+ }
404
+ // ========== Low-level inode I/O ==========
405
+ readInode(idx) {
406
+ const cached = this.inodeCache.get(idx);
407
+ if (cached) return cached;
408
+ const offset = this.inodeTableOffset + idx * INODE_SIZE;
409
+ this.handle.read(this.inodeBuf, { at: offset });
410
+ const v = this.inodeView;
411
+ const inode = {
412
+ type: v.getUint8(INODE.TYPE),
413
+ pathOffset: v.getUint32(INODE.PATH_OFFSET, true),
414
+ pathLength: v.getUint16(INODE.PATH_LENGTH, true),
415
+ mode: v.getUint32(INODE.MODE, true),
416
+ size: v.getFloat64(INODE.SIZE, true),
417
+ firstBlock: v.getUint32(INODE.FIRST_BLOCK, true),
418
+ blockCount: v.getUint32(INODE.BLOCK_COUNT, true),
419
+ mtime: v.getFloat64(INODE.MTIME, true),
420
+ ctime: v.getFloat64(INODE.CTIME, true),
421
+ atime: v.getFloat64(INODE.ATIME, true),
422
+ uid: v.getUint32(INODE.UID, true),
423
+ gid: v.getUint32(INODE.GID, true)
424
+ };
425
+ this.inodeCache.set(idx, inode);
426
+ return inode;
427
+ }
428
+ writeInode(idx, inode) {
429
+ if (inode.type === INODE_TYPE.FREE) {
430
+ this.inodeCache.delete(idx);
431
+ } else {
432
+ this.inodeCache.set(idx, inode);
433
+ }
434
+ const v = this.inodeView;
435
+ v.setUint8(INODE.TYPE, inode.type);
436
+ v.setUint8(INODE.FLAGS, 0);
437
+ v.setUint8(INODE.FLAGS + 1, 0);
438
+ v.setUint8(INODE.FLAGS + 2, 0);
439
+ v.setUint32(INODE.PATH_OFFSET, inode.pathOffset, true);
440
+ v.setUint16(INODE.PATH_LENGTH, inode.pathLength, true);
441
+ v.setUint16(INODE.RESERVED_10, 0, true);
442
+ v.setUint32(INODE.MODE, inode.mode, true);
443
+ v.setFloat64(INODE.SIZE, inode.size, true);
444
+ v.setUint32(INODE.FIRST_BLOCK, inode.firstBlock, true);
445
+ v.setUint32(INODE.BLOCK_COUNT, inode.blockCount, true);
446
+ v.setFloat64(INODE.MTIME, inode.mtime, true);
447
+ v.setFloat64(INODE.CTIME, inode.ctime, true);
448
+ v.setFloat64(INODE.ATIME, inode.atime, true);
449
+ v.setUint32(INODE.UID, inode.uid, true);
450
+ v.setUint32(INODE.GID, inode.gid, true);
451
+ const offset = this.inodeTableOffset + idx * INODE_SIZE;
452
+ this.handle.write(this.inodeBuf, { at: offset });
453
+ }
454
+ // ========== Path table I/O ==========
455
+ readPath(offset, length) {
456
+ const buf = new Uint8Array(length);
457
+ this.handle.read(buf, { at: this.pathTableOffset + offset });
458
+ return decoder.decode(buf);
459
+ }
460
+ appendPath(path) {
461
+ const bytes = encoder.encode(path);
462
+ const offset = this.pathTableUsed;
463
+ if (offset + bytes.byteLength > this.pathTableSize) {
464
+ this.growPathTable(offset + bytes.byteLength);
465
+ }
466
+ this.handle.write(bytes, { at: this.pathTableOffset + offset });
467
+ this.pathTableUsed += bytes.byteLength;
468
+ this.superblockDirty = true;
469
+ return { offset, length: bytes.byteLength };
470
+ }
471
+ growPathTable(needed) {
472
+ const newSize = Math.max(this.pathTableSize * 2, needed + INITIAL_PATH_TABLE_SIZE);
473
+ const growth = newSize - this.pathTableSize;
474
+ const dataSize = this.totalBlocks * this.blockSize;
475
+ const dataBuf = new Uint8Array(dataSize);
476
+ this.handle.read(dataBuf, { at: this.dataOffset });
477
+ const newTotalSize = this.handle.getSize() + growth;
478
+ this.handle.truncate(newTotalSize);
479
+ const newBitmapOffset = this.bitmapOffset + growth;
480
+ const newDataOffset = this.dataOffset + growth;
481
+ this.handle.write(dataBuf, { at: newDataOffset });
482
+ this.handle.write(this.bitmap, { at: newBitmapOffset });
483
+ this.pathTableSize = newSize;
484
+ this.bitmapOffset = newBitmapOffset;
485
+ this.dataOffset = newDataOffset;
486
+ this.superblockDirty = true;
487
+ }
488
+ // ========== Bitmap I/O ==========
489
+ allocateBlocks(count) {
490
+ if (count === 0) return 0;
491
+ const bitmap = this.bitmap;
492
+ let run = 0;
493
+ let start = 0;
494
+ for (let i = 0; i < this.totalBlocks; i++) {
495
+ const byteIdx = i >>> 3;
496
+ const bitIdx = i & 7;
497
+ const used = bitmap[byteIdx] >>> bitIdx & 1;
498
+ if (used) {
499
+ run = 0;
500
+ start = i + 1;
501
+ } else {
502
+ run++;
503
+ if (run === count) {
504
+ for (let j = start; j <= i; j++) {
505
+ const bj = j >>> 3;
506
+ const bi = j & 7;
507
+ bitmap[bj] |= 1 << bi;
508
+ }
509
+ this.markBitmapDirty(start >>> 3, i >>> 3);
510
+ this.freeBlocks -= count;
511
+ this.superblockDirty = true;
512
+ return start;
513
+ }
514
+ }
515
+ }
516
+ return this.growAndAllocate(count);
517
+ }
518
+ growAndAllocate(count) {
519
+ const oldTotal = this.totalBlocks;
520
+ const newTotal = Math.max(oldTotal * 2, oldTotal + count);
521
+ const addedBlocks = newTotal - oldTotal;
522
+ const newFileSize = this.dataOffset + newTotal * this.blockSize;
523
+ this.handle.truncate(newFileSize);
524
+ const newBitmapSize = Math.ceil(newTotal / 8);
525
+ const newBitmap = new Uint8Array(newBitmapSize);
526
+ newBitmap.set(this.bitmap);
527
+ this.bitmap = newBitmap;
528
+ this.totalBlocks = newTotal;
529
+ this.freeBlocks += addedBlocks;
530
+ const start = oldTotal;
531
+ for (let j = start; j < start + count; j++) {
532
+ const bj = j >>> 3;
533
+ const bi = j & 7;
534
+ this.bitmap[bj] |= 1 << bi;
535
+ }
536
+ this.markBitmapDirty(start >>> 3, start + count - 1 >>> 3);
537
+ this.freeBlocks -= count;
538
+ this.superblockDirty = true;
539
+ return start;
540
+ }
541
+ blocksFreedsinceTrim = false;
542
+ freeBlockRange(start, count) {
543
+ if (count === 0) return;
544
+ const bitmap = this.bitmap;
545
+ for (let i = start; i < start + count; i++) {
546
+ const byteIdx = i >>> 3;
547
+ const bitIdx = i & 7;
548
+ bitmap[byteIdx] &= ~(1 << bitIdx);
549
+ }
550
+ this.markBitmapDirty(start >>> 3, start + count - 1 >>> 3);
551
+ this.freeBlocks += count;
552
+ this.superblockDirty = true;
553
+ this.blocksFreedsinceTrim = true;
554
+ }
555
+ // updateSuperblockFreeBlocks is no longer needed — superblock writes are coalesced via commitPending()
556
+ // ========== Inode allocation ==========
557
+ findFreeInode() {
558
+ for (let i = this.freeInodeHint; i < this.inodeCount; i++) {
559
+ if (this.inodeCache.has(i)) continue;
560
+ const offset = this.inodeTableOffset + i * INODE_SIZE;
561
+ const typeBuf = new Uint8Array(1);
562
+ this.handle.read(typeBuf, { at: offset });
563
+ if (typeBuf[0] === INODE_TYPE.FREE) {
564
+ this.freeInodeHint = i + 1;
565
+ return i;
566
+ }
567
+ }
568
+ const idx = this.growInodeTable();
569
+ this.freeInodeHint = idx + 1;
570
+ return idx;
571
+ }
572
+ growInodeTable() {
573
+ const oldCount = this.inodeCount;
574
+ const newCount = oldCount * 2;
575
+ const growth = (newCount - oldCount) * INODE_SIZE;
576
+ const afterInodeOffset = this.inodeTableOffset + oldCount * INODE_SIZE;
577
+ const afterSize = this.handle.getSize() - afterInodeOffset;
578
+ const afterBuf = new Uint8Array(afterSize);
579
+ this.handle.read(afterBuf, { at: afterInodeOffset });
580
+ this.handle.truncate(this.handle.getSize() + growth);
581
+ this.handle.write(afterBuf, { at: afterInodeOffset + growth });
582
+ const zeroes = new Uint8Array(growth);
583
+ this.handle.write(zeroes, { at: afterInodeOffset });
584
+ this.pathTableOffset += growth;
585
+ this.bitmapOffset += growth;
586
+ this.dataOffset += growth;
587
+ this.inodeCount = newCount;
588
+ this.superblockDirty = true;
589
+ return oldCount;
590
+ }
591
+ // ========== Data I/O ==========
592
+ readData(firstBlock, blockCount, size) {
593
+ const buf = new Uint8Array(size);
594
+ const offset = this.dataOffset + firstBlock * this.blockSize;
595
+ this.handle.read(buf, { at: offset });
596
+ return buf;
597
+ }
598
+ writeData(firstBlock, data) {
599
+ const offset = this.dataOffset + firstBlock * this.blockSize;
600
+ this.handle.write(data, { at: offset });
601
+ }
602
+ // ========== Path resolution ==========
603
+ resolvePath(path, depth = 0) {
604
+ if (depth > MAX_SYMLINK_DEPTH) return void 0;
605
+ const idx = this.pathIndex.get(path);
606
+ if (idx === void 0) {
607
+ return this.resolvePathComponents(path, true, depth);
608
+ }
609
+ const inode = this.readInode(idx);
610
+ if (inode.type === INODE_TYPE.SYMLINK) {
611
+ const target = decoder.decode(this.readData(inode.firstBlock, inode.blockCount, inode.size));
612
+ const resolved = target.startsWith("/") ? target : this.resolveRelative(path, target);
613
+ return this.resolvePath(resolved, depth + 1);
614
+ }
615
+ return idx;
616
+ }
617
+ /** Resolve symlinks in intermediate path components */
618
+ resolvePathComponents(path, followLast = true, depth = 0) {
619
+ if (depth > MAX_SYMLINK_DEPTH) return void 0;
620
+ const parts = path.split("/").filter(Boolean);
621
+ let current = "/";
622
+ for (let i = 0; i < parts.length; i++) {
623
+ const isLast = i === parts.length - 1;
624
+ current = current === "/" ? "/" + parts[i] : current + "/" + parts[i];
625
+ const idx = this.pathIndex.get(current);
626
+ if (idx === void 0) return void 0;
627
+ const inode = this.readInode(idx);
628
+ if (inode.type === INODE_TYPE.SYMLINK && (!isLast || followLast)) {
629
+ const target = decoder.decode(this.readData(inode.firstBlock, inode.blockCount, inode.size));
630
+ const resolved = target.startsWith("/") ? target : this.resolveRelative(current, target);
631
+ if (isLast) {
632
+ return this.resolvePathComponents(resolved, true, depth + 1);
633
+ }
634
+ const remaining = parts.slice(i + 1).join("/");
635
+ const newPath = resolved + (remaining ? "/" + remaining : "");
636
+ return this.resolvePathComponents(newPath, followLast, depth + 1);
637
+ }
638
+ }
639
+ return this.pathIndex.get(current);
640
+ }
641
+ resolveRelative(from, target) {
642
+ const dir = from.substring(0, from.lastIndexOf("/")) || "/";
643
+ const parts = (dir + "/" + target).split("/").filter(Boolean);
644
+ const resolved = [];
645
+ for (const p of parts) {
646
+ if (p === ".") continue;
647
+ if (p === "..") {
648
+ resolved.pop();
649
+ continue;
650
+ }
651
+ resolved.push(p);
652
+ }
653
+ return "/" + resolved.join("/");
654
+ }
655
+ // ========== Core inode creation helper ==========
656
+ createInode(path, type, mode, size, data) {
657
+ const idx = this.findFreeInode();
658
+ const { offset: pathOff, length: pathLen } = this.appendPath(path);
659
+ const now = Date.now();
660
+ let firstBlock = 0;
661
+ let blockCount = 0;
662
+ if (data && data.byteLength > 0) {
663
+ blockCount = Math.ceil(data.byteLength / this.blockSize);
664
+ firstBlock = this.allocateBlocks(blockCount);
665
+ this.writeData(firstBlock, data);
666
+ }
667
+ const inode = {
668
+ type,
669
+ pathOffset: pathOff,
670
+ pathLength: pathLen,
671
+ mode,
672
+ size,
673
+ firstBlock,
674
+ blockCount,
675
+ mtime: now,
676
+ ctime: now,
677
+ atime: now,
678
+ uid: this.processUid,
679
+ gid: this.processGid
680
+ };
681
+ this.writeInode(idx, inode);
682
+ this.pathIndex.set(path, idx);
683
+ return idx;
684
+ }
685
+ // ========== Public API — called by server worker dispatch ==========
686
+ /** Normalize a path: ensure leading /, resolve . and .. */
687
+ normalizePath(p) {
688
+ if (p.charCodeAt(0) !== 47) p = "/" + p;
689
+ if (p.length === 1) return p;
690
+ if (p.indexOf("/.") === -1 && p.indexOf("//") === -1 && p.charCodeAt(p.length - 1) !== 47) {
691
+ return p;
692
+ }
693
+ const parts = p.split("/").filter(Boolean);
694
+ const resolved = [];
695
+ for (const part of parts) {
696
+ if (part === ".") continue;
697
+ if (part === "..") {
698
+ resolved.pop();
699
+ continue;
700
+ }
701
+ resolved.push(part);
702
+ }
703
+ return "/" + resolved.join("/");
704
+ }
705
+ // ---- READ ----
706
+ read(path) {
707
+ const t0 = this.debug ? performance.now() : 0;
708
+ path = this.normalizePath(path);
709
+ let idx = this.pathIndex.get(path);
710
+ if (idx !== void 0) {
711
+ const inode2 = this.inodeCache.get(idx);
712
+ if (inode2) {
713
+ if (inode2.type === INODE_TYPE.SYMLINK) {
714
+ idx = this.resolvePathComponents(path, true);
715
+ } else if (inode2.type === INODE_TYPE.DIRECTORY) {
716
+ return { status: CODE_TO_STATUS.EISDIR, data: null };
717
+ } else {
718
+ const data2 = inode2.size > 0 ? this.readData(inode2.firstBlock, inode2.blockCount, inode2.size) : new Uint8Array(0);
719
+ if (this.debug) {
720
+ const t1 = performance.now();
721
+ console.log(`[VFS read] path=${path} size=${inode2.size} TOTAL=${(t1 - t0).toFixed(3)}ms (fast)`);
722
+ }
723
+ return { status: 0, data: data2 };
724
+ }
725
+ }
726
+ }
727
+ if (idx === void 0) idx = this.resolvePathComponents(path, true);
728
+ if (idx === void 0) return { status: CODE_TO_STATUS.ENOENT, data: null };
729
+ const inode = this.readInode(idx);
730
+ if (inode.type === INODE_TYPE.DIRECTORY) return { status: CODE_TO_STATUS.EISDIR, data: null };
731
+ const data = inode.size > 0 ? this.readData(inode.firstBlock, inode.blockCount, inode.size) : new Uint8Array(0);
732
+ if (this.debug) {
733
+ const t1 = performance.now();
734
+ console.log(`[VFS read] path=${path} size=${inode.size} TOTAL=${(t1 - t0).toFixed(3)}ms (slow path)`);
735
+ }
736
+ return { status: 0, data };
737
+ }
738
+ // ---- WRITE ----
739
+ write(path, data, flags = 0) {
740
+ const t0 = this.debug ? performance.now() : 0;
741
+ path = this.normalizePath(path);
742
+ const t1 = this.debug ? performance.now() : 0;
743
+ const parentStatus = this.ensureParent(path);
744
+ if (parentStatus !== 0) return { status: parentStatus };
745
+ const t2 = this.debug ? performance.now() : 0;
746
+ const existingIdx = this.resolvePathComponents(path, true);
747
+ const t3 = this.debug ? performance.now() : 0;
748
+ let tAlloc = t3, tData = t3, tInode = t3;
749
+ if (existingIdx !== void 0) {
750
+ const inode = this.readInode(existingIdx);
751
+ if (inode.type === INODE_TYPE.DIRECTORY) return { status: CODE_TO_STATUS.EISDIR };
752
+ const neededBlocks = Math.ceil(data.byteLength / this.blockSize);
753
+ if (neededBlocks <= inode.blockCount) {
754
+ tAlloc = this.debug ? performance.now() : 0;
755
+ this.writeData(inode.firstBlock, data);
756
+ tData = this.debug ? performance.now() : 0;
757
+ if (neededBlocks < inode.blockCount) {
758
+ this.freeBlockRange(inode.firstBlock + neededBlocks, inode.blockCount - neededBlocks);
759
+ }
760
+ } else {
761
+ this.freeBlockRange(inode.firstBlock, inode.blockCount);
762
+ const newFirst = this.allocateBlocks(neededBlocks);
763
+ tAlloc = this.debug ? performance.now() : 0;
764
+ this.writeData(newFirst, data);
765
+ tData = this.debug ? performance.now() : 0;
766
+ inode.firstBlock = newFirst;
767
+ }
768
+ inode.size = data.byteLength;
769
+ inode.blockCount = neededBlocks;
770
+ inode.mtime = Date.now();
771
+ this.writeInode(existingIdx, inode);
772
+ tInode = this.debug ? performance.now() : 0;
773
+ } else {
774
+ const mode = DEFAULT_FILE_MODE & ~(this.umask & 511);
775
+ this.createInode(path, INODE_TYPE.FILE, mode, data.byteLength, data);
776
+ tAlloc = this.debug ? performance.now() : 0;
777
+ tData = tAlloc;
778
+ tInode = tAlloc;
779
+ }
780
+ if (flags & 1) {
781
+ this.commitPending();
782
+ this.handle.flush();
783
+ }
784
+ const tFlush = this.debug ? performance.now() : 0;
785
+ if (this.debug) {
786
+ const existing = existingIdx !== void 0;
787
+ console.log(`[VFS write] path=${path} size=${data.byteLength} ${existing ? "UPDATE" : "CREATE"} normalize=${(t1 - t0).toFixed(3)}ms parent=${(t2 - t1).toFixed(3)}ms resolve=${(t3 - t2).toFixed(3)}ms alloc=${(tAlloc - t3).toFixed(3)}ms data=${(tData - tAlloc).toFixed(3)}ms inode=${(tInode - tData).toFixed(3)}ms flush=${(tFlush - tInode).toFixed(3)}ms TOTAL=${(tFlush - t0).toFixed(3)}ms`);
788
+ }
789
+ return { status: 0 };
790
+ }
791
+ // ---- APPEND ----
792
+ append(path, data) {
793
+ path = this.normalizePath(path);
794
+ const existingIdx = this.resolvePathComponents(path, true);
795
+ if (existingIdx === void 0) {
796
+ return this.write(path, data);
797
+ }
798
+ const inode = this.readInode(existingIdx);
799
+ if (inode.type === INODE_TYPE.DIRECTORY) return { status: CODE_TO_STATUS.EISDIR };
800
+ const existing = inode.size > 0 ? this.readData(inode.firstBlock, inode.blockCount, inode.size) : new Uint8Array(0);
801
+ const combined = new Uint8Array(existing.byteLength + data.byteLength);
802
+ combined.set(existing);
803
+ combined.set(data, existing.byteLength);
804
+ const neededBlocks = Math.ceil(combined.byteLength / this.blockSize);
805
+ this.freeBlockRange(inode.firstBlock, inode.blockCount);
806
+ const newFirst = this.allocateBlocks(neededBlocks);
807
+ this.writeData(newFirst, combined);
808
+ inode.firstBlock = newFirst;
809
+ inode.blockCount = neededBlocks;
810
+ inode.size = combined.byteLength;
811
+ inode.mtime = Date.now();
812
+ this.writeInode(existingIdx, inode);
813
+ this.commitPending();
814
+ return { status: 0 };
815
+ }
816
+ // ---- UNLINK ----
817
+ unlink(path) {
818
+ path = this.normalizePath(path);
819
+ const idx = this.pathIndex.get(path);
820
+ if (idx === void 0) return { status: CODE_TO_STATUS.ENOENT };
821
+ const inode = this.readInode(idx);
822
+ if (inode.type === INODE_TYPE.DIRECTORY) return { status: CODE_TO_STATUS.EISDIR };
823
+ this.freeBlockRange(inode.firstBlock, inode.blockCount);
824
+ inode.type = INODE_TYPE.FREE;
825
+ this.writeInode(idx, inode);
826
+ this.pathIndex.delete(path);
827
+ if (idx < this.freeInodeHint) this.freeInodeHint = idx;
828
+ this.commitPending();
829
+ return { status: 0 };
830
+ }
831
+ // ---- STAT ----
832
+ stat(path) {
833
+ path = this.normalizePath(path);
834
+ const idx = this.resolvePathComponents(path, true);
835
+ if (idx === void 0) return { status: CODE_TO_STATUS.ENOENT, data: null };
836
+ return this.encodeStatResponse(idx);
837
+ }
838
+ // ---- LSTAT (no symlink follow) ----
839
+ lstat(path) {
840
+ path = this.normalizePath(path);
841
+ const idx = this.pathIndex.get(path);
842
+ if (idx === void 0) return { status: CODE_TO_STATUS.ENOENT, data: null };
843
+ return this.encodeStatResponse(idx);
844
+ }
845
+ encodeStatResponse(idx) {
846
+ const inode = this.readInode(idx);
847
+ const buf = new Uint8Array(49);
848
+ const view = new DataView(buf.buffer);
849
+ view.setUint8(0, inode.type);
850
+ view.setUint32(1, inode.mode, true);
851
+ view.setFloat64(5, inode.size, true);
852
+ view.setFloat64(13, inode.mtime, true);
853
+ view.setFloat64(21, inode.ctime, true);
854
+ view.setFloat64(29, inode.atime, true);
855
+ view.setUint32(37, inode.uid, true);
856
+ view.setUint32(41, inode.gid, true);
857
+ view.setUint32(45, idx, true);
858
+ return { status: 0, data: buf };
859
+ }
860
+ // ---- MKDIR ----
861
+ mkdir(path, flags = 0) {
862
+ path = this.normalizePath(path);
863
+ const recursive = (flags & 1) !== 0;
864
+ if (recursive) {
865
+ return this.mkdirRecursive(path);
866
+ }
867
+ if (this.pathIndex.has(path)) return { status: CODE_TO_STATUS.EEXIST, data: null };
868
+ const parentStatus = this.ensureParent(path);
869
+ if (parentStatus !== 0) return { status: parentStatus, data: null };
870
+ const mode = DEFAULT_DIR_MODE & ~(this.umask & 511);
871
+ this.createInode(path, INODE_TYPE.DIRECTORY, mode, 0);
872
+ this.commitPending();
873
+ const pathBytes = encoder.encode(path);
874
+ return { status: 0, data: pathBytes };
875
+ }
876
+ mkdirRecursive(path) {
877
+ const parts = path.split("/").filter(Boolean);
878
+ let current = "";
879
+ let firstCreated = null;
880
+ for (const part of parts) {
881
+ current += "/" + part;
882
+ if (this.pathIndex.has(current)) {
883
+ const idx = this.pathIndex.get(current);
884
+ const inode = this.readInode(idx);
885
+ if (inode.type !== INODE_TYPE.DIRECTORY) {
886
+ return { status: CODE_TO_STATUS.ENOTDIR, data: null };
887
+ }
888
+ continue;
889
+ }
890
+ const mode = DEFAULT_DIR_MODE & ~(this.umask & 511);
891
+ this.createInode(current, INODE_TYPE.DIRECTORY, mode, 0);
892
+ if (!firstCreated) firstCreated = current;
893
+ }
894
+ this.commitPending();
895
+ const result = firstCreated ? encoder.encode(firstCreated) : void 0;
896
+ return { status: 0, data: result ?? null };
897
+ }
898
+ // ---- RMDIR ----
899
+ rmdir(path, flags = 0) {
900
+ path = this.normalizePath(path);
901
+ const recursive = (flags & 1) !== 0;
902
+ const idx = this.pathIndex.get(path);
903
+ if (idx === void 0) return { status: CODE_TO_STATUS.ENOENT };
904
+ const inode = this.readInode(idx);
905
+ if (inode.type !== INODE_TYPE.DIRECTORY) return { status: CODE_TO_STATUS.ENOTDIR };
906
+ const children = this.getDirectChildren(path);
907
+ if (children.length > 0) {
908
+ if (!recursive) return { status: CODE_TO_STATUS.ENOTEMPTY };
909
+ for (const child of this.getAllDescendants(path)) {
910
+ const childIdx = this.pathIndex.get(child);
911
+ const childInode = this.readInode(childIdx);
912
+ this.freeBlockRange(childInode.firstBlock, childInode.blockCount);
913
+ childInode.type = INODE_TYPE.FREE;
914
+ this.writeInode(childIdx, childInode);
915
+ this.pathIndex.delete(child);
916
+ }
917
+ }
918
+ inode.type = INODE_TYPE.FREE;
919
+ this.writeInode(idx, inode);
920
+ this.pathIndex.delete(path);
921
+ if (idx < this.freeInodeHint) this.freeInodeHint = idx;
922
+ this.commitPending();
923
+ return { status: 0 };
924
+ }
925
+ // ---- READDIR ----
926
+ readdir(path, flags = 0) {
927
+ path = this.normalizePath(path);
928
+ const idx = this.resolvePathComponents(path, true);
929
+ if (idx === void 0) return { status: CODE_TO_STATUS.ENOENT, data: null };
930
+ const inode = this.readInode(idx);
931
+ if (inode.type !== INODE_TYPE.DIRECTORY) return { status: CODE_TO_STATUS.ENOTDIR, data: null };
932
+ const withFileTypes = (flags & 1) !== 0;
933
+ const children = this.getDirectChildren(path);
934
+ if (withFileTypes) {
935
+ let totalSize2 = 4;
936
+ const entries = [];
937
+ for (const childPath of children) {
938
+ const name = childPath.substring(childPath.lastIndexOf("/") + 1);
939
+ const nameBytes = encoder.encode(name);
940
+ const childIdx = this.pathIndex.get(childPath);
941
+ const childInode = this.readInode(childIdx);
942
+ entries.push({ name: nameBytes, type: childInode.type });
943
+ totalSize2 += 2 + nameBytes.byteLength + 1;
944
+ }
945
+ const buf2 = new Uint8Array(totalSize2);
946
+ const view2 = new DataView(buf2.buffer);
947
+ view2.setUint32(0, entries.length, true);
948
+ let offset2 = 4;
949
+ for (const entry of entries) {
950
+ view2.setUint16(offset2, entry.name.byteLength, true);
951
+ offset2 += 2;
952
+ buf2.set(entry.name, offset2);
953
+ offset2 += entry.name.byteLength;
954
+ buf2[offset2++] = entry.type;
955
+ }
956
+ return { status: 0, data: buf2 };
957
+ }
958
+ let totalSize = 4;
959
+ const nameEntries = [];
960
+ for (const childPath of children) {
961
+ const name = childPath.substring(childPath.lastIndexOf("/") + 1);
962
+ const nameBytes = encoder.encode(name);
963
+ nameEntries.push(nameBytes);
964
+ totalSize += 2 + nameBytes.byteLength;
965
+ }
966
+ const buf = new Uint8Array(totalSize);
967
+ const view = new DataView(buf.buffer);
968
+ view.setUint32(0, nameEntries.length, true);
969
+ let offset = 4;
970
+ for (const nameBytes of nameEntries) {
971
+ view.setUint16(offset, nameBytes.byteLength, true);
972
+ offset += 2;
973
+ buf.set(nameBytes, offset);
974
+ offset += nameBytes.byteLength;
975
+ }
976
+ return { status: 0, data: buf };
977
+ }
978
+ // ---- RENAME ----
979
+ rename(oldPath, newPath) {
980
+ oldPath = this.normalizePath(oldPath);
981
+ newPath = this.normalizePath(newPath);
982
+ const idx = this.pathIndex.get(oldPath);
983
+ if (idx === void 0) return { status: CODE_TO_STATUS.ENOENT };
984
+ const parentStatus = this.ensureParent(newPath);
985
+ if (parentStatus !== 0) return { status: parentStatus };
986
+ const existingIdx = this.pathIndex.get(newPath);
987
+ if (existingIdx !== void 0) {
988
+ const existingInode = this.readInode(existingIdx);
989
+ this.freeBlockRange(existingInode.firstBlock, existingInode.blockCount);
990
+ existingInode.type = INODE_TYPE.FREE;
991
+ this.writeInode(existingIdx, existingInode);
992
+ this.pathIndex.delete(newPath);
993
+ }
994
+ const inode = this.readInode(idx);
995
+ const { offset: pathOff, length: pathLen } = this.appendPath(newPath);
996
+ inode.pathOffset = pathOff;
997
+ inode.pathLength = pathLen;
998
+ inode.mtime = Date.now();
999
+ this.writeInode(idx, inode);
1000
+ this.pathIndex.delete(oldPath);
1001
+ this.pathIndex.set(newPath, idx);
1002
+ if (inode.type === INODE_TYPE.DIRECTORY) {
1003
+ const prefix = oldPath === "/" ? "/" : oldPath + "/";
1004
+ const toRename = [];
1005
+ for (const [p, i] of this.pathIndex) {
1006
+ if (p.startsWith(prefix)) {
1007
+ toRename.push([p, i]);
1008
+ }
1009
+ }
1010
+ for (const [p, i] of toRename) {
1011
+ const suffix = p.substring(oldPath.length);
1012
+ const childNewPath = newPath + suffix;
1013
+ const childInode = this.readInode(i);
1014
+ const { offset: cpo, length: cpl } = this.appendPath(childNewPath);
1015
+ childInode.pathOffset = cpo;
1016
+ childInode.pathLength = cpl;
1017
+ this.writeInode(i, childInode);
1018
+ this.pathIndex.delete(p);
1019
+ this.pathIndex.set(childNewPath, i);
1020
+ }
1021
+ }
1022
+ this.commitPending();
1023
+ return { status: 0 };
1024
+ }
1025
+ // ---- EXISTS ----
1026
+ exists(path) {
1027
+ path = this.normalizePath(path);
1028
+ const idx = this.resolvePathComponents(path, true);
1029
+ const buf = new Uint8Array(1);
1030
+ buf[0] = idx !== void 0 ? 1 : 0;
1031
+ return { status: 0, data: buf };
1032
+ }
1033
+ // ---- TRUNCATE ----
1034
+ truncate(path, len = 0) {
1035
+ path = this.normalizePath(path);
1036
+ const idx = this.resolvePathComponents(path, true);
1037
+ if (idx === void 0) return { status: CODE_TO_STATUS.ENOENT };
1038
+ const inode = this.readInode(idx);
1039
+ if (inode.type === INODE_TYPE.DIRECTORY) return { status: CODE_TO_STATUS.EISDIR };
1040
+ if (len === 0) {
1041
+ this.freeBlockRange(inode.firstBlock, inode.blockCount);
1042
+ inode.firstBlock = 0;
1043
+ inode.blockCount = 0;
1044
+ inode.size = 0;
1045
+ } else if (len < inode.size) {
1046
+ const neededBlocks = Math.ceil(len / this.blockSize);
1047
+ if (neededBlocks < inode.blockCount) {
1048
+ this.freeBlockRange(inode.firstBlock + neededBlocks, inode.blockCount - neededBlocks);
1049
+ }
1050
+ inode.blockCount = neededBlocks;
1051
+ inode.size = len;
1052
+ } else if (len > inode.size) {
1053
+ const neededBlocks = Math.ceil(len / this.blockSize);
1054
+ if (neededBlocks > inode.blockCount) {
1055
+ const oldData = this.readData(inode.firstBlock, inode.blockCount, inode.size);
1056
+ this.freeBlockRange(inode.firstBlock, inode.blockCount);
1057
+ const newFirst = this.allocateBlocks(neededBlocks);
1058
+ const newData = new Uint8Array(len);
1059
+ newData.set(oldData);
1060
+ this.writeData(newFirst, newData);
1061
+ inode.firstBlock = newFirst;
1062
+ }
1063
+ inode.blockCount = neededBlocks;
1064
+ inode.size = len;
1065
+ }
1066
+ inode.mtime = Date.now();
1067
+ this.writeInode(idx, inode);
1068
+ this.commitPending();
1069
+ return { status: 0 };
1070
+ }
1071
+ // ---- COPY ----
1072
+ copy(srcPath, destPath, flags = 0) {
1073
+ srcPath = this.normalizePath(srcPath);
1074
+ destPath = this.normalizePath(destPath);
1075
+ const srcIdx = this.resolvePathComponents(srcPath, true);
1076
+ if (srcIdx === void 0) return { status: CODE_TO_STATUS.ENOENT };
1077
+ const srcInode = this.readInode(srcIdx);
1078
+ if (srcInode.type === INODE_TYPE.DIRECTORY) return { status: CODE_TO_STATUS.EISDIR };
1079
+ if (flags & 1 && this.pathIndex.has(destPath)) {
1080
+ return { status: CODE_TO_STATUS.EEXIST };
1081
+ }
1082
+ const data = srcInode.size > 0 ? this.readData(srcInode.firstBlock, srcInode.blockCount, srcInode.size) : new Uint8Array(0);
1083
+ return this.write(destPath, data);
1084
+ }
1085
+ // ---- ACCESS ----
1086
+ access(path, mode = 0) {
1087
+ path = this.normalizePath(path);
1088
+ const idx = this.resolvePathComponents(path, true);
1089
+ if (idx === void 0) return { status: CODE_TO_STATUS.ENOENT };
1090
+ if (mode === 0) return { status: 0 };
1091
+ if (!this.strictPermissions) return { status: 0 };
1092
+ const inode = this.readInode(idx);
1093
+ const filePerm = this.getEffectivePermission(inode);
1094
+ if (mode & 4 && !(filePerm & 4)) return { status: CODE_TO_STATUS.EACCES };
1095
+ if (mode & 2 && !(filePerm & 2)) return { status: CODE_TO_STATUS.EACCES };
1096
+ if (mode & 1 && !(filePerm & 1)) return { status: CODE_TO_STATUS.EACCES };
1097
+ return { status: 0 };
1098
+ }
1099
+ getEffectivePermission(inode) {
1100
+ const modeBits = inode.mode & 511;
1101
+ if (this.processUid === inode.uid) return modeBits >>> 6 & 7;
1102
+ if (this.processGid === inode.gid) return modeBits >>> 3 & 7;
1103
+ return modeBits & 7;
1104
+ }
1105
+ // ---- REALPATH ----
1106
+ realpath(path) {
1107
+ path = this.normalizePath(path);
1108
+ const idx = this.resolvePathComponents(path, true);
1109
+ if (idx === void 0) return { status: CODE_TO_STATUS.ENOENT, data: null };
1110
+ const inode = this.readInode(idx);
1111
+ const resolvedPath = this.readPath(inode.pathOffset, inode.pathLength);
1112
+ return { status: 0, data: encoder.encode(resolvedPath) };
1113
+ }
1114
+ // ---- CHMOD ----
1115
+ chmod(path, mode) {
1116
+ path = this.normalizePath(path);
1117
+ const idx = this.resolvePathComponents(path, true);
1118
+ if (idx === void 0) return { status: CODE_TO_STATUS.ENOENT };
1119
+ const inode = this.readInode(idx);
1120
+ inode.mode = inode.mode & S_IFMT | mode & 4095;
1121
+ inode.ctime = Date.now();
1122
+ this.writeInode(idx, inode);
1123
+ return { status: 0 };
1124
+ }
1125
+ // ---- CHOWN ----
1126
+ chown(path, uid, gid) {
1127
+ path = this.normalizePath(path);
1128
+ const idx = this.resolvePathComponents(path, true);
1129
+ if (idx === void 0) return { status: CODE_TO_STATUS.ENOENT };
1130
+ const inode = this.readInode(idx);
1131
+ inode.uid = uid;
1132
+ inode.gid = gid;
1133
+ inode.ctime = Date.now();
1134
+ this.writeInode(idx, inode);
1135
+ return { status: 0 };
1136
+ }
1137
+ // ---- UTIMES ----
1138
+ utimes(path, atime, mtime) {
1139
+ path = this.normalizePath(path);
1140
+ const idx = this.resolvePathComponents(path, true);
1141
+ if (idx === void 0) return { status: CODE_TO_STATUS.ENOENT };
1142
+ const inode = this.readInode(idx);
1143
+ inode.atime = atime;
1144
+ inode.mtime = mtime;
1145
+ inode.ctime = Date.now();
1146
+ this.writeInode(idx, inode);
1147
+ return { status: 0 };
1148
+ }
1149
+ // ---- SYMLINK ----
1150
+ symlink(target, linkPath) {
1151
+ linkPath = this.normalizePath(linkPath);
1152
+ if (this.pathIndex.has(linkPath)) return { status: CODE_TO_STATUS.EEXIST };
1153
+ const parentStatus = this.ensureParent(linkPath);
1154
+ if (parentStatus !== 0) return { status: parentStatus };
1155
+ const targetBytes = encoder.encode(target);
1156
+ this.createInode(linkPath, INODE_TYPE.SYMLINK, DEFAULT_SYMLINK_MODE, targetBytes.byteLength, targetBytes);
1157
+ this.commitPending();
1158
+ return { status: 0 };
1159
+ }
1160
+ // ---- READLINK ----
1161
+ readlink(path) {
1162
+ path = this.normalizePath(path);
1163
+ const idx = this.pathIndex.get(path);
1164
+ if (idx === void 0) return { status: CODE_TO_STATUS.ENOENT, data: null };
1165
+ const inode = this.readInode(idx);
1166
+ if (inode.type !== INODE_TYPE.SYMLINK) return { status: CODE_TO_STATUS.EINVAL, data: null };
1167
+ const target = this.readData(inode.firstBlock, inode.blockCount, inode.size);
1168
+ return { status: 0, data: target };
1169
+ }
1170
+ // ---- LINK (hard link — copies the file) ----
1171
+ link(existingPath, newPath) {
1172
+ return this.copy(existingPath, newPath);
1173
+ }
1174
+ // ---- OPEN (file descriptor) ----
1175
+ open(path, flags, tabId) {
1176
+ path = this.normalizePath(path);
1177
+ const hasCreate = (flags & 64) !== 0;
1178
+ const hasTrunc = (flags & 512) !== 0;
1179
+ const hasExcl = (flags & 128) !== 0;
1180
+ let idx = this.resolvePathComponents(path, true);
1181
+ if (idx === void 0) {
1182
+ if (!hasCreate) return { status: CODE_TO_STATUS.ENOENT, data: null };
1183
+ const mode = DEFAULT_FILE_MODE & ~(this.umask & 511);
1184
+ idx = this.createInode(path, INODE_TYPE.FILE, mode, 0);
1185
+ } else if (hasExcl && hasCreate) {
1186
+ return { status: CODE_TO_STATUS.EEXIST, data: null };
1187
+ }
1188
+ if (hasTrunc) {
1189
+ this.truncate(path, 0);
1190
+ }
1191
+ const fd = this.nextFd++;
1192
+ this.fdTable.set(fd, { tabId, inodeIdx: idx, position: 0, flags });
1193
+ const buf = new Uint8Array(4);
1194
+ new DataView(buf.buffer).setUint32(0, fd, true);
1195
+ return { status: 0, data: buf };
1196
+ }
1197
+ // ---- CLOSE ----
1198
+ close(fd) {
1199
+ if (!this.fdTable.has(fd)) return { status: CODE_TO_STATUS.EBADF };
1200
+ this.fdTable.delete(fd);
1201
+ return { status: 0 };
1202
+ }
1203
+ // ---- FREAD ----
1204
+ fread(fd, length, position) {
1205
+ const entry = this.fdTable.get(fd);
1206
+ if (!entry) return { status: CODE_TO_STATUS.EBADF, data: null };
1207
+ const inode = this.readInode(entry.inodeIdx);
1208
+ const pos = position ?? entry.position;
1209
+ const readLen = Math.min(length, inode.size - pos);
1210
+ if (readLen <= 0) return { status: 0, data: new Uint8Array(0) };
1211
+ const dataOffset = this.dataOffset + inode.firstBlock * this.blockSize + pos;
1212
+ const buf = new Uint8Array(readLen);
1213
+ this.handle.read(buf, { at: dataOffset });
1214
+ if (position === null) {
1215
+ entry.position += readLen;
1216
+ }
1217
+ return { status: 0, data: buf };
1218
+ }
1219
+ // ---- FWRITE ----
1220
+ fwrite(fd, data, position) {
1221
+ const entry = this.fdTable.get(fd);
1222
+ if (!entry) return { status: CODE_TO_STATUS.EBADF, data: null };
1223
+ const inode = this.readInode(entry.inodeIdx);
1224
+ const isAppend = (entry.flags & 1024) !== 0;
1225
+ const pos = isAppend ? inode.size : position ?? entry.position;
1226
+ const endPos = pos + data.byteLength;
1227
+ if (endPos > inode.size) {
1228
+ const neededBlocks = Math.ceil(endPos / this.blockSize);
1229
+ if (neededBlocks > inode.blockCount) {
1230
+ const oldData = inode.size > 0 ? this.readData(inode.firstBlock, inode.blockCount, inode.size) : new Uint8Array(0);
1231
+ this.freeBlockRange(inode.firstBlock, inode.blockCount);
1232
+ const newFirst = this.allocateBlocks(neededBlocks);
1233
+ const newBuf = new Uint8Array(endPos);
1234
+ newBuf.set(oldData);
1235
+ newBuf.set(data, pos);
1236
+ this.writeData(newFirst, newBuf);
1237
+ inode.firstBlock = newFirst;
1238
+ inode.blockCount = neededBlocks;
1239
+ } else {
1240
+ const dataOffset = this.dataOffset + inode.firstBlock * this.blockSize + pos;
1241
+ this.handle.write(data, { at: dataOffset });
1242
+ }
1243
+ inode.size = endPos;
1244
+ } else {
1245
+ const dataOffset = this.dataOffset + inode.firstBlock * this.blockSize + pos;
1246
+ this.handle.write(data, { at: dataOffset });
1247
+ }
1248
+ inode.mtime = Date.now();
1249
+ this.writeInode(entry.inodeIdx, inode);
1250
+ if (position === null) {
1251
+ entry.position = endPos;
1252
+ }
1253
+ this.commitPending();
1254
+ const buf = new Uint8Array(4);
1255
+ new DataView(buf.buffer).setUint32(0, data.byteLength, true);
1256
+ return { status: 0, data: buf };
1257
+ }
1258
+ // ---- FSTAT ----
1259
+ fstat(fd) {
1260
+ const entry = this.fdTable.get(fd);
1261
+ if (!entry) return { status: CODE_TO_STATUS.EBADF, data: null };
1262
+ return this.encodeStatResponse(entry.inodeIdx);
1263
+ }
1264
+ // ---- FTRUNCATE ----
1265
+ ftruncate(fd, len = 0) {
1266
+ const entry = this.fdTable.get(fd);
1267
+ if (!entry) return { status: CODE_TO_STATUS.EBADF };
1268
+ const inode = this.readInode(entry.inodeIdx);
1269
+ const path = this.readPath(inode.pathOffset, inode.pathLength);
1270
+ return this.truncate(path, len);
1271
+ }
1272
+ // ---- FSYNC ----
1273
+ fsync() {
1274
+ this.commitPending();
1275
+ this.handle.flush();
1276
+ return { status: 0 };
1277
+ }
1278
+ // ---- OPENDIR ----
1279
+ opendir(path, tabId) {
1280
+ path = this.normalizePath(path);
1281
+ const idx = this.resolvePathComponents(path, true);
1282
+ if (idx === void 0) return { status: CODE_TO_STATUS.ENOENT, data: null };
1283
+ const inode = this.readInode(idx);
1284
+ if (inode.type !== INODE_TYPE.DIRECTORY) return { status: CODE_TO_STATUS.ENOTDIR, data: null };
1285
+ const fd = this.nextFd++;
1286
+ this.fdTable.set(fd, { tabId, inodeIdx: idx, position: 0, flags: 0 });
1287
+ const buf = new Uint8Array(4);
1288
+ new DataView(buf.buffer).setUint32(0, fd, true);
1289
+ return { status: 0, data: buf };
1290
+ }
1291
+ // ---- MKDTEMP ----
1292
+ mkdtemp(prefix) {
1293
+ const suffix = Math.random().toString(36).substring(2, 8);
1294
+ const path = this.normalizePath(prefix + suffix);
1295
+ const parentStatus = this.ensureParent(path);
1296
+ if (parentStatus !== 0) {
1297
+ const parentPath = path.substring(0, path.lastIndexOf("/"));
1298
+ if (parentPath) {
1299
+ this.mkdirRecursive(parentPath);
1300
+ }
1301
+ }
1302
+ const mode = DEFAULT_DIR_MODE & ~(this.umask & 511);
1303
+ this.createInode(path, INODE_TYPE.DIRECTORY, mode, 0);
1304
+ this.commitPending();
1305
+ return { status: 0, data: encoder.encode(path) };
1306
+ }
1307
+ // ========== Helpers ==========
1308
+ getDirectChildren(dirPath) {
1309
+ const prefix = dirPath === "/" ? "/" : dirPath + "/";
1310
+ const children = [];
1311
+ for (const path of this.pathIndex.keys()) {
1312
+ if (path === dirPath) continue;
1313
+ if (!path.startsWith(prefix)) continue;
1314
+ const rest = path.substring(prefix.length);
1315
+ if (!rest.includes("/")) {
1316
+ children.push(path);
1317
+ }
1318
+ }
1319
+ return children.sort();
1320
+ }
1321
+ getAllDescendants(dirPath) {
1322
+ const prefix = dirPath === "/" ? "/" : dirPath + "/";
1323
+ const descendants = [];
1324
+ for (const path of this.pathIndex.keys()) {
1325
+ if (path.startsWith(prefix)) descendants.push(path);
1326
+ }
1327
+ return descendants.sort((a, b) => {
1328
+ const da = a.split("/").length;
1329
+ const db = b.split("/").length;
1330
+ return db - da;
1331
+ });
1332
+ }
1333
+ ensureParent(path) {
1334
+ const lastSlash = path.lastIndexOf("/");
1335
+ if (lastSlash <= 0) return 0;
1336
+ const parentPath = path.substring(0, lastSlash);
1337
+ const parentIdx = this.pathIndex.get(parentPath);
1338
+ if (parentIdx === void 0) return CODE_TO_STATUS.ENOENT;
1339
+ const parentInode = this.readInode(parentIdx);
1340
+ if (parentInode.type !== INODE_TYPE.DIRECTORY) return CODE_TO_STATUS.ENOTDIR;
1341
+ return 0;
1342
+ }
1343
+ /** Clean up all fds owned by a tab */
1344
+ cleanupTab(tabId) {
1345
+ for (const [fd, entry] of this.fdTable) {
1346
+ if (entry.tabId === tabId) {
1347
+ this.fdTable.delete(fd);
1348
+ }
1349
+ }
1350
+ }
1351
+ /** Get all file paths and their data for OPFS sync */
1352
+ getAllFiles() {
1353
+ const files = [];
1354
+ for (const [path, idx] of this.pathIndex) {
1355
+ files.push({ path, idx });
1356
+ }
1357
+ return files;
1358
+ }
1359
+ /** Get file path for a file descriptor (used by OPFS sync for FD-based ops) */
1360
+ getPathForFd(fd) {
1361
+ const entry = this.fdTable.get(fd);
1362
+ if (!entry) return null;
1363
+ const inode = this.readInode(entry.inodeIdx);
1364
+ return this.readPath(inode.pathOffset, inode.pathLength);
1365
+ }
1366
+ /** Get file data by inode index */
1367
+ getInodeData(idx) {
1368
+ const inode = this.readInode(idx);
1369
+ const data = inode.size > 0 ? this.readData(inode.firstBlock, inode.blockCount, inode.size) : new Uint8Array(0);
1370
+ return { type: inode.type, data, mtime: inode.mtime };
1371
+ }
1372
+ /** Export all files/dirs/symlinks from the VFS */
1373
+ exportAll() {
1374
+ const result = [];
1375
+ for (const [path, idx] of this.pathIndex) {
1376
+ const inode = this.readInode(idx);
1377
+ let data = null;
1378
+ if (inode.type === INODE_TYPE.FILE || inode.type === INODE_TYPE.SYMLINK) {
1379
+ data = inode.size > 0 ? this.readData(inode.firstBlock, inode.blockCount, inode.size) : new Uint8Array(0);
1380
+ }
1381
+ result.push({ path, type: inode.type, data, mode: inode.mode, mtime: inode.mtime });
1382
+ }
1383
+ result.sort((a, b) => {
1384
+ if (a.type === INODE_TYPE.DIRECTORY && b.type !== INODE_TYPE.DIRECTORY) return -1;
1385
+ if (a.type !== INODE_TYPE.DIRECTORY && b.type === INODE_TYPE.DIRECTORY) return 1;
1386
+ return a.path.localeCompare(b.path);
1387
+ });
1388
+ return result;
1389
+ }
1390
+ flush() {
1391
+ this.handle.flush();
1392
+ }
1393
+ };
1394
+
1395
+ // src/workers/repair.worker.ts
1396
+ self.onmessage = async (event) => {
1397
+ try {
1398
+ const msg = event.data;
1399
+ if (msg.type === "repair") {
1400
+ self.postMessage(await handleRepair(msg.root));
1401
+ } else if (msg.type === "load") {
1402
+ self.postMessage(await handleLoad(msg.root));
1403
+ } else {
1404
+ throw new Error(`Unknown message type: ${msg.type}`);
1405
+ }
1406
+ } catch (err) {
1407
+ self.postMessage({ error: err.message || String(err) });
1408
+ }
1409
+ };
1410
+ async function navigateToRoot(root) {
1411
+ let dir = await navigator.storage.getDirectory();
1412
+ if (root && root !== "/") {
1413
+ for (const seg of root.split("/").filter(Boolean)) {
1414
+ dir = await dir.getDirectoryHandle(seg, { create: true });
1415
+ }
1416
+ }
1417
+ return dir;
1418
+ }
1419
+ async function readOPFSRecursive(dir, prefix, skip) {
1420
+ const result = [];
1421
+ for await (const [name, handle] of dir.entries()) {
1422
+ if (prefix === "" && skip.has(name)) continue;
1423
+ const fullPath = prefix ? `${prefix}/${name}` : `/${name}`;
1424
+ if (handle.kind === "directory") {
1425
+ result.push({ path: fullPath, type: "directory" });
1426
+ const children = await readOPFSRecursive(handle, fullPath, skip);
1427
+ result.push(...children);
1428
+ } else {
1429
+ const file = await handle.getFile();
1430
+ const data = await file.arrayBuffer();
1431
+ result.push({ path: fullPath, type: "file", data });
1432
+ }
1433
+ }
1434
+ return result;
1435
+ }
1436
+ async function cleanupTmpFile(rootDir) {
1437
+ try {
1438
+ await rootDir.removeEntry(".vfs.bin.tmp");
1439
+ } catch {
1440
+ }
1441
+ }
1442
+ async function verifyVFS(fileHandle) {
1443
+ const handle = await fileHandle.createSyncAccessHandle();
1444
+ try {
1445
+ const engine = new VFSEngine();
1446
+ engine.init(handle);
1447
+ } finally {
1448
+ handle.close();
1449
+ }
1450
+ }
1451
+ async function swapTmpToVFS(rootDir, tmpFileHandle) {
1452
+ await verifyVFS(tmpFileHandle);
1453
+ const vfsFileHandle = await rootDir.getFileHandle(".vfs.bin", { create: true });
1454
+ const srcHandle = await tmpFileHandle.createSyncAccessHandle();
1455
+ const dstHandle = await vfsFileHandle.createSyncAccessHandle();
1456
+ try {
1457
+ const size = srcHandle.getSize();
1458
+ dstHandle.truncate(size);
1459
+ const CHUNK = 1024 * 1024;
1460
+ const buf = new Uint8Array(CHUNK);
1461
+ for (let off = 0; off < size; off += CHUNK) {
1462
+ const n = srcHandle.read(buf, { at: off });
1463
+ dstHandle.write(n < CHUNK ? buf.subarray(0, n) : buf, { at: off });
1464
+ }
1465
+ dstHandle.flush();
1466
+ } finally {
1467
+ dstHandle.close();
1468
+ srcHandle.close();
1469
+ }
1470
+ try {
1471
+ await rootDir.removeEntry(".vfs.bin.tmp");
1472
+ } catch {
1473
+ }
1474
+ }
1475
+ async function handleRepair(root) {
1476
+ const rootDir = await navigateToRoot(root);
1477
+ await cleanupTmpFile(rootDir);
1478
+ const vfsFileHandle = await rootDir.getFileHandle(".vfs.bin");
1479
+ const file = await vfsFileHandle.getFile();
1480
+ const raw = new Uint8Array(await file.arrayBuffer());
1481
+ const fileSize = raw.byteLength;
1482
+ if (fileSize < SUPERBLOCK.SIZE) {
1483
+ throw new Error(`VFS file too small to repair (${fileSize} bytes)`);
1484
+ }
1485
+ const view = new DataView(raw.buffer);
1486
+ let inodeCount;
1487
+ let blockSize;
1488
+ let totalBlocks;
1489
+ let inodeTableOffset;
1490
+ let pathTableOffset;
1491
+ let dataOffset;
1492
+ let pathTableUsed;
1493
+ const magic = view.getUint32(SUPERBLOCK.MAGIC, true);
1494
+ const version = view.getUint32(SUPERBLOCK.VERSION, true);
1495
+ const superblockValid = magic === VFS_MAGIC && version === VFS_VERSION;
1496
+ if (superblockValid) {
1497
+ inodeCount = view.getUint32(SUPERBLOCK.INODE_COUNT, true);
1498
+ blockSize = view.getUint32(SUPERBLOCK.BLOCK_SIZE, true);
1499
+ totalBlocks = view.getUint32(SUPERBLOCK.TOTAL_BLOCKS, true);
1500
+ inodeTableOffset = view.getFloat64(SUPERBLOCK.INODE_OFFSET, true);
1501
+ pathTableOffset = view.getFloat64(SUPERBLOCK.PATH_OFFSET, true);
1502
+ dataOffset = view.getFloat64(SUPERBLOCK.DATA_OFFSET, true);
1503
+ pathTableUsed = view.getUint32(SUPERBLOCK.PATH_USED, true);
1504
+ if (blockSize === 0 || (blockSize & blockSize - 1) !== 0 || inodeCount === 0 || inodeTableOffset >= fileSize || pathTableOffset >= fileSize || dataOffset >= fileSize) {
1505
+ const layout = calculateLayout(DEFAULT_INODE_COUNT, DEFAULT_BLOCK_SIZE, INITIAL_DATA_BLOCKS);
1506
+ inodeCount = DEFAULT_INODE_COUNT;
1507
+ blockSize = DEFAULT_BLOCK_SIZE;
1508
+ totalBlocks = INITIAL_DATA_BLOCKS;
1509
+ inodeTableOffset = layout.inodeTableOffset;
1510
+ pathTableOffset = layout.pathTableOffset;
1511
+ dataOffset = layout.dataOffset;
1512
+ pathTableUsed = INITIAL_PATH_TABLE_SIZE;
1513
+ }
1514
+ } else {
1515
+ const layout = calculateLayout(DEFAULT_INODE_COUNT, DEFAULT_BLOCK_SIZE, INITIAL_DATA_BLOCKS);
1516
+ inodeCount = DEFAULT_INODE_COUNT;
1517
+ blockSize = DEFAULT_BLOCK_SIZE;
1518
+ totalBlocks = INITIAL_DATA_BLOCKS;
1519
+ inodeTableOffset = layout.inodeTableOffset;
1520
+ pathTableOffset = layout.pathTableOffset;
1521
+ dataOffset = layout.dataOffset;
1522
+ pathTableUsed = INITIAL_PATH_TABLE_SIZE;
1523
+ }
1524
+ const decoder2 = new TextDecoder("utf-8", { fatal: true });
1525
+ const recovered = [];
1526
+ let lost = 0;
1527
+ const maxInodes = Math.min(inodeCount, Math.floor((fileSize - inodeTableOffset) / INODE_SIZE));
1528
+ for (let i = 0; i < maxInodes; i++) {
1529
+ const off = inodeTableOffset + i * INODE_SIZE;
1530
+ if (off + INODE_SIZE > fileSize) break;
1531
+ const type = raw[off + INODE.TYPE];
1532
+ if (type < INODE_TYPE.FILE || type > INODE_TYPE.SYMLINK) continue;
1533
+ const inodeView = new DataView(raw.buffer, off, INODE_SIZE);
1534
+ const pathOff = inodeView.getUint32(INODE.PATH_OFFSET, true);
1535
+ const pathLength = inodeView.getUint16(INODE.PATH_LENGTH, true);
1536
+ const size = inodeView.getFloat64(INODE.SIZE, true);
1537
+ const firstBlock = inodeView.getUint32(INODE.FIRST_BLOCK, true);
1538
+ const absPathOffset = pathTableOffset + pathOff;
1539
+ if (pathLength === 0 || pathLength > 4096 || absPathOffset + pathLength > fileSize || pathOff + pathLength > pathTableUsed) {
1540
+ lost++;
1541
+ continue;
1542
+ }
1543
+ let entryPath;
1544
+ try {
1545
+ entryPath = decoder2.decode(raw.subarray(absPathOffset, absPathOffset + pathLength));
1546
+ } catch {
1547
+ lost++;
1548
+ continue;
1549
+ }
1550
+ if (!entryPath.startsWith("/") || entryPath.includes("\0")) {
1551
+ lost++;
1552
+ continue;
1553
+ }
1554
+ if (type === INODE_TYPE.DIRECTORY) {
1555
+ recovered.push({ path: entryPath, type, dataOffset: 0, dataSize: 0, contentLost: false });
1556
+ continue;
1557
+ }
1558
+ if (size < 0 || size > fileSize || !isFinite(size)) {
1559
+ lost++;
1560
+ continue;
1561
+ }
1562
+ const blockCount = inodeView.getUint32(INODE.BLOCK_COUNT, true);
1563
+ const dataStart = dataOffset + firstBlock * blockSize;
1564
+ if (dataStart + size > fileSize || firstBlock >= totalBlocks || blockCount > 0 && firstBlock + blockCount > totalBlocks) {
1565
+ recovered.push({ path: entryPath, type, dataOffset: 0, dataSize: 0, contentLost: true });
1566
+ lost++;
1567
+ continue;
1568
+ }
1569
+ recovered.push({ path: entryPath, type, dataOffset: dataStart, dataSize: size, contentLost: false });
1570
+ }
1571
+ const tmpFileHandle = await rootDir.getFileHandle(".vfs.bin.tmp", { create: true });
1572
+ const tmpHandle = await tmpFileHandle.createSyncAccessHandle();
1573
+ let repairOk = false;
1574
+ let criticalErrors = 0;
1575
+ const MAX_CRITICAL_ERRORS = 5;
1576
+ try {
1577
+ const engine = new VFSEngine();
1578
+ engine.init(tmpHandle);
1579
+ const dirs = recovered.filter((e) => e.type === INODE_TYPE.DIRECTORY && e.path !== "/").sort((a, b) => a.path.localeCompare(b.path));
1580
+ const files = recovered.filter((e) => e.type === INODE_TYPE.FILE);
1581
+ const symlinks = recovered.filter((e) => e.type === INODE_TYPE.SYMLINK);
1582
+ for (const dir of dirs) {
1583
+ if (engine.mkdir(dir.path, 16877).status !== 0) {
1584
+ criticalErrors++;
1585
+ lost++;
1586
+ if (criticalErrors >= MAX_CRITICAL_ERRORS) {
1587
+ throw new Error(`Repair aborted: too many critical errors (${criticalErrors} mkdir failures)`);
1588
+ }
1589
+ }
1590
+ }
1591
+ for (const f of files) {
1592
+ const data = f.dataSize > 0 ? raw.subarray(f.dataOffset, f.dataOffset + f.dataSize) : new Uint8Array(0);
1593
+ if (engine.write(f.path, data).status !== 0) {
1594
+ lost++;
1595
+ }
1596
+ }
1597
+ for (const sym of symlinks) {
1598
+ if (sym.dataSize === 0 && sym.contentLost) {
1599
+ lost++;
1600
+ continue;
1601
+ }
1602
+ const data = sym.dataSize > 0 ? raw.subarray(sym.dataOffset, sym.dataOffset + sym.dataSize) : new Uint8Array(0);
1603
+ let target;
1604
+ try {
1605
+ target = decoder2.decode(data);
1606
+ } catch {
1607
+ lost++;
1608
+ continue;
1609
+ }
1610
+ if (target.length === 0 || target.includes("\0")) {
1611
+ lost++;
1612
+ continue;
1613
+ }
1614
+ if (engine.symlink(target, sym.path).status !== 0) lost++;
1615
+ }
1616
+ engine.flush();
1617
+ repairOk = true;
1618
+ } finally {
1619
+ tmpHandle.close();
1620
+ if (!repairOk) {
1621
+ await cleanupTmpFile(rootDir);
1622
+ }
1623
+ }
1624
+ try {
1625
+ await swapTmpToVFS(rootDir, tmpFileHandle);
1626
+ } catch (err) {
1627
+ await cleanupTmpFile(rootDir);
1628
+ throw new Error(`Repair built a VFS but verification failed: ${err.message}`);
1629
+ }
1630
+ const entries = recovered.filter((e) => e.path !== "/").map((e) => ({
1631
+ path: e.path,
1632
+ type: e.type === INODE_TYPE.FILE ? "file" : e.type === INODE_TYPE.DIRECTORY ? "directory" : "symlink",
1633
+ size: e.dataSize,
1634
+ contentLost: e.contentLost
1635
+ }));
1636
+ return { recovered: entries.length, lost, entries };
1637
+ }
1638
+ async function handleLoad(root) {
1639
+ const rootDir = await navigateToRoot(root);
1640
+ await cleanupTmpFile(rootDir);
1641
+ const opfsEntries = await readOPFSRecursive(rootDir, "", /* @__PURE__ */ new Set([".vfs.bin", ".vfs.bin.tmp"]));
1642
+ const tmpFileHandle = await rootDir.getFileHandle(".vfs.bin.tmp", { create: true });
1643
+ const tmpHandle = await tmpFileHandle.createSyncAccessHandle();
1644
+ let buildOk = false;
1645
+ let files = 0;
1646
+ let directories = 0;
1647
+ try {
1648
+ const engine = new VFSEngine();
1649
+ engine.init(tmpHandle);
1650
+ const dirs = opfsEntries.filter((e) => e.type === "directory").sort((a, b) => a.path.localeCompare(b.path));
1651
+ for (const dir of dirs) {
1652
+ if (engine.mkdir(dir.path, 16877).status === 0) {
1653
+ directories++;
1654
+ }
1655
+ }
1656
+ const fileEntries = opfsEntries.filter((e) => e.type === "file");
1657
+ for (const file of fileEntries) {
1658
+ if (engine.write(file.path, new Uint8Array(file.data ?? new ArrayBuffer(0))).status === 0) {
1659
+ files++;
1660
+ }
1661
+ }
1662
+ engine.flush();
1663
+ buildOk = true;
1664
+ } finally {
1665
+ tmpHandle.close();
1666
+ if (!buildOk) {
1667
+ await cleanupTmpFile(rootDir);
1668
+ }
1669
+ }
1670
+ try {
1671
+ await swapTmpToVFS(rootDir, tmpFileHandle);
1672
+ } catch (err) {
1673
+ await cleanupTmpFile(rootDir);
1674
+ throw new Error(`Load built a VFS but verification failed: ${err.message}`);
1675
+ }
1676
+ return { files, directories };
1677
+ }
1678
+ //# sourceMappingURL=repair.worker.js.map