@poe-platform/safe-fs 0.1.729 → 0.1.730

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,6 +25,14 @@ console.log(await fs.readFile("note.txt", "utf8"));
25
25
 
26
26
  Output: `hello`, then `hello world`. Nothing touches the host filesystem. Raw adapters exchange `Uint8Array` values; the Node bridge adds strings, encodings, `Buffer` results, and stat predicates. Its `cwd` is the relative-path base and, by default, the confinement boundary. Set an explicit `root` to use a different boundary, for example `{ cwd: "/work", root: "/" }` to address a complete provider namespace. The bridge supports `unlink` when the provider offers atomic file removal; unsupported methods fail without host fallback. `createHostFileSystem()` provides trusted, unrestricted native host access when a rooted adapter is not desired.
27
27
 
28
+ Under the `workerd` export condition, `@poe-platform/safe-fs/fs/real` reads the
29
+ native request-local filesystem, including retained `/tmp` file bytes.
30
+ The restricted profile refuses generic descriptors, permission/timestamp
31
+ changes, exact creation modes, and owned staging; it promises neither atomic
32
+ rename nor inode/version identity from placeholder native metadata. Retained
33
+ reads remain available, with native seek reporting `ENOTSUP`. Ordinary browser
34
+ bundles cannot import this backend. Node keeps its POSIX host profile.
35
+
28
36
  For host storage, use `await createRealFileSystem({ root: "/absolute/existing/directory" })` instead. The root must already exist; virtual `/` maps to that directory. ZIP creation and updates can use this adapter's private owned staging in an isolated host tree. Unzip extraction requires atomic ancestry verification, currently supported by MemoryFileSystem; the real adapter and mount views refuse it. Its `trustedOwnedStaging` capability checks original entries before publication and cleanup, and preserves foreign staging children. It does not advertise atomic conditional mutations: keep external writers and other in-flight writes away from the tree during these operations. Read the safety boundary below before exposing it to untrusted code.
29
37
 
30
38
  To create new outputs with Safe Bash's `dos2unix`, `unix2dos`, or compression commands, host adapters need atomic no-replace publication. Supply `createRealFileSystem({ root, renameNoReplace })` only when that callback binds a qualified native primitive such as Linux `renameat2` with `RENAME_NOREPLACE`. It receives resolved absolute host paths and an optional signal in its third argument. Existing destinations must reject `EEXIST` atomically; existence checks followed by rename and copy/delete are insufficient. Without this binding the adapter explicitly refuses those operations. See the [no-replace contract](src/contracts/filesystem.md#atomic-no-replace-rename).
@@ -108,7 +116,7 @@ See the [staging contract](src/contracts/filesystem.md#atomic-owned-staging).
108
116
  | Backend or wrapper | Use it for |
109
117
  | --- | --- |
110
118
  | `createMemoryFileSystem()` | Isolated, nonpersistent storage with links, permissions, timestamps, and streams; each path resolution admits at most 65,536 cumulative UTF-16 code units across the input and followed symlink targets, rejecting excess with `ENAMETOOLONG` before component allocation |
111
- | `createRealFileSystem({ root })` | An existing host directory, with virtual paths rooted inside it; Node only |
119
+ | `createRealFileSystem({ root })` | An existing host directory, with virtual paths rooted inside it; Node or qualified Workerd operations |
112
120
  | `new S3FileSystem({ transport, bucket, … })` | Bucket/prefix storage through an explicitly supplied transport; Node only |
113
121
  | `new WebDavFileSystem({ baseUrl, fetch, … })` | A WebDAV namespace through an explicitly supplied Fetch implementation |
114
122
  | `createReadOnlyFileSystem(filesystem)` | Rejecting writes through one view of an existing filesystem |
@@ -78,6 +78,7 @@ export interface FileSystemCapabilities {
78
78
  readonly atomicFileStaging?: boolean;
79
79
  /** Creates a cleanup handle bound to owned staging entries, independent of ancestor paths. */
80
80
  readonly retainedStagingCleanup?: boolean;
81
+ readonly retainedStagingWrite?: boolean;
81
82
  /** Atomically verifies every supplied root-to-parent directory identity at publication. */
82
83
  readonly atomicStagingAncestry?: boolean;
83
84
  /** Prepares directory guards that validate without yielding or mutating state. */
@@ -249,6 +250,13 @@ export interface FileStaging {
249
250
  readonly directory: FileStagingEntry;
250
251
  readonly file: FileStagingEntry;
251
252
  readonly cleanup?: FileStagingCleanup;
253
+ /** Available for retained regular-file staging on qualified backends. Writes
254
+ * append to the owned entry; finish seals it and returns its publication stat.
255
+ * Cleanup owns the lifetime and checks the last successfully written revision. */
256
+ readonly writer?: {
257
+ write(bytes: Uint8Array, options?: FsOptions): Promise<void>;
258
+ finish(options?: FsOptions): Promise<FileStat>;
259
+ };
252
260
  }
253
261
  export type StagedFileContent = {
254
262
  readonly type: "file";
@@ -12,7 +12,7 @@ export * from "./fs/mount/index.js";
12
12
  export * from "./fs/overlay/index.js";
13
13
  export * from "./fs/quota/index.js";
14
14
  export * from "./fs/object-publication/index.js";
15
- export { scopeFileSystem, retainFileSystemCleanup } from "./fs/scoped.js";
15
+ export { retargetScopedFileSystem, scopeFileSystem, retainFileSystemCleanup } from "./fs/scoped.js";
16
16
  export type { RetainedFileSystemCleanupView, RetainedFileSystemCleanupOptions } from "./fs/scoped.js";
17
17
  export * from "./fs/webdav/index.js";
18
18
  export * from "./bridge/index.js";
@@ -11,7 +11,7 @@ export * from "./fs/mount/index.js";
11
11
  export * from "./fs/overlay/index.js";
12
12
  export * from "./fs/quota/index.js";
13
13
  export * from "./fs/object-publication/index.js";
14
- export { scopeFileSystem, retainFileSystemCleanup } from "./fs/scoped.js";
14
+ export { retargetScopedFileSystem, scopeFileSystem, retainFileSystemCleanup } from "./fs/scoped.js";
15
15
  export * from "./fs/webdav/index.js";
16
16
  export * from "./bridge/index.js";
17
17
  export * from "./python/index.js";
@@ -105,7 +105,7 @@ export function readOnlyCapabilities(capabilities) {
105
105
  "symlinks", "streamingRead", "open", "retainedRead", "versionedDescriptors",
106
106
  ].filter(name => capabilities[name] !== undefined).map(name => [name, capabilities[name]]));
107
107
  return Object.freeze({
108
- ...inspection, retainedStagingCleanup: false, readOnly: true, write: false, append: false, exclusiveCreate: false,
108
+ ...inspection, retainedStagingCleanup: false, retainedStagingWrite: false, readOnly: true, write: false, append: false, exclusiveCreate: false,
109
109
  synchronousStagingResolution: false,
110
110
  mkdir: false, recursiveMkdir: false, remove: false, removeDirectory: false, recursiveRemove: false,
111
111
  rename: false, copy: false, exclusiveCopy: false, truncate: false, streamingAppend: false,
@@ -118,7 +118,7 @@ export function quotaCapabilities(capabilities) {
118
118
  const streamingWrite = requireCapabilities(capabilities.write, capabilities.append, !capabilities.readOnly);
119
119
  const streamingAppend = requireCapabilities(capabilities.append, !capabilities.readOnly);
120
120
  const { streamingWrite: ignoredWrite, streamingAppend: ignoredAppend, ...rest } = capabilities;
121
- return Object.freeze({ ...rest, synchronousStagingResolution: false, retainedStagingCleanup: false, atomicStagingAncestry: false, synchronousDirectoryValidation: false, guardedStagingPublication: false, atomicFilePublication: false, descriptorWriteStream: false, atomicResize: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicEntryRemovalReceipt: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
121
+ return Object.freeze({ ...rest, synchronousStagingResolution: false, retainedStagingCleanup: false, retainedStagingWrite: false, atomicStagingAncestry: false, synchronousDirectoryValidation: false, guardedStagingPublication: false, atomicFilePublication: false, descriptorWriteStream: false, atomicResize: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicEntryRemovalReceipt: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
122
122
  ...(streamingWrite === undefined ? {} : { streamingWrite }),
123
123
  ...(streamingAppend === undefined ? {} : { streamingAppend }),
124
124
  });
@@ -143,6 +143,8 @@ export function ownedMutationCapabilities(filesystem, capabilities = filesystem.
143
143
  (unavailable ??= {}).atomicFileStaging = false;
144
144
  if (capabilities.retainedStagingCleanup === true && (capabilities.atomicFileStaging !== true || unavailable?.atomicFileStaging === false))
145
145
  (unavailable ??= {}).retainedStagingCleanup = false;
146
+ if (capabilities.retainedStagingWrite === true && (capabilities.retainedStagingCleanup !== true || unavailable?.retainedStagingCleanup === false))
147
+ (unavailable ??= {}).retainedStagingWrite = false;
146
148
  if (capabilities.atomicStagingAncestry === true && (capabilities.atomicFileStaging !== true || unavailable?.atomicFileStaging === false))
147
149
  (unavailable ??= {}).atomicStagingAncestry = false;
148
150
  if (capabilities.synchronousDirectoryValidation === true && typeof filesystem.prepareDirectoryAncestry !== "function")
@@ -44,6 +44,39 @@ const EMPTY_ALLOC_BYTES = new Uint8Array(0);
44
44
  const SMALL_ALLOC_SLAB_SIZE = 8192;
45
45
  let smallAllocSlab = new Uint8Array(SMALL_ALLOC_SLAB_SIZE);
46
46
  let smallAllocOffset = 0;
47
+ const DUMMY_POOL_LEDGER = new MemoryLedger(normalizeMemoryFileSystemLimits({}));
48
+ const DUMMY_POOL_ALLOCATION = new MemoryAllocation(EMPTY_ALLOC_BYTES, DUMMY_POOL_LEDGER);
49
+ const sharedAllocationPool = [];
50
+ const sharedFileNodePool = [];
51
+ const sharedDirectoryNodePool = [];
52
+ const sharedRemovedScratch = [];
53
+ let sharedRemovedKeyChars = 0;
54
+ let sharedRemovedKeyEntries = 0;
55
+ function collectRemovedChild(child, name) {
56
+ sharedRemovedScratch.push(child);
57
+ sharedRemovedKeyChars += name.length;
58
+ sharedRemovedKeyEntries++;
59
+ }
60
+ function replenishSharedMemoryPools() {
61
+ while (sharedAllocationPool.length < 112) {
62
+ if (smallAllocOffset + 64 > SMALL_ALLOC_SLAB_SIZE) {
63
+ smallAllocSlab = new Uint8Array(SMALL_ALLOC_SLAB_SIZE);
64
+ smallAllocOffset = 0;
65
+ }
66
+ const slice = smallAllocSlab.subarray(smallAllocOffset, smallAllocOffset + 64);
67
+ smallAllocOffset += 64;
68
+ DUMMY_POOL_LEDGER.reserve(64, 0, "init", "/");
69
+ const alloc = new MemoryAllocation(slice, DUMMY_POOL_LEDGER);
70
+ alloc.release();
71
+ sharedAllocationPool.push(alloc);
72
+ }
73
+ while (sharedFileNodePool.length < 112) {
74
+ sharedFileNodePool.push(new MemoryFileNode(0, 0, 0, 0, DUMMY_POOL_ALLOCATION, undefined));
75
+ }
76
+ while (sharedDirectoryNodePool.length < 16) {
77
+ sharedDirectoryNodePool.push(new MemoryDirectoryNode(0, 0, 0));
78
+ }
79
+ }
47
80
  class MemoryDirectoryNode {
48
81
  constructor(mode, ino, now) {
49
82
  this.mode = mode;
@@ -271,7 +304,7 @@ export class MemoryFileSystem {
271
304
  permissions: true,
272
305
  timestamps: true,
273
306
  atomicRename: true,
274
- atomicFileStaging: true, retainedStagingCleanup: true, atomicStagingAncestry: true, atomicFileMutation: true, atomicEntryRemoval: true, atomicEntryRemovalReceipt: true, atomicTreeRemoval: true,
307
+ atomicFileStaging: true, retainedStagingCleanup: true, retainedStagingWrite: true, atomicStagingAncestry: true, atomicFileMutation: true, atomicEntryRemoval: true, atomicEntryRemovalReceipt: true, atomicTreeRemoval: true,
275
308
  synchronousDirectoryValidation: true, synchronousStagingResolution: true, guardedStagingPublication: true,
276
309
  atomicDirectoryMetadata: true,
277
310
  streamingRead: true,
@@ -289,6 +322,9 @@ export class MemoryFileSystem {
289
322
  symlinkCount = 0;
290
323
  constructor(options = {}) {
291
324
  this.ledger = new MemoryLedger(normalizeMemoryFileSystemLimits(options));
325
+ if (this.ledger.hasInfiniteRetained && this.ledger.limits.maxFileBytes === Infinity) {
326
+ replenishSharedMemoryPools();
327
+ }
292
328
  memoryCaches.set(this.ledger, new MemoryCache());
293
329
  this.ledger.reserve(0, 1, "mkdir", "/");
294
330
  this.root = this.directory(0o755);
@@ -380,7 +416,7 @@ export class MemoryFileSystem {
380
416
  retained.set("/", this.root);
381
417
  }
382
418
  const capabilities = this.capabilities;
383
- const allowed = new Set(["mkdir", "rm", "rmdir", "rename", "symlink", "link", "chmod", "utimes", "writeFile", "appendFile", "writeStream", "writeFileConditional", "removeFileConditional", "prepareDirectory", "createStagedFile", "publishStagedFile", "removeStagedFile"]);
419
+ const allowed = new Set(["mkdir", "rm", "rmdir", "rename", "symlink", "link", "chmod", "utimes", "writeFile", "appendFile", "writeStream", "writeFileConditional", "removeFileConditional", "removeEntryConditional", "prepareDirectory", "createStagedFile", "publishStagedFile", "removeStagedFile"]);
384
420
  const reads = new Set(["access", "capabilitiesFor", "compareEntry", "lstat", "stat", "readFile", "readStream", "readdir", "readlink", "realpath"]);
385
421
  const check = (path, followFinal) => {
386
422
  if (!roots.some(root => root === "/" || path === root || path.startsWith(`${root}/`)))
@@ -430,6 +466,24 @@ export class MemoryFileSystem {
430
466
  this.fail("EPERM", "writeStream", path);
431
467
  } };
432
468
  }
469
+ if (property === "createStagedFile")
470
+ return Reflect.apply(value, target, args).then(staging => {
471
+ const writer = staging.writer;
472
+ if (!writer)
473
+ return staging;
474
+ return Object.freeze({ ...staging, writer: Object.freeze({
475
+ write: async (bytes, controls = {}) => {
476
+ controls.signal?.throwIfAborted();
477
+ check(staging.file.path, true);
478
+ await writer.write(bytes, controls);
479
+ },
480
+ finish: async (controls = {}) => {
481
+ controls.signal?.throwIfAborted();
482
+ check(staging.file.path, true);
483
+ return writer.finish(controls);
484
+ },
485
+ }) });
486
+ });
433
487
  return Reflect.apply(value, target, args);
434
488
  };
435
489
  },
@@ -446,7 +500,7 @@ export class MemoryFileSystem {
446
500
  const now = Date.now();
447
501
  const fullMode = typeModes.directory | mode;
448
502
  const ino = this.nextInode++;
449
- const pooled = memoryCaches.get(this.ledger)?.directories.pop();
503
+ const pooled = sharedDirectoryNodePool.pop() ?? memoryCaches.get(this.ledger)?.directories.pop();
450
504
  if (pooled) {
451
505
  pooled.mode = fullMode;
452
506
  pooled.ino = ino;
@@ -511,11 +565,21 @@ export class MemoryFileSystem {
511
565
  this.totalBytes -= node.byteLength;
512
566
  const alloc = node.allocation;
513
567
  node.view = undefined;
568
+ node.allocation = DUMMY_POOL_ALLOCATION;
514
569
  alloc.release();
515
- if (alloc.isReleased64() && cache.allocations.length < 128) {
516
- cache.allocations.push(alloc);
570
+ if (alloc.isReleased64()) {
571
+ alloc.detachLedger(DUMMY_POOL_LEDGER);
572
+ if (this.ledger.hasInfiniteRetained && this.ledger.limits.maxFileBytes === Infinity && sharedAllocationPool.length < 128) {
573
+ sharedAllocationPool.push(alloc);
574
+ }
575
+ else if (cache.allocations.length < 128) {
576
+ cache.allocations.push(alloc);
577
+ }
517
578
  }
518
- if (cache.files.length < 128) {
579
+ if (sharedFileNodePool.length < 128) {
580
+ sharedFileNodePool.push(node);
581
+ }
582
+ else if (cache.files.length < 128) {
519
583
  cache.files.push(node);
520
584
  }
521
585
  }
@@ -527,8 +591,13 @@ export class MemoryFileSystem {
527
591
  cache.lastFastDirPrefix = "";
528
592
  cache.lastFastDirNode = undefined;
529
593
  }
530
- if (node.entries.size === 0 && cache.directories.length < 32) {
531
- cache.directories.push(node);
594
+ if (node.entries.size === 0) {
595
+ if (sharedDirectoryNodePool.length < 32) {
596
+ sharedDirectoryNodePool.push(node);
597
+ }
598
+ else if (cache.directories.length < 32) {
599
+ cache.directories.push(node);
600
+ }
532
601
  }
533
602
  }
534
603
  }
@@ -900,11 +969,14 @@ export class MemoryFileSystem {
900
969
  if (length === 0) {
901
970
  return new MemoryAllocation(EMPTY_ALLOC_BYTES, this.ledger);
902
971
  }
903
- const allocations = memoryCaches.get(this.ledger).allocations;
904
- if (length === 64 && allocations.length > 0) {
905
- const pooled = allocations.pop();
906
- pooled.reuse();
907
- return pooled;
972
+ if (length === 64) {
973
+ const pooled = (this.ledger.hasInfiniteRetained && this.ledger.limits.maxFileBytes === Infinity
974
+ ? sharedAllocationPool.pop()
975
+ : undefined) ?? memoryCaches.get(this.ledger).allocations.pop();
976
+ if (pooled) {
977
+ pooled.reuse(this.ledger);
978
+ return pooled;
979
+ }
908
980
  }
909
981
  try {
910
982
  // A shared slab's backing buffer exceeds the admitted slice capacity.
@@ -1020,7 +1092,7 @@ export class MemoryFileSystem {
1020
1092
  const now = Date.now();
1021
1093
  const fileMode = typeModes.file | target.mode;
1022
1094
  const view = capacity === length ? allocation.data : undefined;
1023
- const pooled = memoryCaches.get(this.ledger).files.pop();
1095
+ const pooled = sharedFileNodePool.pop() ?? memoryCaches.get(this.ledger).files.pop();
1024
1096
  if (pooled) {
1025
1097
  pooled.mode = fileMode;
1026
1098
  pooled.ino = this.nextInode++;
@@ -1183,7 +1255,7 @@ export class MemoryFileSystem {
1183
1255
  return;
1184
1256
  }
1185
1257
  if (!current) {
1186
- const capacity = length;
1258
+ const capacity = length > 0 && length < 64 && this.ledger.hasInfiniteRetained && this.ledger.limits.maxFileBytes === Infinity ? 64 : length;
1187
1259
  const allocation = this.allocate(capacity, syscall, path);
1188
1260
  try {
1189
1261
  allocation.data.set(data);
@@ -1191,8 +1263,8 @@ export class MemoryFileSystem {
1191
1263
  try {
1192
1264
  const now = Date.now();
1193
1265
  const fileMode = typeModes.file | mode;
1194
- const view = allocation.data;
1195
- let node = cache.files.pop();
1266
+ const view = capacity === length ? allocation.data : undefined;
1267
+ let node = sharedFileNodePool.pop() ?? cache.files.pop();
1196
1268
  if (node) {
1197
1269
  node.mode = fileMode;
1198
1270
  node.ino = this.nextInode++;
@@ -1326,7 +1398,7 @@ export class MemoryFileSystem {
1326
1398
  return;
1327
1399
  }
1328
1400
  if (!current) {
1329
- const capacity = length;
1401
+ const capacity = length > 0 && length < 64 && this.ledger.hasInfiniteRetained && this.ledger.limits.maxFileBytes === Infinity ? 64 : length;
1330
1402
  const allocation = this.allocate(capacity, syscall, name);
1331
1403
  try {
1332
1404
  allocation.data.set(data);
@@ -1334,8 +1406,8 @@ export class MemoryFileSystem {
1334
1406
  try {
1335
1407
  const now = Date.now();
1336
1408
  const fileMode = typeModes.file | mode;
1337
- const view = allocation.data;
1338
- let node = cache.files.pop();
1409
+ const view = capacity === length ? allocation.data : undefined;
1410
+ let node = sharedFileNodePool.pop() ?? cache.files.pop();
1339
1411
  if (node) {
1340
1412
  node.mode = fileMode;
1341
1413
  node.ino = this.nextInode++;
@@ -1517,13 +1589,16 @@ export class MemoryFileSystem {
1517
1589
  location.parent.references++;
1518
1590
  directory.references++;
1519
1591
  file.references++;
1520
- return Object.freeze({ ...staging, cleanup: this.retainStagingCleanup(staging, location.parent, directory, file, location.name, name) });
1592
+ return Object.freeze({ ...staging, ...this.retainStagingCleanup(staging, location.parent, directory, file, location.name, name) });
1521
1593
  }
1522
1594
  retainStagingCleanup(staging, parent, directory, file, directoryName, fileName) {
1523
1595
  // Clear this state on release, so keeping the closed handle does not keep
1524
1596
  // detached nodes alive outside the Memory ledger.
1525
1597
  let retained = { parent, directory, file };
1526
- return createStagingCleanup(staging.directory.path, options => {
1598
+ let expected = staging.file.stat;
1599
+ let writing = true;
1600
+ const receipt = () => ({ ...staging, file: { ...staging.file, stat: expected } });
1601
+ const ownedCleanup = createStagingCleanup(staging.directory.path, options => {
1527
1602
  options.signal?.throwIfAborted();
1528
1603
  const state = retained;
1529
1604
  if (state.parent.nlink === 0 || state.parent.entries.get(directoryName) !== state.directory)
@@ -1535,7 +1610,7 @@ export class MemoryFileSystem {
1535
1610
  const child = state.directory.entries.get(fileName);
1536
1611
  if (child && child !== state.file)
1537
1612
  this.fail("EAGAIN", "removeStagedFile", staging.file.path);
1538
- this.removeStagingLocations(staging, {
1613
+ this.removeStagingLocations(receipt(), {
1539
1614
  node: state.directory, parent: state.parent, name: directoryName, path: staging.directory.path,
1540
1615
  }, { node: child, parent: state.directory, name: fileName, path: staging.file.path });
1541
1616
  }, () => {
@@ -1545,6 +1620,43 @@ export class MemoryFileSystem {
1545
1620
  this.releaseReference(state.directory, staging.directory.path);
1546
1621
  this.releaseReference(state.parent, staging.parent.path);
1547
1622
  });
1623
+ const cleanup = Object.freeze({
1624
+ remove: (options) => { writing = false; return ownedCleanup.remove(options); },
1625
+ close: () => { writing = false; return ownedCleanup.close(); },
1626
+ });
1627
+ if (file.type !== "file")
1628
+ return { cleanup };
1629
+ const inspect = (options) => {
1630
+ options.signal?.throwIfAborted();
1631
+ if (!retained || !writing)
1632
+ this.fail("EBADF", "stagedWrite", staging.file.path);
1633
+ const location = this.stagingLocations(receipt());
1634
+ if (location.file.node !== retained.file)
1635
+ this.fail("EAGAIN", "stagedWrite", staging.file.path);
1636
+ this.expectEntry(retained.file, expected, staging.file.path);
1637
+ this.permission(retained.parent, 3, "stagedWrite", staging.parent.path);
1638
+ this.permission(retained.directory, 3, "stagedWrite", staging.directory.path);
1639
+ return retained.file;
1640
+ };
1641
+ return { cleanup, writer: Object.freeze({
1642
+ write: async (bytes, options = {}) => {
1643
+ const node = inspect(options);
1644
+ if (!(bytes instanceof Uint8Array))
1645
+ throw new TypeError("Staged writes require Uint8Array data");
1646
+ this.writeAt(node, bytes, node.byteLength, "stagedWrite", staging.file.path);
1647
+ expected = Object.freeze(this.snapshot(node));
1648
+ },
1649
+ finish: async (options = {}) => {
1650
+ const node = inspect(options);
1651
+ this.changed(node);
1652
+ // changed updates modification time; archive creation metadata is final.
1653
+ node.atimeMs = staging.file.stat.atimeMs;
1654
+ node.mtimeMs = staging.file.stat.mtimeMs;
1655
+ writing = false;
1656
+ expected = Object.freeze(this.snapshot(node));
1657
+ return expected;
1658
+ },
1659
+ }) };
1548
1660
  }
1549
1661
  async prepareDirectoryAncestry(ancestors, options = {}) {
1550
1662
  const entries = snapshotDirectoryAncestry(ancestors);
@@ -1890,38 +2002,38 @@ export class MemoryFileSystem {
1890
2002
  this.fail("EISDIR", syscall, path);
1891
2003
  const cache = memoryCaches.get(this.ledger);
1892
2004
  cache.clearWrites();
1893
- const removed = cache.removedScratch;
2005
+ const removed = sharedRemovedScratch;
1894
2006
  removed.length = 0;
1895
2007
  removed.push(node);
1896
- let keyChars = 0;
1897
- let keyEntries = 0;
1898
- for (let index = 0; index < removed.length; index++) {
1899
- const entry = removed[index];
1900
- if (entry.type === "directory" && entry.entries.size > 0) {
1901
- this.permission(entry, 7, syscall, path);
1902
- for (const [name, child] of entry.entries) {
1903
- removed.push(child);
1904
- keyChars += name.length;
1905
- keyEntries++;
2008
+ sharedRemovedKeyChars = 0;
2009
+ sharedRemovedKeyEntries = 0;
2010
+ try {
2011
+ for (let index = 0; index < removed.length; index++) {
2012
+ const entry = removed[index];
2013
+ if (entry.type === "directory" && entry.entries.size > 0) {
2014
+ this.permission(entry, 7, syscall, path);
2015
+ entry.entries.forEach(collectRemovedChild);
1906
2016
  }
1907
2017
  }
1908
- }
1909
- location.parent.entries.delete(location.name);
1910
- this.ledger.release((location.name.length + keyChars) * 2, 1 + keyEntries);
1911
- const now = Date.now();
1912
- for (let i = 0; i < removed.length; i++) {
1913
- const entry = removed[i];
1914
- if (entry.type === "directory") {
1915
- entry.entries.clear();
1916
- }
1917
- entry.nlink--;
1918
- if (entry.nlink !== 0 || entry.references !== 0) {
1919
- entry.ctimeMs = now;
1920
- entry.revision = Math.min(Number.MAX_SAFE_INTEGER + 1, entry.revision + 1);
2018
+ location.parent.entries.delete(location.name);
2019
+ this.ledger.release((location.name.length + sharedRemovedKeyChars) * 2, 1 + sharedRemovedKeyEntries);
2020
+ const now = Date.now();
2021
+ for (let i = 0; i < removed.length; i++) {
2022
+ const entry = removed[i];
2023
+ if (entry.type === "directory") {
2024
+ entry.entries.clear();
2025
+ }
2026
+ entry.nlink--;
2027
+ if (entry.nlink !== 0 || entry.references !== 0) {
2028
+ entry.ctimeMs = now;
2029
+ entry.revision = Math.min(Number.MAX_SAFE_INTEGER + 1, entry.revision + 1);
2030
+ }
2031
+ this.releaseNode(entry);
1921
2032
  }
1922
- this.releaseNode(entry);
1923
2033
  }
1924
- removed.length = 0;
2034
+ finally {
2035
+ removed.length = 0;
2036
+ }
1925
2037
  this.changed(location.parent);
1926
2038
  }
1927
2039
  unlink(path, options = {}) {
@@ -18,10 +18,11 @@ export declare class MemoryLedger {
18
18
  export declare class MemoryAllocation {
19
19
  private references;
20
20
  readonly data: Uint8Array;
21
- private readonly ledger;
21
+ private ledger;
22
22
  constructor(data: Uint8Array, ledger: MemoryLedger);
23
23
  retain(): void;
24
24
  release(): void;
25
25
  isReleased64(): boolean;
26
- reuse(): void;
26
+ detachLedger(dummyLedger: MemoryLedger): void;
27
+ reuse(ledger?: MemoryLedger): void;
27
28
  }
@@ -83,8 +83,13 @@ export class MemoryAllocation {
83
83
  isReleased64() {
84
84
  return this.references === 0 && this.data.byteLength === 64;
85
85
  }
86
- reuse() {
86
+ detachLedger(dummyLedger) {
87
+ this.ledger = dummyLedger;
88
+ }
89
+ reuse(ledger) {
87
90
  this.references = 1;
91
+ if (ledger !== undefined)
92
+ this.ledger = ledger;
88
93
  this.data.fill(0);
89
94
  }
90
95
  }
@@ -188,7 +188,7 @@ export class MountFileSystem {
188
188
  && await this.supportsDirectoryValidation(location.path.slice(0, location.path.lastIndexOf("/")) || "/", options, true)
189
189
  : this.capabilities.conditionalChmod;
190
190
  const { synchronousDirectoryValidation: ignoredValidation, synchronousStagingResolution: ignoredResolution, ...ordinary } = resize;
191
- const withOpen = { ...ordinary, ...(conditionalChmod === undefined ? {} : { conditionalChmod }), ...(resolution === undefined ? {} : { synchronousStagingResolution: resolution }), ...(validation === undefined ? {} : { synchronousDirectoryValidation: validation }), atomicStagingAncestry: ancestry, ...(typeof location.mount.backend.open === "function" ? {} : { open: false }) };
191
+ const withOpen = { ...ordinary, retainedStagingWrite: false, ...(conditionalChmod === undefined ? {} : { conditionalChmod }), ...(resolution === undefined ? {} : { synchronousStagingResolution: resolution }), ...(validation === undefined ? {} : { synchronousDirectoryValidation: validation }), atomicStagingAncestry: ancestry, ...(typeof location.mount.backend.open === "function" ? {} : { open: false }) };
192
192
  const capabilities = location.synthetic ? { ...withOpen, open: false, retainedRead: false }
193
193
  : retainedResizeCapabilities(location.mount.backend, retainedReadCapabilities(location.mount.backend, withOpen));
194
194
  if (location.synthetic)
@@ -1,3 +1,4 @@
1
+ import { platform } from "#safe-fs-platform";
1
2
  import { snapshotStagingCreation } from "../staging-cleanup.js";
2
3
  import { snapshotConditionalChmod } from "../conditional-chmod.js";
3
4
  import { runStagingGuard, snapshotDirectoryAncestry } from "../staging-ancestry.js";
@@ -7,6 +8,7 @@ import * as native from "node:fs/promises";
7
8
  import { dirname, basename, isAbsolute, join, relative, resolve, sep } from "node:path";
8
9
  import { nativeAllocatedBytes } from "./allocation.js";
9
10
  import { openFileDescriptor } from "../descriptor.js";
11
+ import { compareIdentity } from "../mount/identity.js";
10
12
  import { finishCleanup } from "../../contracts/cleanup.js";
11
13
  import { callNativeSeekEnd, loadNativeSeekBinding } from "../../node/native-seek.js";
12
14
  import { admitDirectoryEntries, directoryEntryLimit } from "../directory-admission.js";
@@ -32,11 +34,11 @@ function fileStat(stats) {
32
34
  mtimeMs: "mtimeNs" in stats ? Number(stats.mtimeNs / 1000000n) + Number(stats.mtimeNs % 1000000n) / 1e6 : stats.mtimeMs,
33
35
  ctimeMs: "ctimeNs" in stats ? Number(stats.ctimeNs / 1000000n) + Number(stats.ctimeNs % 1000000n) / 1e6 : stats.ctimeMs,
34
36
  birthtimeMs: "birthtimeNs" in stats ? Number(stats.birthtimeNs / 1000000n) + Number(stats.birthtimeNs % 1000000n) / 1e6 : stats.birthtimeMs, ino, dev,
35
- ...(Number.isSafeInteger(dev) && dev >= 0 && Number.isSafeInteger(ino) && ino >= 0
37
+ ...(Number.isSafeInteger(dev) && dev >= 0 && Number.isSafeInteger(ino) && ino > 0
36
38
  ? { identityScope: Symbol.for("virtual-bash.fs.native") } : {}),
37
39
  nlink: Number(stats.nlink), uid: Number(stats.uid), gid: Number(stats.gid),
38
40
  };
39
- if ("ctimeNs" in stats && !stats.isDirectory())
41
+ if (snapshot.identityScope !== undefined && "ctimeNs" in stats && (stats.ctimeNs !== 0n || stats.mtimeNs !== 0n) && !stats.isDirectory())
40
42
  Object.defineProperty(snapshot, "opaqueVersion", { value: `${stats.ctimeNs}:${stats.mtimeNs}`, enumerable: true });
41
43
  return snapshot;
42
44
  }
@@ -98,14 +100,13 @@ function nativeError(error) {
98
100
  */
99
101
  export class RealFileSystem {
100
102
  capabilities = Object.freeze({
101
- read: true, stat: true, readdir: true, realpath: true, access: true, open: true,
103
+ read: true, stat: true, readdir: true, realpath: true, access: true,
102
104
  write: true, append: true, exclusiveCreate: true, explicitDirectories: true, implicitDirectories: false,
103
105
  mkdir: true, recursiveMkdir: true, remove: true, removeDirectory: true, recursiveRemove: true,
104
106
  rename: true, atomicRenameNoReplace: false, copy: true, exclusiveCopy: true, readlink: true, truncate: true,
105
107
  streamingAppend: true, randomAccessWrite: true,
106
- readOnly: false, symlinks: true, hardlinks: true, permissions: true,
107
- conditionalChmod: true,
108
- timestamps: true, atomicRename: true, streamingRead: true, streamingWrite: true, retainedRead: true, retainedResize: true, trustedOwnedStaging: true,
108
+ readOnly: false, symlinks: true, hardlinks: true, streamingRead: true, streamingWrite: true, retainedRead: true, retainedResize: true,
109
+ ...platform.nativeFileSystem,
109
110
  });
110
111
  configuredRoot;
111
112
  renameNoReplace;
@@ -122,6 +123,8 @@ export class RealFileSystem {
122
123
  this.configuredRoot = root;
123
124
  this.renameNoReplace = typeof options === "string" ? undefined : options.renameNoReplace;
124
125
  if (this.renameNoReplace !== undefined) {
126
+ if (!platform.nativeFileSystem.atomicRename)
127
+ throw new FsError("ENOTSUP", { syscall: "root", message: "atomic rename is unavailable on this host" });
125
128
  if (typeof this.renameNoReplace !== "function")
126
129
  throw new FsError("EINVAL", { syscall: "root", message: "renameNoReplace must be a function" });
127
130
  this.capabilities = Object.freeze({ ...this.capabilities, atomicRenameNoReplace: true });
@@ -291,10 +294,15 @@ export class RealFileSystem {
291
294
  }
292
295
  }
293
296
  expectStaging(path, expected, identityOnly = false) {
297
+ if (!platform.nativeFileSystem.trustedOwnedStaging)
298
+ throw new FsError("ENOTSUP");
294
299
  if (expected && (expected.identityScope !== Symbol.for("virtual-bash.fs.native")
295
300
  || expected.dev === undefined || expected.ino === undefined || !identityOnly && expected.opaqueVersion === undefined))
296
301
  throw new FsError("ENOTSUP");
297
302
  const current = this.stagingSnapshot(path);
303
+ if (expected && current && (current.identityScope !== Symbol.for("virtual-bash.fs.native")
304
+ || current.dev === undefined || current.ino === undefined || !identityOnly && current.opaqueVersion === undefined))
305
+ throw new FsError("ENOTSUP");
298
306
  if (expected === null ? current !== null : !current || current.dev !== expected.dev || current.ino !== expected.ino || current.type !== expected.type
299
307
  || !identityOnly && (current.opaqueVersion !== expected.opaqueVersion || current.size !== expected.size || current.mode !== expected.mode || current.nlink !== expected.nlink))
300
308
  throw new FsError("EAGAIN");
@@ -521,6 +529,8 @@ export class RealFileSystem {
521
529
  }
522
530
  }
523
531
  open(path, options) {
532
+ if (!platform.nativeFileSystem.open)
533
+ return this.operation("open", path, options, async () => { throw new FsError("ENOTSUP"); });
524
534
  return openFileDescriptor(path, options, {
525
535
  noFollow: true, positionedRead: true, positionedWrite: true, truncate: true, synchronization: "storage",
526
536
  }, async (admitted) => {
@@ -663,7 +673,7 @@ export class RealFileSystem {
663
673
  return this.operation("mkdir", path, options, async () => {
664
674
  if (options.mode !== undefined)
665
675
  integer(options.mode);
666
- if (options.exactMode && options.recursive)
676
+ if (options.exactMode && (!platform.nativeFileSystem.permissions || options.recursive))
667
677
  throw new FsError("ENOTSUP");
668
678
  const target = await this.path(path, {
669
679
  ...options,
@@ -746,8 +756,13 @@ export class RealFileSystem {
746
756
  }
747
757
  if (target && options.exclusive)
748
758
  throw new FsError("EEXIST");
749
- if (target && origin.isFile() && origin.dev === target.dev && origin.ino === target.ino)
750
- throw new FsError("EINVAL");
759
+ if (target && origin.isFile()) {
760
+ const identity = compareIdentity(fileStat(origin), fileStat(target));
761
+ if (identity === "same")
762
+ throw new FsError("EINVAL");
763
+ if (identity === "unknown")
764
+ throw new FsError("ENOTSUP");
765
+ }
751
766
  if (!target)
752
767
  flags |= constants.COPYFILE_EXCL;
753
768
  }
@@ -813,6 +828,8 @@ export class RealFileSystem {
813
828
  }
814
829
  async chmod(path, mode, options = {}) {
815
830
  return this.operation("chmod", path, options, async () => {
831
+ if (!platform.nativeFileSystem.permissions)
832
+ throw new FsError("ENOTSUP");
816
833
  integer(mode);
817
834
  const conditional = snapshotConditionalChmod(path, options);
818
835
  if (conditional) {
@@ -846,6 +863,8 @@ export class RealFileSystem {
846
863
  }
847
864
  async utimes(path, atimeMs, mtimeMs, options = {}) {
848
865
  return this.operation("utimes", path, options, async () => {
866
+ if (!platform.nativeFileSystem.timestamps)
867
+ throw new FsError("ENOTSUP");
849
868
  if (!Number.isFinite(atimeMs) || !Number.isFinite(mtimeMs)
850
869
  || Math.abs(atimeMs) > 8.64e15 || Math.abs(mtimeMs) > 8.64e15)
851
870
  throw new FsError("EINVAL");
@@ -1,4 +1,5 @@
1
1
  import type { FileSystem, FsOptions } from "../contracts/filesystem.js";
2
+ export declare function retargetScopedFileSystem(scoped: FileSystem, charge: () => void, signal: AbortSignal, cleanupCharge?: () => void, maxPathComponents?: number): boolean;
2
3
  export declare function scopeFileSystem(filesystem: FileSystem, charge: () => void, signal: AbortSignal, cleanupCharge?: () => void, options?: {
3
4
  readonly preserveDescriptorWriteReceipt?: boolean;
4
5
  readonly creationMask?: number;
@@ -8,6 +8,14 @@ import { openRetainedResizeFile, retainedResizeCapabilities, ownedMutationCapabi
8
8
  import { createStagingCleanup, snapshotStagingCreation } from "./staging-cleanup.js";
9
9
  import { inspectStagingBindings, runStagingGuard, snapshotDirectoryAncestry, snapshotStagingResolution } from "./staging-ancestry.js";
10
10
  const originals = new WeakMap();
11
+ const retargeters = new WeakMap();
12
+ export function retargetScopedFileSystem(scoped, charge, signal, cleanupCharge = charge, maxPathComponents) {
13
+ const fn = retargeters.get(scoped);
14
+ if (!fn)
15
+ return false;
16
+ fn(charge, signal, cleanupCharge, maxPathComponents);
17
+ return true;
18
+ }
11
19
  const operations = new Set([
12
20
  "prepareDirectoryAncestry", "prepareStagingResolution",
13
21
  "publishFileConditional", "removeEntryConditional", "removeTreeConditional", "writeFileConditional", "removeFileConditional", "createStagedFile", "publishStagedFile", "removeStagedFile", "prepareDirectory",
@@ -19,7 +27,7 @@ const operations = new Set([
19
27
  export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = charge, options = {}) {
20
28
  const original = originals.get(filesystem)?.filesystem ?? filesystem;
21
29
  const creationMask = options.creationMask ?? originals.get(filesystem)?.creationMask;
22
- const maxPathComponents = options.maxPathComponents ?? originals.get(filesystem)?.maxPathComponents;
30
+ let maxPathComponents = options.maxPathComponents ?? originals.get(filesystem)?.maxPathComponents;
23
31
  if (creationMask !== undefined && (!Number.isInteger(creationMask) || creationMask < 0 || creationMask > 0o777))
24
32
  throw new RangeError("creationMask must be a permission mask between 0 and 0777");
25
33
  const creationOptions = (settings, directory = false) => {
@@ -343,7 +351,20 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
343
351
  controls.signal?.throwIfAborted();
344
352
  return backendCleanup.remove(controls);
345
353
  }, () => backendCleanup.close());
346
- return Object.freeze({ ...staging, cleanup });
354
+ const backendWriter = staging.writer;
355
+ const writer = backendWriter && Object.freeze({
356
+ write: async (bytes, settings = {}) => {
357
+ const controls = resizeOptions(settings);
358
+ admit(controls);
359
+ await backendWriter.write(bytes, controls);
360
+ },
361
+ finish: async (settings = {}) => {
362
+ const controls = resizeOptions(settings);
363
+ admit(controls);
364
+ return backendWriter.finish(controls);
365
+ },
366
+ });
367
+ return Object.freeze({ ...staging, cleanup, ...(writer ? { writer } : {}) });
347
368
  }
348
369
  catch (error) {
349
370
  await finishCleanup(() => backendCleanup.close(), true);
@@ -471,7 +492,17 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
471
492
  return scoped;
472
493
  },
473
494
  });
474
- originals.set(view, { filesystem: original, signal, cleanupCharge, creationMask, maxPathComponents });
495
+ const originalRecord = { filesystem: original, signal, cleanupCharge, creationMask, maxPathComponents };
496
+ originals.set(view, originalRecord);
497
+ retargeters.set(view, (nextCharge, nextSignal, nextCleanupCharge, nextMaxPathComponents) => {
498
+ charge = nextCharge;
499
+ signal = nextSignal;
500
+ cleanupCharge = nextCleanupCharge;
501
+ maxPathComponents = nextMaxPathComponents ?? originals.get(filesystem)?.maxPathComponents;
502
+ originalRecord.signal = nextSignal;
503
+ originalRecord.cleanupCharge = nextCleanupCharge;
504
+ originalRecord.maxPathComponents = maxPathComponents;
505
+ });
475
506
  registerEntryView(view, async (path, options) => {
476
507
  assertOpen(options);
477
508
  return { filesystem: original, path };
@@ -0,0 +1 @@
1
+ export declare function loadBinding(): Promise<never>;
@@ -0,0 +1,5 @@
1
+ import { FsError } from "../contracts/errors.js";
2
+ // Workers support native file reads, but cannot load the optional Node addon.
3
+ export async function loadBinding() {
4
+ throw new FsError("ENOTSUP", { message: "Native seek is unavailable in Workers" });
5
+ }
@@ -3,6 +3,14 @@ import type { ScopedTransportBudgetFrame } from "./transport-budget.js";
3
3
  export type PlatformErrno = number | undefined;
4
4
  export type PlatformComparisonCallback<Callback> = Callback & never;
5
5
  export declare const platform: Readonly<{
6
+ nativeFileSystem: Readonly<{
7
+ open: false;
8
+ permissions: false;
9
+ timestamps: false;
10
+ conditionalChmod: false;
11
+ trustedOwnedStaging: false;
12
+ atomicRename: false;
13
+ }>;
6
14
  maxCollectionBytes: number;
7
15
  errno(_code: string): PlatformErrno;
8
16
  callbackAuthorities: false;
@@ -1,5 +1,6 @@
1
1
  import { FsError } from "../contracts/errors.js";
2
2
  export const platform = Object.freeze({
3
+ nativeFileSystem: Object.freeze({ open: false, permissions: false, timestamps: false, conditionalChmod: false, trustedOwnedStaging: false, atomicRename: false }),
3
4
  maxCollectionBytes: 32 * 1024 * 1024,
4
5
  errno(_code) {
5
6
  return undefined;
@@ -4,6 +4,14 @@ import { type ScopedTransportBudgetFrame } from "./transport-budget.js";
4
4
  export type PlatformErrno = number;
5
5
  export type PlatformComparisonCallback<Callback> = Callback;
6
6
  export declare const platform: Readonly<{
7
+ nativeFileSystem: Readonly<{
8
+ open: true;
9
+ permissions: true;
10
+ timestamps: true;
11
+ conditionalChmod: true;
12
+ trustedOwnedStaging: true;
13
+ atomicRename: true;
14
+ }>;
7
15
  maxCollectionBytes: number;
8
16
  errno(code: string): PlatformErrno;
9
17
  callbackAuthorities: true;
@@ -16,6 +16,7 @@ const systemErrnos = (() => {
16
16
  })();
17
17
  const negotiating = new AsyncLocalStorage();
18
18
  export const platform = Object.freeze({
19
+ nativeFileSystem: Object.freeze({ open: true, permissions: true, timestamps: true, conditionalChmod: true, trustedOwnedStaging: true, atomicRename: true }),
19
20
  maxCollectionBytes: Infinity,
20
21
  errno(code) {
21
22
  const errno = systemErrnos.get(code === "EOPNOTSUPP" ? "ENOTSUP" : code);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poe-platform/safe-fs",
3
- "version": "0.1.729",
3
+ "version": "0.1.730",
4
4
  "description": "Composable filesystem with a portable core and explicit Node adapters",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -69,9 +69,11 @@
69
69
  },
70
70
  "./fs/real": {
71
71
  "types": {
72
+ "workerd": "./dist/safe-fs/fs/real/index.d.ts",
72
73
  "browser": "./dist/safe-fs/node-unavailable.d.ts",
73
74
  "default": "./dist/safe-fs/fs/real/index.d.ts"
74
75
  },
76
+ "workerd": "./dist/safe-fs/fs/real/index.js",
75
77
  "browser": null,
76
78
  "import": "./dist/safe-fs/fs/real/index.js"
77
79
  },
@@ -134,15 +136,17 @@
134
136
  "imports": {
135
137
  "#safe-fs-platform": {
136
138
  "types": {
139
+ "workerd": "./dist/safe-fs/platform/browser.d.ts",
137
140
  "browser": "./dist/safe-fs/platform/browser.d.ts",
138
141
  "default": "./dist/safe-fs/platform/node.d.ts"
139
142
  },
143
+ "workerd": "./dist/safe-fs/platform/browser.js",
140
144
  "browser": "./dist/safe-fs/platform/browser.js",
141
145
  "default": "./dist/safe-fs/platform/node.js"
142
146
  },
143
147
  "#safe-fs-native-seek": {
144
148
  "types": "./dist/safe-fs/native/fs-seek/loader.d.ts",
145
- "workerd": null,
149
+ "workerd": "./dist/safe-fs/node/native-seek-unavailable.js",
146
150
  "browser": null,
147
151
  "default": "./dist/safe-fs/native/fs-seek/loader.mjs"
148
152
  }