@componentor/fs 3.0.44 → 3.0.46

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.
@@ -154,6 +154,16 @@ var VFSEngine = class {
154
154
  superblockDirty = false;
155
155
  // Free inode hint — skip O(n) scan
156
156
  freeInodeHint = 0;
157
+ // Implicit directory support — tracks all directory prefixes implied by file paths.
158
+ // Rebuilt lazily when pathIndex changes (tracked via generation counter).
159
+ // Map value is the stable timestamp (ms since epoch) assigned when the implicit
160
+ // dir was first discovered, so that stat() returns consistent mtime/ctime/atime
161
+ // across repeated calls.
162
+ implicitDirs = /* @__PURE__ */ new Map();
163
+ implicitDirsGen = -1;
164
+ // generation when implicitDirs was last rebuilt
165
+ pathIndexGen = 0;
166
+ // bumped on every pathIndex mutation
157
167
  // Configurable upper bounds
158
168
  maxInodes = 4e6;
159
169
  maxBlocks = 4e6;
@@ -438,6 +448,7 @@ var VFSEngine = class {
438
448
  }
439
449
  this.pathIndex.set(path, i);
440
450
  }
451
+ this.pathIndexGen++;
441
452
  }
442
453
  // ========== Low-level inode I/O ==========
443
454
  readInode(idx) {
@@ -510,14 +521,23 @@ var VFSEngine = class {
510
521
  growPathTable(needed) {
511
522
  const newSize = Math.max(this.pathTableSize * 2, needed + INITIAL_PATH_TABLE_SIZE);
512
523
  const growth = newSize - this.pathTableSize;
513
- const dataSize = this.totalBlocks * this.blockSize;
514
- const dataBuf = new Uint8Array(dataSize);
515
- this.handle.read(dataBuf, { at: this.dataOffset });
516
524
  const newTotalSize = this.handle.getSize() + growth;
517
525
  this.handle.truncate(newTotalSize);
526
+ const dataSize = this.totalBlocks * this.blockSize;
527
+ const CHUNK = 4 * 1024 * 1024;
528
+ const scratch = new Uint8Array(Math.min(CHUNK, Math.max(dataSize, 1)));
529
+ let remaining = dataSize;
530
+ while (remaining > 0) {
531
+ const chunk = Math.min(remaining, CHUNK);
532
+ const srcAt = this.dataOffset + (remaining - chunk);
533
+ const dstAt = this.dataOffset + growth + (remaining - chunk);
534
+ const slice = chunk < scratch.length ? scratch.subarray(0, chunk) : scratch;
535
+ this.handle.read(slice, { at: srcAt });
536
+ this.handle.write(slice, { at: dstAt });
537
+ remaining -= chunk;
538
+ }
518
539
  const newBitmapOffset = this.bitmapOffset + growth;
519
540
  const newDataOffset = this.dataOffset + growth;
520
- this.handle.write(dataBuf, { at: newDataOffset });
521
541
  this.handle.write(this.bitmap, { at: newBitmapOffset });
522
542
  this.pathTableSize = newSize;
523
543
  this.bitmapOffset = newBitmapOffset;
@@ -525,6 +545,23 @@ var VFSEngine = class {
525
545
  this.superblockDirty = true;
526
546
  }
527
547
  // ========== Bitmap I/O ==========
548
+ // Write `length` zero bytes at absolute file offset `at` via a small
549
+ // reusable scratch buffer. Used to materialize POSIX "holes" when a
550
+ // write starts past the current file size — those bytes must read as
551
+ // zeros rather than whatever stale data happened to live in the
552
+ // underlying storage blocks.
553
+ zeroFileRange(at, length) {
554
+ if (length <= 0) return;
555
+ const CHUNK = 4 * 1024 * 1024;
556
+ const zeros = new Uint8Array(Math.min(length, CHUNK));
557
+ let written = 0;
558
+ while (written < length) {
559
+ const n = Math.min(CHUNK, length - written);
560
+ const slice = n < zeros.length ? zeros.subarray(0, n) : zeros;
561
+ this.handle.write(slice, { at: at + written });
562
+ written += n;
563
+ }
564
+ }
528
565
  allocateBlocks(count) {
529
566
  if (count === 0) return 0;
530
567
  const bitmap = this.bitmap;
@@ -732,6 +769,7 @@ var VFSEngine = class {
732
769
  };
733
770
  this.writeInode(idx, inode);
734
771
  this.pathIndex.set(path, idx);
772
+ this.pathIndexGen++;
735
773
  return idx;
736
774
  }
737
775
  // ========== Public API — called by server worker dispatch ==========
@@ -849,17 +887,28 @@ var VFSEngine = class {
849
887
  }
850
888
  const inode = this.readInode(existingIdx);
851
889
  if (inode.type === INODE_TYPE.DIRECTORY) return { status: CODE_TO_STATUS.EISDIR };
852
- const existing = inode.size > 0 ? this.readData(inode.firstBlock, inode.blockCount, inode.size) : new Uint8Array(0);
853
- const combined = new Uint8Array(existing.byteLength + data.byteLength);
854
- combined.set(existing);
855
- combined.set(data, existing.byteLength);
856
- const neededBlocks = Math.ceil(combined.byteLength / this.blockSize);
857
- this.freeBlockRange(inode.firstBlock, inode.blockCount);
890
+ const combinedSize = inode.size + data.byteLength;
891
+ const neededBlocks = Math.ceil(combinedSize / this.blockSize);
858
892
  const newFirst = this.allocateBlocks(neededBlocks);
859
- this.writeData(newFirst, combined);
893
+ const newBase = this.dataOffset + newFirst * this.blockSize;
894
+ if (inode.size > 0) {
895
+ const oldBase = this.dataOffset + inode.firstBlock * this.blockSize;
896
+ const CHUNK = 4 * 1024 * 1024;
897
+ const scratch = new Uint8Array(Math.min(CHUNK, inode.size));
898
+ let copied = 0;
899
+ while (copied < inode.size) {
900
+ const n = Math.min(CHUNK, inode.size - copied);
901
+ const slice = n < scratch.length ? scratch.subarray(0, n) : scratch;
902
+ this.handle.read(slice, { at: oldBase + copied });
903
+ this.handle.write(slice, { at: newBase + copied });
904
+ copied += n;
905
+ }
906
+ }
907
+ this.freeBlockRange(inode.firstBlock, inode.blockCount);
908
+ this.handle.write(data, { at: newBase + inode.size });
860
909
  inode.firstBlock = newFirst;
861
910
  inode.blockCount = neededBlocks;
862
- inode.size = combined.byteLength;
911
+ inode.size = combinedSize;
863
912
  inode.mtime = Date.now();
864
913
  this.writeInode(existingIdx, inode);
865
914
  this.commitPending();
@@ -877,6 +926,7 @@ var VFSEngine = class {
877
926
  inode.type = INODE_TYPE.FREE;
878
927
  this.writeInode(idx, inode);
879
928
  this.pathIndex.delete(path);
929
+ this.pathIndexGen++;
880
930
  if (idx < this.freeInodeHint) this.freeInodeHint = idx;
881
931
  this.commitPending();
882
932
  return { status: 0 };
@@ -885,7 +935,12 @@ var VFSEngine = class {
885
935
  stat(path) {
886
936
  path = this.normalizePath(path);
887
937
  const idx = this.resolvePathComponents(path, true);
888
- if (idx === void 0) return { status: CODE_TO_STATUS.ENOENT, data: null };
938
+ if (idx === void 0) {
939
+ if (this.isImplicitDirectory(path)) {
940
+ return this.encodeImplicitDirStatResponse(path);
941
+ }
942
+ return { status: CODE_TO_STATUS.ENOENT, data: null };
943
+ }
889
944
  return this.encodeStatResponse(idx);
890
945
  }
891
946
  // ---- LSTAT (no symlink follow for the FINAL component) ----
@@ -894,7 +949,12 @@ var VFSEngine = class {
894
949
  let idx = this.resolvePathComponents(path, false);
895
950
  if (idx === void 0) {
896
951
  idx = this.resolvePathComponents(path, true);
897
- if (idx === void 0) return { status: CODE_TO_STATUS.ENOENT, data: null };
952
+ if (idx === void 0) {
953
+ if (this.isImplicitDirectory(path)) {
954
+ return this.encodeImplicitDirStatResponse(path);
955
+ }
956
+ return { status: CODE_TO_STATUS.ENOENT, data: null };
957
+ }
898
958
  }
899
959
  return this.encodeStatResponse(idx);
900
960
  }
@@ -903,13 +963,17 @@ var VFSEngine = class {
903
963
  let nlink = inode.nlink;
904
964
  if (inode.type === INODE_TYPE.DIRECTORY) {
905
965
  const path = this.readPath(inode.pathOffset, inode.pathLength);
906
- const children = this.getDirectChildren(path);
966
+ const children = this.getDirectChildrenWithImplicit(path);
907
967
  let subdirCount = 0;
908
968
  for (const child of children) {
909
- const childIdx = this.pathIndex.get(child);
910
- if (childIdx !== void 0) {
911
- const childInode = this.readInode(childIdx);
912
- if (childInode.type === INODE_TYPE.DIRECTORY) subdirCount++;
969
+ if (child.type === "implicit") {
970
+ subdirCount++;
971
+ } else {
972
+ const childIdx = this.pathIndex.get(child.path);
973
+ if (childIdx !== void 0) {
974
+ const childInode = this.readInode(childIdx);
975
+ if (childInode.type === INODE_TYPE.DIRECTORY) subdirCount++;
976
+ }
913
977
  }
914
978
  }
915
979
  nlink = 2 + subdirCount;
@@ -935,7 +999,9 @@ var VFSEngine = class {
935
999
  if (recursive) {
936
1000
  return this.mkdirRecursive(path);
937
1001
  }
938
- if (this.pathIndex.has(path)) return { status: CODE_TO_STATUS.EEXIST, data: null };
1002
+ if (this.pathIndex.has(path) || this.isImplicitDirectory(path)) {
1003
+ return { status: CODE_TO_STATUS.EEXIST, data: null };
1004
+ }
939
1005
  const parentStatus = this.ensureParent(path);
940
1006
  if (parentStatus !== 0) return { status: parentStatus, data: null };
941
1007
  const mode = DEFAULT_DIR_MODE & ~(this.umask & 511);
@@ -970,7 +1036,26 @@ var VFSEngine = class {
970
1036
  path = this.normalizePath(path);
971
1037
  const recursive = (flags & 1) !== 0;
972
1038
  const idx = this.pathIndex.get(path);
973
- if (idx === void 0) return { status: CODE_TO_STATUS.ENOENT };
1039
+ if (idx === void 0) {
1040
+ if (this.isImplicitDirectory(path)) {
1041
+ const children2 = this.getDirectChildrenWithImplicit(path);
1042
+ if (children2.length > 0) {
1043
+ if (!recursive) return { status: CODE_TO_STATUS.ENOTEMPTY };
1044
+ for (const desc of this.getAllDescendants(path)) {
1045
+ const descIdx = this.pathIndex.get(desc);
1046
+ const descInode = this.readInode(descIdx);
1047
+ this.freeBlockRange(descInode.firstBlock, descInode.blockCount);
1048
+ descInode.type = INODE_TYPE.FREE;
1049
+ this.writeInode(descIdx, descInode);
1050
+ this.pathIndex.delete(desc);
1051
+ }
1052
+ this.pathIndexGen++;
1053
+ this.commitPending();
1054
+ }
1055
+ return { status: 0 };
1056
+ }
1057
+ return { status: CODE_TO_STATUS.ENOENT };
1058
+ }
974
1059
  const inode = this.readInode(idx);
975
1060
  if (inode.type !== INODE_TYPE.DIRECTORY) return { status: CODE_TO_STATUS.ENOTDIR };
976
1061
  const children = this.getDirectChildren(path);
@@ -988,6 +1073,7 @@ var VFSEngine = class {
988
1073
  inode.type = INODE_TYPE.FREE;
989
1074
  this.writeInode(idx, inode);
990
1075
  this.pathIndex.delete(path);
1076
+ this.pathIndexGen++;
991
1077
  if (idx < this.freeInodeHint) this.freeInodeHint = idx;
992
1078
  this.commitPending();
993
1079
  return { status: 0 };
@@ -996,20 +1082,33 @@ var VFSEngine = class {
996
1082
  readdir(path, flags = 0) {
997
1083
  path = this.normalizePath(path);
998
1084
  const resolved = this.resolvePathFull(path, true);
999
- if (!resolved) return { status: CODE_TO_STATUS.ENOENT, data: null };
1000
- const inode = this.readInode(resolved.idx);
1001
- if (inode.type !== INODE_TYPE.DIRECTORY) return { status: CODE_TO_STATUS.ENOTDIR, data: null };
1085
+ let effectiveDirPath;
1086
+ if (resolved) {
1087
+ const inode = this.readInode(resolved.idx);
1088
+ if (inode.type !== INODE_TYPE.DIRECTORY) return { status: CODE_TO_STATUS.ENOTDIR, data: null };
1089
+ effectiveDirPath = resolved.resolvedPath;
1090
+ } else if (this.isImplicitDirectory(path)) {
1091
+ effectiveDirPath = path;
1092
+ } else {
1093
+ return { status: CODE_TO_STATUS.ENOENT, data: null };
1094
+ }
1002
1095
  const withFileTypes = (flags & 1) !== 0;
1003
- const children = this.getDirectChildren(resolved.resolvedPath);
1096
+ const children = this.getDirectChildrenWithImplicit(effectiveDirPath);
1004
1097
  if (withFileTypes) {
1005
1098
  let totalSize2 = 4;
1006
1099
  const entries = [];
1007
- for (const childPath of children) {
1008
- const name = childPath.substring(childPath.lastIndexOf("/") + 1);
1100
+ for (const child of children) {
1101
+ const name = child.path.substring(child.path.lastIndexOf("/") + 1);
1009
1102
  const nameBytes = encoder.encode(name);
1010
- const childIdx = this.pathIndex.get(childPath);
1011
- const childInode = this.readInode(childIdx);
1012
- entries.push({ name: nameBytes, type: childInode.type });
1103
+ let type;
1104
+ if (child.type === "implicit") {
1105
+ type = INODE_TYPE.DIRECTORY;
1106
+ } else {
1107
+ const childIdx = this.pathIndex.get(child.path);
1108
+ const childInode = this.readInode(childIdx);
1109
+ type = childInode.type;
1110
+ }
1111
+ entries.push({ name: nameBytes, type });
1013
1112
  totalSize2 += 2 + nameBytes.byteLength + 1;
1014
1113
  }
1015
1114
  const buf2 = new Uint8Array(totalSize2);
@@ -1027,8 +1126,8 @@ var VFSEngine = class {
1027
1126
  }
1028
1127
  let totalSize = 4;
1029
1128
  const nameEntries = [];
1030
- for (const childPath of children) {
1031
- const name = childPath.substring(childPath.lastIndexOf("/") + 1);
1129
+ for (const child of children) {
1130
+ const name = child.path.substring(child.path.lastIndexOf("/") + 1);
1032
1131
  const nameBytes = encoder.encode(name);
1033
1132
  nameEntries.push(nameBytes);
1034
1133
  totalSize += 2 + nameBytes.byteLength;
@@ -1069,6 +1168,7 @@ var VFSEngine = class {
1069
1168
  this.writeInode(idx, inode);
1070
1169
  this.pathIndex.delete(oldPath);
1071
1170
  this.pathIndex.set(newPath, idx);
1171
+ this.pathIndexGen++;
1072
1172
  if (inode.type === INODE_TYPE.DIRECTORY) {
1073
1173
  const prefix = oldPath === "/" ? "/" : oldPath + "/";
1074
1174
  const toRename = [];
@@ -1097,7 +1197,7 @@ var VFSEngine = class {
1097
1197
  path = this.normalizePath(path);
1098
1198
  const idx = this.resolvePathComponents(path, true);
1099
1199
  const buf = new Uint8Array(1);
1100
- buf[0] = idx !== void 0 ? 1 : 0;
1200
+ buf[0] = idx !== void 0 || this.isImplicitDirectory(path) ? 1 : 0;
1101
1201
  return { status: 0, data: buf };
1102
1202
  }
1103
1203
  // ---- TRUNCATE ----
@@ -1122,13 +1222,29 @@ var VFSEngine = class {
1122
1222
  } else if (len > inode.size) {
1123
1223
  const neededBlocks = Math.ceil(len / this.blockSize);
1124
1224
  if (neededBlocks > inode.blockCount) {
1125
- const oldData = this.readData(inode.firstBlock, inode.blockCount, inode.size);
1126
- this.freeBlockRange(inode.firstBlock, inode.blockCount);
1127
1225
  const newFirst = this.allocateBlocks(neededBlocks);
1128
- const newData = new Uint8Array(len);
1129
- newData.set(oldData);
1130
- this.writeData(newFirst, newData);
1226
+ const newBase = this.dataOffset + newFirst * this.blockSize;
1227
+ if (inode.size > 0) {
1228
+ const oldBase = this.dataOffset + inode.firstBlock * this.blockSize;
1229
+ const CHUNK = 4 * 1024 * 1024;
1230
+ const scratch = new Uint8Array(Math.min(CHUNK, inode.size));
1231
+ let copied = 0;
1232
+ while (copied < inode.size) {
1233
+ const n = Math.min(CHUNK, inode.size - copied);
1234
+ const slice = n < scratch.length ? scratch.subarray(0, n) : scratch;
1235
+ this.handle.read(slice, { at: oldBase + copied });
1236
+ this.handle.write(slice, { at: newBase + copied });
1237
+ copied += n;
1238
+ }
1239
+ }
1240
+ this.freeBlockRange(inode.firstBlock, inode.blockCount);
1241
+ this.zeroFileRange(newBase + inode.size, len - inode.size);
1131
1242
  inode.firstBlock = newFirst;
1243
+ } else {
1244
+ this.zeroFileRange(
1245
+ this.dataOffset + inode.firstBlock * this.blockSize + inode.size,
1246
+ len - inode.size
1247
+ );
1132
1248
  }
1133
1249
  inode.blockCount = neededBlocks;
1134
1250
  inode.size = len;
@@ -1149,14 +1265,45 @@ var VFSEngine = class {
1149
1265
  if (flags & 1 && this.pathIndex.has(destPath)) {
1150
1266
  return { status: CODE_TO_STATUS.EEXIST };
1151
1267
  }
1152
- const data = srcInode.size > 0 ? this.readData(srcInode.firstBlock, srcInode.blockCount, srcInode.size) : new Uint8Array(0);
1153
- return this.write(destPath, data);
1268
+ if (srcPath === destPath) return { status: 0 };
1269
+ const srcSize = srcInode.size;
1270
+ const srcFirstBlock = srcInode.firstBlock;
1271
+ const emptyStatus = this.write(destPath, new Uint8Array(0));
1272
+ if (emptyStatus.status !== 0) return emptyStatus;
1273
+ if (srcSize === 0) return { status: 0 };
1274
+ const destIdx = this.resolvePathComponents(destPath, true);
1275
+ if (destIdx === void 0) return { status: CODE_TO_STATUS.EIO };
1276
+ const destInode = this.readInode(destIdx);
1277
+ const neededBlocks = Math.ceil(srcSize / this.blockSize);
1278
+ const newFirst = this.allocateBlocks(neededBlocks);
1279
+ const newBase = this.dataOffset + newFirst * this.blockSize;
1280
+ const srcBase = this.dataOffset + srcFirstBlock * this.blockSize;
1281
+ const CHUNK = 4 * 1024 * 1024;
1282
+ const scratch = new Uint8Array(Math.min(CHUNK, srcSize));
1283
+ let copied = 0;
1284
+ while (copied < srcSize) {
1285
+ const n = Math.min(CHUNK, srcSize - copied);
1286
+ const slice = n < scratch.length ? scratch.subarray(0, n) : scratch;
1287
+ this.handle.read(slice, { at: srcBase + copied });
1288
+ this.handle.write(slice, { at: newBase + copied });
1289
+ copied += n;
1290
+ }
1291
+ destInode.firstBlock = newFirst;
1292
+ destInode.blockCount = neededBlocks;
1293
+ destInode.size = srcSize;
1294
+ destInode.mtime = Date.now();
1295
+ this.writeInode(destIdx, destInode);
1296
+ this.commitPending();
1297
+ return { status: 0 };
1154
1298
  }
1155
1299
  // ---- ACCESS ----
1156
1300
  access(path, mode = 0) {
1157
1301
  path = this.normalizePath(path);
1158
1302
  const idx = this.resolvePathComponents(path, true);
1159
- if (idx === void 0) return { status: CODE_TO_STATUS.ENOENT };
1303
+ if (idx === void 0) {
1304
+ if (this.isImplicitDirectory(path)) return { status: 0 };
1305
+ return { status: CODE_TO_STATUS.ENOENT };
1306
+ }
1160
1307
  if (mode === 0) return { status: 0 };
1161
1308
  if (!this.strictPermissions) return { status: 0 };
1162
1309
  const inode = this.readInode(idx);
@@ -1176,7 +1323,12 @@ var VFSEngine = class {
1176
1323
  realpath(path) {
1177
1324
  path = this.normalizePath(path);
1178
1325
  const idx = this.resolvePathComponents(path, true);
1179
- if (idx === void 0) return { status: CODE_TO_STATUS.ENOENT, data: null };
1326
+ if (idx === void 0) {
1327
+ if (this.isImplicitDirectory(path)) {
1328
+ return { status: 0, data: encoder.encode(path) };
1329
+ }
1330
+ return { status: CODE_TO_STATUS.ENOENT, data: null };
1331
+ }
1180
1332
  const inode = this.readInode(idx);
1181
1333
  const resolvedPath = this.readPath(inode.pathOffset, inode.pathLength);
1182
1334
  return { status: 0, data: encoder.encode(resolvedPath) };
@@ -1314,16 +1466,35 @@ var VFSEngine = class {
1314
1466
  if (endPos > inode.size) {
1315
1467
  const neededBlocks = Math.ceil(endPos / this.blockSize);
1316
1468
  if (neededBlocks > inode.blockCount) {
1317
- const oldData = inode.size > 0 ? this.readData(inode.firstBlock, inode.blockCount, inode.size) : new Uint8Array(0);
1318
- this.freeBlockRange(inode.firstBlock, inode.blockCount);
1319
1469
  const newFirst = this.allocateBlocks(neededBlocks);
1320
- const newBuf = new Uint8Array(endPos);
1321
- newBuf.set(oldData);
1322
- newBuf.set(data, pos);
1323
- this.writeData(newFirst, newBuf);
1470
+ const newBase = this.dataOffset + newFirst * this.blockSize;
1471
+ const oldBase = this.dataOffset + inode.firstBlock * this.blockSize;
1472
+ if (inode.size > 0) {
1473
+ const CHUNK = 4 * 1024 * 1024;
1474
+ const scratch = new Uint8Array(Math.min(CHUNK, inode.size));
1475
+ let copied = 0;
1476
+ while (copied < inode.size) {
1477
+ const n = Math.min(CHUNK, inode.size - copied);
1478
+ const slice = n < scratch.length ? scratch.subarray(0, n) : scratch;
1479
+ this.handle.read(slice, { at: oldBase + copied });
1480
+ this.handle.write(slice, { at: newBase + copied });
1481
+ copied += n;
1482
+ }
1483
+ }
1484
+ this.freeBlockRange(inode.firstBlock, inode.blockCount);
1485
+ if (pos > inode.size) {
1486
+ this.zeroFileRange(newBase + inode.size, pos - inode.size);
1487
+ }
1488
+ this.handle.write(data, { at: newBase + pos });
1324
1489
  inode.firstBlock = newFirst;
1325
1490
  inode.blockCount = neededBlocks;
1326
1491
  } else {
1492
+ if (pos > inode.size) {
1493
+ this.zeroFileRange(
1494
+ this.dataOffset + inode.firstBlock * this.blockSize + inode.size,
1495
+ pos - inode.size
1496
+ );
1497
+ }
1327
1498
  const dataOffset = this.dataOffset + inode.firstBlock * this.blockSize + pos;
1328
1499
  this.handle.write(data, { at: dataOffset });
1329
1500
  }
@@ -1346,6 +1517,7 @@ var VFSEngine = class {
1346
1517
  fstat(fd) {
1347
1518
  const entry = this.fdTable.get(fd);
1348
1519
  if (!entry) return { status: CODE_TO_STATUS.EBADF, data: null };
1520
+ if (entry.implicitPath) return this.encodeImplicitDirStatResponse(entry.implicitPath);
1349
1521
  return this.encodeStatResponse(entry.inodeIdx);
1350
1522
  }
1351
1523
  // ---- FTRUNCATE ----
@@ -1368,6 +1540,7 @@ var VFSEngine = class {
1368
1540
  fchmod(fd, mode) {
1369
1541
  const entry = this.fdTable.get(fd);
1370
1542
  if (!entry) return { status: CODE_TO_STATUS.EBADF };
1543
+ if (entry.implicitPath) return { status: 0 };
1371
1544
  const inode = this.readInode(entry.inodeIdx);
1372
1545
  inode.mode = inode.mode & S_IFMT | mode & 4095;
1373
1546
  inode.ctime = Date.now();
@@ -1378,6 +1551,7 @@ var VFSEngine = class {
1378
1551
  fchown(fd, uid, gid) {
1379
1552
  const entry = this.fdTable.get(fd);
1380
1553
  if (!entry) return { status: CODE_TO_STATUS.EBADF };
1554
+ if (entry.implicitPath) return { status: 0 };
1381
1555
  const inode = this.readInode(entry.inodeIdx);
1382
1556
  inode.uid = uid;
1383
1557
  inode.gid = gid;
@@ -1389,6 +1563,7 @@ var VFSEngine = class {
1389
1563
  futimes(fd, atime, mtime) {
1390
1564
  const entry = this.fdTable.get(fd);
1391
1565
  if (!entry) return { status: CODE_TO_STATUS.EBADF };
1566
+ if (entry.implicitPath) return { status: 0 };
1392
1567
  const inode = this.readInode(entry.inodeIdx);
1393
1568
  inode.atime = atime;
1394
1569
  inode.mtime = mtime;
@@ -1400,7 +1575,16 @@ var VFSEngine = class {
1400
1575
  opendir(path, tabId) {
1401
1576
  path = this.normalizePath(path);
1402
1577
  const idx = this.resolvePathComponents(path, true);
1403
- if (idx === void 0) return { status: CODE_TO_STATUS.ENOENT, data: null };
1578
+ if (idx === void 0) {
1579
+ if (this.isImplicitDirectory(path)) {
1580
+ const fd2 = this.nextFd++;
1581
+ this.fdTable.set(fd2, { tabId, inodeIdx: -1, position: 0, flags: 0, implicitPath: path });
1582
+ const buf2 = new Uint8Array(4);
1583
+ new DataView(buf2.buffer).setUint32(0, fd2, true);
1584
+ return { status: 0, data: buf2 };
1585
+ }
1586
+ return { status: CODE_TO_STATUS.ENOENT, data: null };
1587
+ }
1404
1588
  const inode = this.readInode(idx);
1405
1589
  if (inode.type !== INODE_TYPE.DIRECTORY) return { status: CODE_TO_STATUS.ENOTDIR, data: null };
1406
1590
  const fd = this.nextFd++;
@@ -1439,6 +1623,106 @@ var VFSEngine = class {
1439
1623
  }
1440
1624
  return children.sort();
1441
1625
  }
1626
+ /**
1627
+ * Rebuild the set of all implicit directory paths.
1628
+ * An implicit directory is any ancestor path of a file/symlink in pathIndex
1629
+ * that doesn't itself have an explicit inode entry.
1630
+ * Only rebuilt when pathIndex has changed (tracked via generation counter).
1631
+ */
1632
+ rebuildImplicitDirs() {
1633
+ if (this.implicitDirsGen === this.pathIndexGen) return;
1634
+ const now = Date.now();
1635
+ const prev = this.implicitDirs;
1636
+ this.implicitDirs = /* @__PURE__ */ new Map();
1637
+ for (const filePath of this.pathIndex.keys()) {
1638
+ let pos = filePath.length;
1639
+ while (true) {
1640
+ pos = filePath.lastIndexOf("/", pos - 1);
1641
+ if (pos <= 0) break;
1642
+ const ancestor = filePath.substring(0, pos);
1643
+ if (this.implicitDirs.has(ancestor)) break;
1644
+ if (!this.pathIndex.has(ancestor)) {
1645
+ this.implicitDirs.set(ancestor, prev.get(ancestor) ?? now);
1646
+ }
1647
+ }
1648
+ }
1649
+ this.implicitDirsGen = this.pathIndexGen;
1650
+ }
1651
+ /**
1652
+ * Check if a path is an implicit directory (exists because files exist under it,
1653
+ * but no explicit directory inode was created for it).
1654
+ */
1655
+ isImplicitDirectory(path) {
1656
+ if (path === "/") return false;
1657
+ this.rebuildImplicitDirs();
1658
+ return this.implicitDirs.has(path);
1659
+ }
1660
+ /**
1661
+ * Get direct children of a directory path, including implicit subdirectories.
1662
+ * Returns unique child full paths. Each entry is tagged with whether it's a
1663
+ * real inode or an implicit directory.
1664
+ */
1665
+ getDirectChildrenWithImplicit(dirPath) {
1666
+ const prefix = dirPath === "/" ? "/" : dirPath + "/";
1667
+ const childNames = /* @__PURE__ */ new Map();
1668
+ for (const path of this.pathIndex.keys()) {
1669
+ if (path === dirPath) continue;
1670
+ if (!path.startsWith(prefix)) continue;
1671
+ const rest = path.substring(prefix.length);
1672
+ const slashPos = rest.indexOf("/");
1673
+ if (slashPos === -1) {
1674
+ childNames.set(rest, "real");
1675
+ } else {
1676
+ const childName = rest.substring(0, slashPos);
1677
+ if (!childNames.has(childName)) {
1678
+ const childFullPath = prefix + childName;
1679
+ childNames.set(childName, this.pathIndex.has(childFullPath) ? "real" : "implicit");
1680
+ }
1681
+ }
1682
+ }
1683
+ const result = [];
1684
+ for (const [name, type] of childNames) {
1685
+ result.push({ path: prefix + name, type });
1686
+ }
1687
+ result.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
1688
+ return result;
1689
+ }
1690
+ /**
1691
+ * Encode a synthetic stat response for an implicit directory.
1692
+ * Returns directory stats with default mode, zero size, current timestamps.
1693
+ */
1694
+ encodeImplicitDirStatResponse(path) {
1695
+ this.rebuildImplicitDirs();
1696
+ const ts = this.implicitDirs.get(path) ?? Date.now();
1697
+ const mode = DEFAULT_DIR_MODE & ~(this.umask & 511);
1698
+ const children = this.getDirectChildrenWithImplicit(path);
1699
+ let subdirCount = 0;
1700
+ for (const child of children) {
1701
+ if (child.type === "implicit") {
1702
+ subdirCount++;
1703
+ } else {
1704
+ const childIdx = this.pathIndex.get(child.path);
1705
+ if (childIdx !== void 0) {
1706
+ const childInode = this.readInode(childIdx);
1707
+ if (childInode.type === INODE_TYPE.DIRECTORY) subdirCount++;
1708
+ }
1709
+ }
1710
+ }
1711
+ const nlink = 2 + subdirCount;
1712
+ const buf = new Uint8Array(53);
1713
+ const view = new DataView(buf.buffer);
1714
+ view.setUint8(0, INODE_TYPE.DIRECTORY);
1715
+ view.setUint32(1, mode, true);
1716
+ view.setFloat64(5, 0, true);
1717
+ view.setFloat64(13, ts, true);
1718
+ view.setFloat64(21, ts, true);
1719
+ view.setFloat64(29, ts, true);
1720
+ view.setUint32(37, this.processUid, true);
1721
+ view.setUint32(41, this.processGid, true);
1722
+ view.setUint32(45, 0, true);
1723
+ view.setUint32(49, nlink, true);
1724
+ return { status: 0, data: buf };
1725
+ }
1442
1726
  getAllDescendants(dirPath) {
1443
1727
  const prefix = dirPath === "/" ? "/" : dirPath + "/";
1444
1728
  const descendants = [];
@@ -1456,7 +1740,10 @@ var VFSEngine = class {
1456
1740
  if (lastSlash <= 0) return 0;
1457
1741
  const parentPath = path.substring(0, lastSlash);
1458
1742
  const parentIdx = this.pathIndex.get(parentPath);
1459
- if (parentIdx === void 0) return CODE_TO_STATUS.ENOENT;
1743
+ if (parentIdx === void 0) {
1744
+ if (this.isImplicitDirectory(parentPath)) return 0;
1745
+ return CODE_TO_STATUS.ENOENT;
1746
+ }
1460
1747
  const parentInode = this.readInode(parentIdx);
1461
1748
  if (parentInode.type !== INODE_TYPE.DIRECTORY) return CODE_TO_STATUS.ENOTDIR;
1462
1749
  return 0;