@poe-platform/safe-fs 0.1.805 → 0.1.807

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.
Files changed (39) hide show
  1. package/README.md +5 -5
  2. package/dist/safe-fs/bridge/filesystem.d.ts +1 -0
  3. package/dist/safe-fs/bridge/filesystem.js +3 -0
  4. package/dist/safe-fs/bridge/index.d.ts +1 -0
  5. package/dist/safe-fs/bridge/index.js +1 -0
  6. package/dist/safe-fs/config/registry.d.ts +2 -0
  7. package/dist/safe-fs/config/registry.js +15 -0
  8. package/dist/safe-fs/config.node.js +3 -14
  9. package/dist/safe-fs/config.portable.d.ts +3 -0
  10. package/dist/safe-fs/config.portable.js +9 -0
  11. package/dist/safe-fs/contracts/filesystem.d.ts +12 -0
  12. package/dist/safe-fs/contracts/index.d.ts +1 -0
  13. package/dist/safe-fs/contracts/index.js +1 -0
  14. package/dist/safe-fs/contracts/io.js +1 -1
  15. package/dist/safe-fs/core.d.ts +3 -0
  16. package/dist/safe-fs/core.js +2 -0
  17. package/dist/safe-fs/fs/devices/index.d.ts +2 -1
  18. package/dist/safe-fs/fs/devices/index.js +41 -0
  19. package/dist/safe-fs/fs/memory/index.d.ts +2 -2
  20. package/dist/safe-fs/fs/memory/index.js +89 -84
  21. package/dist/safe-fs/fs/memory/ledger.d.ts +0 -4
  22. package/dist/safe-fs/fs/memory/ledger.js +0 -15
  23. package/dist/safe-fs/fs/mount/identity.d.ts +4 -0
  24. package/dist/safe-fs/fs/mount/identity.js +8 -0
  25. package/dist/safe-fs/fs/mount/index.d.ts +1 -0
  26. package/dist/safe-fs/fs/mount/index.js +31 -0
  27. package/dist/safe-fs/fs/object-publication/index.d.ts +3 -0
  28. package/dist/safe-fs/fs/object-publication/index.js +3 -2
  29. package/dist/safe-fs/fs/quota/index.js +1 -1
  30. package/dist/safe-fs/fs/s3/http/transport.js +1 -1
  31. package/dist/safe-fs/fs/s3/http/xml.js +2 -2
  32. package/dist/safe-fs/fs/scoped.js +32 -2
  33. package/dist/safe-fs/index.d.ts +2 -0
  34. package/dist/safe-fs/index.js +2 -0
  35. package/dist/safe-fs/node/filesystem.d.ts +2 -1
  36. package/dist/safe-fs/node/filesystem.js +1 -5
  37. package/dist/safe-fs/runtime-core.d.ts +1 -0
  38. package/dist/safe-fs/runtime-core.js +1 -0
  39. package/package.json +5 -1
package/README.md CHANGED
@@ -35,7 +35,7 @@ rename nor inode/version identity from placeholder native metadata. Retained
35
35
  reads remain available, with native seek reporting `ENOTSUP`. Ordinary browser
36
36
  bundles cannot import this backend. Node keeps its POSIX host profile.
37
37
 
38
- 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.
38
+ 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. Archive extraction requires atomic ancestry verification. MemoryFileSystem supports it, including a writable root composed with read-only mounts such as `/skills/.system`; the real adapter refuses it. Mount extraction delegates confinement to the root backend and keeps other mounts read-only in the extraction view. Extraction roots inside a separate mount are rejected because directory and metadata mutations cannot atomically guard ancestors on another backend. Staged file publication supports nested writable mounts when every ancestor provides authoritative validation. 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.
39
39
 
40
40
  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).
41
41
 
@@ -148,7 +148,7 @@ Omit `maxOperations` or pass `Infinity` for unlimited cleanup operations.
148
148
  | `createOverlayFileSystem({ upper, lower })` | Reading through to a lower layer and writing changes to an upper layer |
149
149
  | `withFileSystemQuota(filesystem, { maxBytes })` | Enforcing a cumulative logical-byte ceiling across every write path |
150
150
 
151
- `createNodeFsBridge` offers a promises-shaped subset, including recursive `cp` and `mkdtemp`. A trusted `reserveReadFile` callback can reserve host resources and return a release function; cancellation retains the reservation until the backend read settles. The portable `createFsBridge` from `@poe-platform/safe-fs/core` instead requires a caller-supplied text codec and returns `Uint8Array` values.
151
+ `createNodeFsBridge` offers a promises-shaped subset, including recursive `cp` and `mkdtemp`. A trusted `reserveReadFile` callback can reserve host resources and return a release function; cancellation retains the reservation until the backend read settles. The portable `createFsBridge` from `@poe-platform/safe-fs/bridge` instead requires a caller-supplied text codec and returns `Uint8Array` values.
152
152
 
153
153
  ## Write an adapter
154
154
 
@@ -253,7 +253,7 @@ console.log(new TextDecoder().decode(await catalog.readFile("/welcome.txt")));
253
253
 
254
254
  Output: `Hello from the catalog`. `readFile` returns a copy so callers cannot mutate the stored snapshot. Reads check cancellation and `maxBytes`; required mutations always reject with `EROFS`. `stat` and `lstat` share an implementation because this adapter has no symlinks.
255
255
 
256
- `createFileSystem` validates options before calling the factory. The Node registry includes `memory` and `real`, accepts new names, and rejects attempts to replace those defaults. For an explicit allowlist without either default, pass `new Map([["catalog", catalogAdapter]])` directly as `registry`. Names select registered factories; they do not load modules from config.
256
+ `createFileSystem` validates options before calling the factory. For Workers, import `createPortableFileSystemAdapterRegistry` and `createFileSystem` from `@poe-code/safe-fs/core`; the portable registry includes `memory` and caller-supplied adapters, without ambient filesystem access. The Node registry includes `memory` and `real`, accepts new names, and rejects attempts to replace those defaults. For an explicit allowlist without either default, pass `new Map([["catalog", catalogAdapter]])` directly as `registry`. Names select registered factories; they do not load modules from config.
257
257
 
258
258
  For a mutable backend, preserve exclusive-create behavior, return independent byte buffers, handle cancellation and size limits, and declare only capabilities you actually provide. Use `FsError` for filesystem failures. Do not invent entry identity from matching paths or metadata; omit `compareEntry` when identity is unknown.
259
259
 
@@ -357,8 +357,8 @@ Each batch lists from the beginning because previous keys have been deleted; it
357
357
  | `addressingStyle` | `path`; alternative `virtual-hosted` requires a DNS endpoint |
358
358
  | `listUrlEncoding` | `percent`; alternative `form` |
359
359
  | `allowInsecureHttp` | `false`; HTTPS required unless explicitly enabled |
360
- | `maxPutBytes`, `maxGetBytes` | Unlimited unless configured |
361
- | `maxXmlBytes` | Unlimited unless configured |
360
+ | `maxPutBytes`, `maxGetBytes` | Unlimited when omitted or `Infinity`; positive safe integers set byte caps |
361
+ | `maxXmlBytes` | Unlimited when omitted or `Infinity`; positive safe integers set a byte cap |
362
362
  | `requestTimeoutMs` | Unlimited unless configured |
363
363
  | `enableCopy` | `true`; disabling uses a buffered GET/PUT fallback |
364
364
  | `verifiedConditionalOperations` | Optional `put`, `copy`, `delete` booleans, each defaulting to false; enable only after verifying the server's semantics |
@@ -12,6 +12,7 @@ type DirectoryOptions = {
12
12
  };
13
13
  export declare class FileSystemBridge<Binary extends Uint8Array> {
14
14
  #private;
15
+ static getProvider(bridge: object): FileSystem | undefined;
15
16
  constructor(fs: FileSystem, options: {
16
17
  readonly cwd?: string;
17
18
  readonly root?: string;
@@ -40,6 +40,9 @@ function timeValue(value) {
40
40
  return number;
41
41
  }
42
42
  export class FileSystemBridge {
43
+ static getProvider(bridge) {
44
+ return #fs in bridge ? bridge.#fs : undefined;
45
+ }
43
46
  #fs;
44
47
  #cwd;
45
48
  #root;
@@ -4,3 +4,4 @@ import type { FsBridgeOptions } from "./types.js";
4
4
  export type { FsBridgeCodec, FsBridgeDirent, FsBridgeEncoding, FsBridgeFileSystem, FsBridgeOptions, FsBridgeStats } from "./types.js";
5
5
  export type FsBridge = FileSystemBridge<Uint8Array<ArrayBuffer>>;
6
6
  export declare function createFsBridge(fs: FileSystem, options: FsBridgeOptions): FsBridge;
7
+ export declare const getFsBridgeProvider: typeof FileSystemBridge.getProvider;
@@ -16,3 +16,4 @@ export function createFsBridge(fs, options) {
16
16
  }
17
17
  });
18
18
  }
19
+ export const getFsBridgeProvider = FileSystemBridge.getProvider;
@@ -0,0 +1,2 @@
1
+ import type { FileSystemAdapterRegistry } from "../config.js";
2
+ export declare function extendAdapterRegistry(defaults: FileSystemAdapterRegistry, extensions?: FileSystemAdapterRegistry): FileSystemAdapterRegistry;
@@ -0,0 +1,15 @@
1
+ export function extendAdapterRegistry(defaults, extensions) {
2
+ if (extensions !== undefined &&
3
+ (extensions === null ||
4
+ typeof extensions.get !== "function" ||
5
+ typeof extensions[Symbol.iterator] !== "function")) {
6
+ throw new TypeError("registry must be a filesystem adapter map.");
7
+ }
8
+ const registry = new Map(defaults);
9
+ for (const [name, descriptor] of extensions ?? []) {
10
+ if (registry.has(name))
11
+ throw new TypeError(`Filesystem adapter already registered: ${name}`);
12
+ registry.set(name, descriptor);
13
+ }
14
+ return registry;
15
+ }
@@ -1,22 +1,11 @@
1
+ import { extendAdapterRegistry } from "./config/registry.js";
1
2
  import { createMemoryFileSystemAdapter } from "./config/memory.js";
2
3
  import { createRealFileSystemAdapter } from "./config/real.js";
3
4
  import { createMemoryFileSystem } from "./fs/memory/index.js";
4
5
  import { createRealFileSystem } from "./fs/real/index.js";
5
6
  export function createNodeFileSystemAdapterRegistry(extensions) {
6
- if (extensions !== undefined &&
7
- (extensions === null ||
8
- typeof extensions.get !== "function" ||
9
- typeof extensions[Symbol.iterator] !== "function")) {
10
- throw new TypeError("registry must be a filesystem adapter map.");
11
- }
12
- const registry = new Map([
7
+ return extendAdapterRegistry(new Map([
13
8
  ["memory", createMemoryFileSystemAdapter(createMemoryFileSystem)],
14
9
  ["real", createRealFileSystemAdapter(createRealFileSystem)]
15
- ]);
16
- for (const [name, descriptor] of extensions ?? []) {
17
- if (registry.has(name))
18
- throw new TypeError(`Filesystem adapter already registered: ${name}`);
19
- registry.set(name, descriptor);
20
- }
21
- return registry;
10
+ ]), extensions);
22
11
  }
@@ -0,0 +1,3 @@
1
+ import type { FileSystemAdapterRegistry } from "./config.js";
2
+ /** Portable defaults never grant access to the host filesystem. */
3
+ export declare function createPortableFileSystemAdapterRegistry(extensions?: FileSystemAdapterRegistry): FileSystemAdapterRegistry;
@@ -0,0 +1,9 @@
1
+ import { createMemoryFileSystemAdapter } from "./config/memory.js";
2
+ import { extendAdapterRegistry } from "./config/registry.js";
3
+ import { createMemoryFileSystem } from "./fs/memory/index.js";
4
+ /** Portable defaults never grant access to the host filesystem. */
5
+ export function createPortableFileSystemAdapterRegistry(extensions) {
6
+ return extendAdapterRegistry(new Map([
7
+ ["memory", createMemoryFileSystemAdapter(createMemoryFileSystem)]
8
+ ]), extensions);
9
+ }
@@ -284,6 +284,17 @@ export interface PublishStagedFileOptions extends FsOptions {
284
284
  readonly parent: FileStat;
285
285
  readonly destination: FileStat | null;
286
286
  }
287
+ /** Publish one staged regular file and validate/retire sibling source entries in
288
+ * the same indivisible commit. Every expected version (including absence) must
289
+ * match before any mutation. Hosts must not emulate this with sequential calls.
290
+ * Unavailable hosts omit publishStagedFileSet. Sources remain caller-owned. */
291
+ export interface PublishStagedFileSetOptions extends PublishStagedFileOptions {
292
+ readonly companions: readonly {
293
+ readonly path: string;
294
+ readonly expected: FileStat | null;
295
+ readonly remove: boolean;
296
+ }[];
297
+ }
287
298
  export interface PrepareDirectoryOptions extends FsOptions {
288
299
  readonly expected: FileStat | null;
289
300
  readonly parent: FileStat;
@@ -321,6 +332,7 @@ export interface FileSystem {
321
332
  prepareDirectory?(path: string, options: PrepareDirectoryOptions): Promise<FileStat>;
322
333
  createStagedFile?(directoryPath: string, name: string, content: StagedFileContent, options: CreateStagedFileOptions): Promise<FileStaging>;
323
334
  publishStagedFile?(staging: FileStaging, destination: string, options: PublishStagedFileOptions): Promise<void>;
335
+ publishStagedFileSet?(staging: FileStaging, destination: string, options: PublishStagedFileSetOptions): Promise<FileStat>;
324
336
  /** Admit a complete canonical root-to-directory prefix and prepare a guard.
325
337
  * The returned guard checks current identities/search access synchronously;
326
338
  * preparation is not validation, and the guard's result is not an async lease. */
@@ -3,3 +3,4 @@ export * from "./filesystem.js";
3
3
  export * from "./io.js";
4
4
  export * from "./path.js";
5
5
  export * from "./object.js";
6
+ export { compareIdentity, compareFileVersion } from "../fs/mount/identity.js";
@@ -3,3 +3,4 @@ export * from "./filesystem.js";
3
3
  export * from "./io.js";
4
4
  export * from "./path.js";
5
5
  export * from "./object.js";
6
+ export { compareIdentity, compareFileVersion } from "../fs/mount/identity.js";
@@ -211,7 +211,7 @@ class ReadBytesGenerator {
211
211
  if (!this.finished && this.iterator?.return) {
212
212
  this.finished = true;
213
213
  const it = this.iterator;
214
- const cleanup = Promise.resolve().then(() => it.return());
214
+ const cleanup = new Promise(resolve => { resolve(it.return()); });
215
215
  if (signal?.aborted)
216
216
  void cleanup.catch(noop);
217
217
  else
@@ -14,3 +14,6 @@ export * from "./contracts/object.js";
14
14
  export { ObjectAuthority } from "./fs/object-authority.js";
15
15
  export * from "./fs/s3/index.js";
16
16
  export * from "./fs/s3/http/index.js";
17
+ export { createFileSystem, readConfigRecord, validateFileSystemConfig } from "./config.js";
18
+ export type { FileSystemConfig, FileSystemAdapterDescriptor, FileSystemAdapterRegistry } from "./config.js";
19
+ export { createPortableFileSystemAdapterRegistry } from "./config.portable.js";
@@ -13,3 +13,5 @@ export * from "./contracts/object.js";
13
13
  export { ObjectAuthority } from "./fs/object-authority.js";
14
14
  export * from "./fs/s3/index.js";
15
15
  export * from "./fs/s3/http/index.js";
16
+ export { createFileSystem, readConfigRecord, validateFileSystemConfig } from "./config.js";
17
+ export { createPortableFileSystemAdapterRegistry } from "./config.portable.js";
@@ -1,5 +1,5 @@
1
1
  import type { FileDescriptor, OpenFileOptions } from "../../contracts/descriptor.js";
2
- import type { AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileResizeOperation, FileResizeOptions, FileStat, FileSystem, OpenReadFileOptions, OpenResizeFileOptions, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions, ConditionalFilePublicationOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, ConditionalRemoveEntryOptions, ConditionalRemoveEntryReceiptOptions, CreateStagedFileOptions, FileStaging, FileStagingEntry, FileStagingResolution, PublishStagedFileOptions, PrepareDirectoryOptions, StagedFileContent } from "../../contracts/filesystem.js";
2
+ import type { AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileResizeOperation, FileResizeOptions, FileStat, FileSystem, OpenReadFileOptions, OpenResizeFileOptions, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions, ConditionalFilePublicationOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, ConditionalRemoveEntryOptions, ConditionalRemoveEntryReceiptOptions, CreateStagedFileOptions, FileStaging, FileStagingEntry, FileStagingResolution, PublishStagedFileOptions, PublishStagedFileSetOptions, PrepareDirectoryOptions, StagedFileContent } from "../../contracts/filesystem.js";
3
3
  import type { ByteSource } from "../../contracts/io.js";
4
4
  import { type EntryLocation } from "../mount/comparison.js";
5
5
  import { pathNamespace } from "../path-namespace.js";
@@ -40,6 +40,7 @@ export declare class DeviceFileSystem implements FileSystem {
40
40
  removeTreeConditional(path: string, options: ConditionalRemoveEntryOptions): Promise<void>;
41
41
  createStagedFile(path: string, name: string, content: StagedFileContent, options: CreateStagedFileOptions): Promise<FileStaging>;
42
42
  publishStagedFile(staging: FileStaging, destination: string, options: PublishStagedFileOptions): Promise<void>;
43
+ publishStagedFileSet(receipt: FileStaging, destination: string, supplied: PublishStagedFileSetOptions): Promise<FileStat>;
43
44
  prepareDirectoryAncestry(ancestors: readonly FileStagingEntry[], options?: FsOptions): Promise<() => true>;
44
45
  prepareStagingResolution(path: string, options?: FsOptions): Promise<FileStagingResolution>;
45
46
  removeStagedFile(staging: FileStaging, options?: FsOptions): Promise<void>;
@@ -639,6 +639,47 @@ export class DeviceFileSystem {
639
639
  await requireOwnedMutation(this.#filesystem, destination, "guardedStagingPublication", options, options.destination === null);
640
640
  await this.#filesystem.publishStagedFile(staging, destination, options);
641
641
  }
642
+ async publishStagedFileSet(receipt, destination, supplied) {
643
+ const publish = this.#filesystem.publishStagedFileSet;
644
+ if (!publish)
645
+ throw new FsError("ENOTSUP", { path: destination });
646
+ const entry = (value) => ({ path: value.path, stat: { ...value.stat } });
647
+ const staging = { ...receipt, parent: entry(receipt.parent), directory: entry(receipt.directory), file: entry(receipt.file) };
648
+ const options = {
649
+ ...supplied,
650
+ parent: { ...supplied.parent },
651
+ destination: supplied.destination === null ? null : { ...supplied.destination },
652
+ companions: supplied.companions.map(value => ({ ...value, expected: value.expected === null ? null : { ...value.expected } })),
653
+ ...(supplied.ancestors === undefined ? {} : { ancestors: snapshotDirectoryAncestry(supplied.ancestors) }),
654
+ };
655
+ options.signal?.throwIfAborted();
656
+ const capabilities = await this.#filesystem.capabilitiesFor?.(destination, { ...options, create: options.destination === null }) ?? this.#filesystem.capabilities;
657
+ if (capabilities.readOnly === true)
658
+ throw new FsError("EROFS", { path: destination });
659
+ for (const path of [staging.parent.path, staging.directory.path])
660
+ await this.#mutable(path, options, false);
661
+ const guards = [];
662
+ if (options.ancestors)
663
+ guards.push(await this.prepareDirectoryAncestry(options.ancestors, options));
664
+ for (const path of [staging.file.path, destination, ...options.companions.map(value => value.path)]) {
665
+ await this.#mutable(path, options, false);
666
+ const resolution = await this.prepareStagingResolution(path, options);
667
+ guards.push(resolution.validate);
668
+ }
669
+ const callerGuard = options.commitGuard;
670
+ options.signal?.throwIfAborted();
671
+ return publish.call(this.#filesystem, staging, destination, { ...options, commitGuard: () => {
672
+ options.signal?.throwIfAborted();
673
+ if (callerGuard !== undefined)
674
+ runStagingGuard(callerGuard);
675
+ // Recheck every route in the backend's atomic commit, so a source cannot
676
+ // cross into the virtual device namespace after asynchronous admission.
677
+ for (const guard of guards)
678
+ runStagingGuard(guard);
679
+ options.signal?.throwIfAborted();
680
+ return true;
681
+ } });
682
+ }
642
683
  async prepareDirectoryAncestry(ancestors, options = {}) {
643
684
  options.signal?.throwIfAborted();
644
685
  try {
@@ -1,4 +1,4 @@
1
- import type { AppendFileOptions, CopyFileOptions, DirectoryEntry, EntryComparison, FileReadHandle, FileResizeHandle, FileStat, FileSystem, FileSystemCapabilities, FsOptions, ChmodOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, FileDescriptor, OpenFileOptions, OpenReadFileOptions, OpenResizeFileOptions, WriteFileOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, ConditionalRemoveEntryOptions, ConditionalRemoveEntryReceiptOptions, CreateStagedFileOptions, FileStaging, FileStagingEntry, FileStagingResolution, PublishStagedFileOptions, PrepareDirectoryOptions, StagedFileContent } from "../../contracts/filesystem.js";
1
+ import type { AppendFileOptions, CopyFileOptions, DirectoryEntry, EntryComparison, FileReadHandle, FileResizeHandle, FileStat, FileSystem, FileSystemCapabilities, FsOptions, ChmodOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, FileDescriptor, OpenFileOptions, OpenReadFileOptions, OpenResizeFileOptions, WriteFileOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, ConditionalRemoveEntryOptions, ConditionalRemoveEntryReceiptOptions, CreateStagedFileOptions, FileStaging, FileStagingEntry, FileStagingResolution, PublishStagedFileOptions, PublishStagedFileSetOptions, PrepareDirectoryOptions, StagedFileContent } from "../../contracts/filesystem.js";
2
2
  import type { ByteSource } from "../../contracts/io.js";
3
3
  import { type MemoryAtomicView } from "./atomic-view.js";
4
4
  import { MemoryAllocation, MemoryLedger } from "./ledger.js";
@@ -35,7 +35,6 @@ interface SymlinkNode extends Metadata {
35
35
  }
36
36
  type MemoryNode = FileNode | DirectoryNode | SymlinkNode;
37
37
  declare class MemoryCache {
38
- readonly allocations: MemoryAllocation[];
39
38
  readonly files: FileNode[];
40
39
  readonly directories: DirectoryNode[];
41
40
  readonly removedScratch: MemoryNode[];
@@ -118,6 +117,7 @@ export declare class MemoryFileSystem implements FileSystem {
118
117
  prepareStagingResolution(path: string, options?: FsOptions): Promise<FileStagingResolution>;
119
118
  private verifyDirectoryAncestry;
120
119
  publishStagedFile(receipt: FileStaging, destination: string, options: PublishStagedFileOptions): Promise<void>;
120
+ publishStagedFileSet(receipt: FileStaging, destination: string, options: PublishStagedFileSetOptions): Promise<FileStat>;
121
121
  removeStagedFile(staging: FileStaging, options?: FsOptions): Promise<void>;
122
122
  private removeStagingLocations;
123
123
  prepareDirectory(path: string, options: PrepareDirectoryOptions): Promise<FileStat>;
@@ -324,13 +324,6 @@ class FastDirectoryEntriesMap {
324
324
  }
325
325
  const SHARED_POOL_CAPACITY = 256;
326
326
  const SHARED_DIR_POOL_CAPACITY = 64;
327
- const sharedAllocationPool = new Array(SHARED_POOL_CAPACITY).fill(DUMMY_POOL_ALLOCATION);
328
- let sharedAllocationPoolLen = 0;
329
- const SHARED_LARGE_POOL_CAPACITY = 8;
330
- const sharedLargeAllocationPool = new Array(SHARED_LARGE_POOL_CAPACITY).fill(DUMMY_POOL_ALLOCATION);
331
- let sharedLargeAllocationPoolLen = 0;
332
- const sharedMediumAllocationPool = new Array(SHARED_LARGE_POOL_CAPACITY).fill(DUMMY_POOL_ALLOCATION);
333
- let sharedMediumAllocationPoolLen = 0;
334
327
  const sharedFileNodePool = new Array(SHARED_POOL_CAPACITY).fill(DUMMY_POOL_FILE_NODE);
335
328
  let sharedFileNodePoolLen = 0;
336
329
  const sharedDirectoryNodePool = new Array(SHARED_DIR_POOL_CAPACITY);
@@ -351,27 +344,9 @@ function collectRemovedChild(child, name) {
351
344
  sharedRemovedKeyEntries++;
352
345
  }
353
346
  function replenishSharedMemoryPools() {
354
- while (sharedAllocationPoolLen < 112) {
355
- DUMMY_POOL_LEDGER.reserve(64, 0, "init", "/");
356
- const alloc = new MemoryAllocation(new Uint8Array(64), DUMMY_POOL_LEDGER);
357
- alloc.release();
358
- sharedAllocationPool[sharedAllocationPoolLen++] = alloc;
359
- }
360
347
  while (sharedFileNodePoolLen < 112) {
361
348
  sharedFileNodePool[sharedFileNodePoolLen++] = new MemoryFileNode(0, 0, 0, 0, DUMMY_POOL_ALLOCATION, undefined);
362
349
  }
363
- while (sharedLargeAllocationPoolLen < 4) {
364
- DUMMY_POOL_LEDGER.reserve(65536, 0, "init", "/");
365
- const alloc = new MemoryAllocation(new Uint8Array(65536), DUMMY_POOL_LEDGER);
366
- alloc.release();
367
- sharedLargeAllocationPool[sharedLargeAllocationPoolLen++] = alloc;
368
- }
369
- while (sharedMediumAllocationPoolLen < 4) {
370
- DUMMY_POOL_LEDGER.reserve(16384, 0, "init", "/");
371
- const alloc = new MemoryAllocation(new Uint8Array(16384), DUMMY_POOL_LEDGER);
372
- alloc.release();
373
- sharedMediumAllocationPool[sharedMediumAllocationPoolLen++] = alloc;
374
- }
375
350
  while (sharedDirectoryNodePoolLen < 16) {
376
351
  sharedDirectoryNodePool[sharedDirectoryNodePoolLen++] = new MemoryDirectoryNode(0, 0, 0);
377
352
  }
@@ -403,7 +378,6 @@ Object.assign(MemoryDirectoryNode.prototype, {
403
378
  const DUMMY_POOL_DIR_NODE = new MemoryDirectoryNode(0, 0, null);
404
379
  DUMMY_POOL_DIR_NODE.atimeMs = DUMMY_POOL_DIR_NODE.mtimeMs = DUMMY_POOL_DIR_NODE.ctimeMs = DUMMY_POOL_DIR_NODE.birthtimeMs = 1700000000000;
405
380
  class MemoryCache {
406
- allocations = [];
407
381
  files = [];
408
382
  directories = [];
409
383
  removedScratch = [];
@@ -448,7 +422,7 @@ const compareOwnedMemory = async (own, peer, options) => {
448
422
  let answer = "unknown";
449
423
  const visited = new Set();
450
424
  for (const [left, right] of [[own, peer], [peer, own]]) {
451
- if ((!registeredAuthorities.has(left.filesystem) && !ownedStores.has(left.filesystem)) || visited.has(left.filesystem))
425
+ if (!registeredAuthorities.has(left.filesystem) || visited.has(left.filesystem))
452
426
  continue;
453
427
  visited.add(left.filesystem);
454
428
  const comparison = left.filesystem.compareEntry;
@@ -911,21 +885,6 @@ export class MemoryFileSystem {
911
885
  node.sourceRef = undefined;
912
886
  node.allocation = DUMMY_POOL_ALLOCATION;
913
887
  alloc.release();
914
- if (alloc.isReleased64()) {
915
- alloc.detachLedger(DUMMY_POOL_LEDGER);
916
- if (this.ledger.hasInfiniteRetained && this.ledger.hasInfiniteFileBytes && sharedAllocationPoolLen < SHARED_POOL_CAPACITY) {
917
- sharedAllocationPool[sharedAllocationPoolLen++] = alloc;
918
- }
919
- else if (cache.allocations.length < 128) {
920
- cache.allocations.push(alloc);
921
- }
922
- }
923
- else if (alloc.isReleased65536()) {
924
- alloc.detachLedger(DUMMY_POOL_LEDGER);
925
- if (this.ledger.hasInfiniteRetained && this.ledger.hasInfiniteFileBytes && sharedLargeAllocationPoolLen < SHARED_LARGE_POOL_CAPACITY) {
926
- sharedLargeAllocationPool[sharedLargeAllocationPoolLen++] = alloc;
927
- }
928
- }
929
888
  if (sharedFileNodePoolLen < SHARED_POOL_CAPACITY) {
930
889
  sharedFileNodePool[sharedFileNodePoolLen++] = node;
931
890
  }
@@ -965,18 +924,6 @@ export class MemoryFileSystem {
965
924
  node.view = length === allocation.data.byteLength ? allocation.data : undefined;
966
925
  node.allocation = allocation;
967
926
  previous.release();
968
- if (previous.isReleased64()) {
969
- previous.detachLedger(DUMMY_POOL_LEDGER);
970
- if (this.ledger.hasInfiniteRetained && this.ledger.hasInfiniteFileBytes && sharedAllocationPoolLen < SHARED_POOL_CAPACITY) {
971
- sharedAllocationPool[sharedAllocationPoolLen++] = previous;
972
- }
973
- }
974
- else if (previous.isReleased65536()) {
975
- previous.detachLedger(DUMMY_POOL_LEDGER);
976
- if (this.ledger.hasInfiniteRetained && this.ledger.hasInfiniteFileBytes && sharedLargeAllocationPoolLen < SHARED_LARGE_POOL_CAPACITY) {
977
- sharedLargeAllocationPool[sharedLargeAllocationPoolLen++] = previous;
978
- }
979
- }
980
927
  }
981
928
  admitSize(node, length, syscall, path) {
982
929
  this.ledger.fileSize(length, syscall, path);
@@ -1354,36 +1301,7 @@ export class MemoryFileSystem {
1354
1301
  if (length === 0) {
1355
1302
  return DUMMY_POOL_ALLOCATION;
1356
1303
  }
1357
- if (length === 16384) {
1358
- if (this.ledger.hasInfiniteRetained && this.ledger.hasInfiniteFileBytes && sharedMediumAllocationPoolLen > 0) {
1359
- const pooled = sharedMediumAllocationPool[--sharedMediumAllocationPoolLen];
1360
- sharedMediumAllocationPool[sharedMediumAllocationPoolLen] = DUMMY_POOL_ALLOCATION;
1361
- pooled.reuse(this.ledger);
1362
- return pooled;
1363
- }
1364
- }
1365
- if (length === 65536) {
1366
- if (this.ledger.hasInfiniteRetained && this.ledger.hasInfiniteFileBytes && sharedLargeAllocationPoolLen > 0) {
1367
- const pooled = sharedLargeAllocationPool[--sharedLargeAllocationPoolLen];
1368
- sharedLargeAllocationPool[sharedLargeAllocationPoolLen] = DUMMY_POOL_ALLOCATION;
1369
- pooled.reuse(this.ledger);
1370
- return pooled;
1371
- }
1372
- }
1373
- if (length === 64) {
1374
- let pooled;
1375
- if (this.ledger.hasInfiniteRetained && this.ledger.hasInfiniteFileBytes && sharedAllocationPoolLen > 0) {
1376
- pooled = sharedAllocationPool[--sharedAllocationPoolLen];
1377
- sharedAllocationPool[sharedAllocationPoolLen] = DUMMY_POOL_ALLOCATION;
1378
- }
1379
- else {
1380
- pooled = memoryCaches.get(this.ledger).allocations.pop();
1381
- }
1382
- if (pooled) {
1383
- pooled.reuse(this.ledger);
1384
- return pooled;
1385
- }
1386
- }
1304
+ // Views can outlive files: never recycle their backing buffers across owners.
1387
1305
  try {
1388
1306
  return new MemoryAllocation(new Uint8Array(length), this.ledger);
1389
1307
  }
@@ -2329,6 +2247,93 @@ export class MemoryFileSystem {
2329
2247
  this.fail("EAGAIN", "publishStagedFile", destination);
2330
2248
  return MemoryFileSystem.prototype.rename.call(this, staging.file.path, destination, { ...controls, noReplace: expected === null });
2331
2249
  }
2250
+ async publishStagedFileSet(receipt, destination, options) {
2251
+ const signal = options.signal;
2252
+ const guard = options.commitGuard;
2253
+ const ancestors = options.ancestors === undefined ? undefined : snapshotDirectoryAncestry(options.ancestors);
2254
+ const snapshot = (entry) => ({ path: entry.path, stat: { ...entry.stat } });
2255
+ const staging = { parent: snapshot(receipt.parent), directory: snapshot(receipt.directory), file: snapshot(receipt.file) };
2256
+ const parent = { ...options.parent };
2257
+ const expected = options.destination === null ? null : { ...options.destination };
2258
+ const companions = options.companions.map(entry => ({ path: entry.path, expected: entry.expected === null ? null : { ...entry.expected }, remove: entry.remove }));
2259
+ const syscall = "publishStagedFileSet";
2260
+ const parentPath = destination.slice(0, destination.lastIndexOf("/")) || "/";
2261
+ const paths = new Set([destination]);
2262
+ const canonical = (path) => path.startsWith("/") && path.slice(1).split("/").every(part => part !== "" && part !== "." && part !== "..");
2263
+ if (!canonical(destination) || ancestors && ancestors.at(-1)?.path !== parentPath)
2264
+ this.fail("EINVAL", syscall, destination);
2265
+ for (const entry of companions) {
2266
+ if (!canonical(entry.path) || paths.has(entry.path) || (entry.path.slice(0, entry.path.lastIndexOf("/")) || "/") !== parentPath)
2267
+ this.fail("EINVAL", syscall, entry.path);
2268
+ paths.add(entry.path);
2269
+ }
2270
+ signal?.throwIfAborted();
2271
+ if (guard !== undefined)
2272
+ runStagingGuard(guard);
2273
+ signal?.throwIfAborted();
2274
+ if (ancestors)
2275
+ this.verifyDirectoryAncestry(ancestors, signal === undefined ? {} : { signal });
2276
+ const { directory, file } = this.stagingLocations(staging);
2277
+ this.expectEntry(file.node, staging.file.stat, staging.file.path);
2278
+ const target = this.entry(destination, syscall, true);
2279
+ this.expectEntry(target.parent, parent, destination, false);
2280
+ this.expectEntry(target.node, expected, destination);
2281
+ const node = file.node;
2282
+ if (node.type !== "file" || target.parent === directory.node || target.node === node)
2283
+ this.fail("EINVAL", syscall, destination);
2284
+ if (target.node && target.node.type !== "file")
2285
+ this.fail("EAGAIN", syscall, destination);
2286
+ this.permission(file.parent, 3, syscall, staging.file.path);
2287
+ this.permission(target.parent, 3, syscall, destination);
2288
+ const retired = [];
2289
+ for (const companion of companions) {
2290
+ const location = this.entry(companion.path, syscall, true);
2291
+ if (location.parent !== target.parent || location.node === node)
2292
+ this.fail("EINVAL", syscall, companion.path);
2293
+ this.expectEntry(location.node, companion.expected, companion.path);
2294
+ if (location.node && location.node.type !== "file")
2295
+ this.fail("EAGAIN", syscall, companion.path);
2296
+ if (companion.remove && location.node)
2297
+ retired.push(location);
2298
+ }
2299
+ const now = Date.now();
2300
+ const published = { ...this.snapshot(node), ctimeMs: now, revision: Math.min(Number.MAX_SAFE_INTEGER + 1, node.revision + 1) };
2301
+ // Admission and all source-set checks precede the single synchronous commit.
2302
+ // No calls to overridable async mutations can interleave a partial result.
2303
+ const nameGrowth = target.node ? 0 : (target.name.length - file.name.length) * 2;
2304
+ this.ledger.reserve(Math.max(0, nameGrowth), 0, syscall, destination);
2305
+ try {
2306
+ target.parent.entries.set(target.name, node);
2307
+ }
2308
+ catch (error) {
2309
+ this.ledger.release(Math.max(0, nameGrowth), 0);
2310
+ throw error;
2311
+ }
2312
+ memoryCaches.get(this.ledger).clearWrites();
2313
+ file.parent.entries.delete(file.name);
2314
+ const unlink = (entry) => {
2315
+ entry.node.nlink--;
2316
+ entry.node.ctimeMs = now;
2317
+ entry.node.revision = Math.min(Number.MAX_SAFE_INTEGER + 1, entry.node.revision + 1);
2318
+ this.releaseNode(entry.node);
2319
+ };
2320
+ if (target.node) {
2321
+ this.ledger.release(file.name.length * 2, 1);
2322
+ unlink(target);
2323
+ }
2324
+ else if (nameGrowth < 0)
2325
+ this.ledger.release(-nameGrowth, 0);
2326
+ for (const entry of retired) {
2327
+ entry.parent.entries.delete(entry.name);
2328
+ this.ledger.release(entry.name.length * 2, 1);
2329
+ unlink(entry);
2330
+ }
2331
+ this.changed(file.parent, now);
2332
+ this.changed(target.parent, now);
2333
+ node.ctimeMs = now;
2334
+ node.revision = published.revision;
2335
+ return published;
2336
+ }
2332
2337
  async removeStagedFile(staging, options = {}) {
2333
2338
  options.signal?.throwIfAborted();
2334
2339
  const { directory, file } = this.stagingLocations(staging);
@@ -23,8 +23,4 @@ export declare class MemoryAllocation {
23
23
  constructor(data: Uint8Array, ledger: MemoryLedger);
24
24
  retain(): void;
25
25
  release(): void;
26
- isReleased64(): boolean;
27
- isReleased65536(): boolean;
28
- detachLedger(dummyLedger: MemoryLedger): void;
29
- reuse(ledger?: MemoryLedger): void;
30
26
  }
@@ -89,21 +89,6 @@ export class MemoryAllocation {
89
89
  this.ledger.release(this.data.byteLength, 0);
90
90
  }
91
91
  }
92
- isReleased64() {
93
- return this.references === 0 && this.data.byteLength === 64;
94
- }
95
- isReleased65536() {
96
- return this.references === 0 && this.data.byteLength === 65536;
97
- }
98
- detachLedger(dummyLedger) {
99
- this.ledger = dummyLedger;
100
- }
101
- reuse(ledger) {
102
- this.references = 1;
103
- if (ledger !== undefined)
104
- this.ledger = ledger;
105
- this.data.fill(0);
106
- }
107
92
  }
108
93
  Object.assign(MemoryAllocation.prototype, {
109
94
  references: 1,
@@ -1,2 +1,6 @@
1
1
  import type { FileStat } from "../../contracts/filesystem.js";
2
2
  export declare function compareIdentity(left: FileStat | undefined, right: FileStat | undefined): "same" | "distinct" | "unknown";
3
+ /** Compare content snapshots, including adapter generations when supplied.
4
+ * Identity is checked separately so identityless streaming adapters remain usable.
5
+ */
6
+ export declare function compareFileVersion(left: FileStat, right: FileStat): boolean;
@@ -19,3 +19,11 @@ export function compareIdentity(left, right) {
19
19
  return "distinct";
20
20
  return "unknown";
21
21
  }
22
+ /** Compare content snapshots, including adapter generations when supplied.
23
+ * Identity is checked separately so identityless streaming adapters remain usable.
24
+ */
25
+ export function compareFileVersion(left, right) {
26
+ return left.type === right.type && left.size === right.size
27
+ && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs
28
+ && left.revision === right.revision && left.opaqueVersion === right.opaqueVersion;
29
+ }
@@ -12,6 +12,7 @@ export declare class MountFileSystem implements FileSystem {
12
12
  private namespaceMutation;
13
13
  private readonly namespaceWaiters;
14
14
  constructor(options: MountFileSystemOptions);
15
+ confineExtraction(roots: readonly string[], options?: FsOptions): Promise<FileSystem>;
15
16
  private select;
16
17
  capabilitiesFor(path: string, options?: CapabilityQueryOptions): Promise<FileSystemCapabilities>;
17
18
  private supportsStagingAncestry;
@@ -9,6 +9,7 @@ import { pathNamespace } from "../path-namespace.js";
9
9
  import { createStagingCleanup, snapshotStagingCreation } from "../staging-cleanup.js";
10
10
  import { directoryAncestryPaths, inspectStagingBindings, runStagingGuard, snapshotDirectoryAncestry, snapshotStagingResolution } from "../staging-ancestry.js";
11
11
  import { compareIdentity } from "./identity.js";
12
+ import { createReadOnlyFileSystem } from "../readonly/index.js";
12
13
  import { snapshotConditionalChmod } from "../conditional-chmod.js";
13
14
  import { compareEntries, registerEntryAuthority, registerEntryView } from "./comparison.js";
14
15
  const syntheticStat = Object.freeze({
@@ -150,6 +151,36 @@ export class MountFileSystem {
150
151
  ...(retainedRead === undefined ? {} : { retainedRead }),
151
152
  });
152
153
  }
154
+ async confineExtraction(roots, options = {}) {
155
+ options.signal?.throwIfAborted();
156
+ const root = this.select("/").backend;
157
+ const captured = [...roots];
158
+ for (const path of captured) {
159
+ validatePath(path);
160
+ if (normalizePath(globalPath(path)) !== path)
161
+ fail("EINVAL");
162
+ // A backing confinement retains its own root-to-destination ancestry.
163
+ // It cannot retain directories belonging to another mounted backend.
164
+ if (this.select(path).path !== "/")
165
+ fail("ENOTSUP");
166
+ }
167
+ if (!root.confineExtraction)
168
+ fail("ENOTSUP");
169
+ const confined = await root.confineExtraction(captured, options);
170
+ options.signal?.throwIfAborted();
171
+ const observations = new Set(["prepareDirectoryAncestry", "prepareStagingResolution"]);
172
+ const backing = new Proxy(confined, {
173
+ get(target, key) {
174
+ // These only capture/validate bindings. Mutations must always use the
175
+ // backend's confined view, including creation, metadata and hardlinks.
176
+ const owner = observations.has(key) ? root : target;
177
+ const value = Reflect.get(owner, key);
178
+ return typeof value === "function" ? value.bind(owner) : value;
179
+ },
180
+ });
181
+ return new MountFileSystem({ root: backing, mounts: Object.fromEntries(this.mounts.filter(mount => mount.path !== "/").map(mount => [mount.path,
182
+ mount.backend.capabilities.readOnly === true ? mount.backend : createReadOnlyFileSystem(mount.backend)])) });
183
+ }
153
184
  select(path) {
154
185
  return this.mounts.find((mount) => within(mount.path, path));
155
186
  }
@@ -15,6 +15,7 @@ export interface ObjectFilePublicationOptions extends FsOptions {
15
15
  readonly mode: number;
16
16
  }
17
17
  export interface ObjectFileAcquireOptions extends FsOptions {
18
+ readonly noFollow?: boolean;
18
19
  readonly access: OpenFileOptions["access"];
19
20
  }
20
21
  export interface ObjectFileStaging {
@@ -28,6 +29,8 @@ export interface ObjectFileStagingOptions extends FsOptions {
28
29
  readonly maxFileBytes: number;
29
30
  }
30
31
  export interface ObjectFilePublicationStore {
32
+ /** Certifies acquire can reject symbolic links when noFollow is requested. */
33
+ readonly noFollow?: boolean;
31
34
  acquire(path: string, options: ObjectFileAcquireOptions): Promise<ObjectFileVersion | undefined>;
32
35
  publish?(path: string, expectedRevision: string | null, source: ByteSource, options: ObjectFilePublicationOptions): Promise<ObjectFileVersion>;
33
36
  createStaging?(path: string, options: ObjectFileStagingOptions): Promise<ObjectFileStaging>;
@@ -11,6 +11,7 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
11
11
  const maxOpenFiles = options.maxOpenFiles ?? Infinity;
12
12
  if (!Number.isSafeInteger(chunkBytes) || chunkBytes < 1
13
13
  || ![maxStagedBytes, maxStagedPages, maxFileBytes, maxOpenFiles].every(value => value === Infinity || Number.isSafeInteger(value) && value > 0)
14
+ || store.noFollow !== undefined && typeof store.noFollow !== "boolean"
14
15
  || typeof store.acquire !== "function"
15
16
  || store.publish !== undefined && typeof store.publish !== "function"
16
17
  || store.createStaging !== undefined && typeof store.createStaging !== "function")
@@ -68,7 +69,7 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
68
69
  state.pages.clear();
69
70
  };
70
71
  const open = (path, openOptions) => openFileDescriptor(path, openOptions, {
71
- publication: "conditional", position: true, positionedRead: true, positionedWrite: true,
72
+ publication: "conditional", position: true, positionedRead: true, positionedWrite: true, noFollow: store.noFollow === true,
72
73
  truncate: true, synchronization: "storage",
73
74
  }, async (admitted) => {
74
75
  validatePath(path);
@@ -233,7 +234,7 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
233
234
  throw new FsError("ENOTSUP", { path, message: "Object writes require authoritative conditional publication" });
234
235
  if (admitted.access !== "write" && capabilities.read === false)
235
236
  throw new FsError("EACCES", { path });
236
- const acquired = await store.acquire(path, { access: admitted.access, ...(admitted.signal ? { signal: admitted.signal } : {}) });
237
+ const acquired = await store.acquire(path, { access: admitted.access, ...(admitted.noFollow ? { noFollow: true } : {}), ...(admitted.signal ? { signal: admitted.signal } : {}) });
237
238
  if (acquired) {
238
239
  try {
239
240
  state.head = version(acquired);
@@ -499,7 +499,7 @@ export function withFileSystemQuota(fs, options) {
499
499
  // capabilities and methods without violating invariants on own properties.
500
500
  return new Proxy(Object.create(fs), {
501
501
  get(_target, property) {
502
- if (property === "prepareStagingResolution" || property === "prepareDirectoryAncestry" || property === "publishFileConditional" || property === "removeEntryConditional" || property === "writeFileConditional" || property === "removeFileConditional" || property === "resizeFile" || property === "canonicalizeMissingTarget" || property === "createStagedFile" || property === "publishStagedFile" || property === "removeStagedFile" || property === "prepareDirectory")
502
+ if (property === "publishStagedFileSet" || property === "prepareStagingResolution" || property === "prepareDirectoryAncestry" || property === "publishFileConditional" || property === "removeEntryConditional" || property === "writeFileConditional" || property === "removeFileConditional" || property === "resizeFile" || property === "canonicalizeMissingTarget" || property === "createStagedFile" || property === "publishStagedFile" || property === "removeStagedFile" || property === "prepareDirectory")
503
503
  return undefined;
504
504
  if (property === "capabilities")
505
505
  return quotaCapabilities({ ...retainedResizeCapabilities(fs, retainedReadCapabilities(fs)), ...(typeof fs.open === "function" ? {} : { open: false }) });
@@ -22,7 +22,7 @@ function utf8ByteLength(value) {
22
22
  return bytes;
23
23
  }
24
24
  function limit(value, fallback, name, maximum = Number.MAX_SAFE_INTEGER) {
25
- if (value === undefined)
25
+ if (value === undefined || value === Infinity && fallback === Infinity)
26
26
  return fallback;
27
27
  const result = value;
28
28
  if (!Number.isSafeInteger(result) || result < 1 || result > maximum)
@@ -37,8 +37,8 @@ function decodeEntities(text) {
37
37
  }
38
38
  export function parseXml(bytes, limits = {}) {
39
39
  for (const limit of [limits.maxNodes, limits.maxDepth]) {
40
- if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1))
41
- throw new RangeError("XML limits must be positive safe integers");
40
+ if (limit !== undefined && limit !== Infinity && (!Number.isSafeInteger(limit) || limit < 1))
41
+ throw new RangeError("XML limits must be positive safe integers or Infinity");
42
42
  }
43
43
  let xml;
44
44
  try {
@@ -18,7 +18,7 @@ export function retargetScopedFileSystem(scoped, charge, signal, cleanupCharge =
18
18
  return true;
19
19
  }
20
20
  const operations = new Set([
21
- "prepareDirectoryAncestry", "prepareStagingResolution",
21
+ "prepareDirectoryAncestry", "prepareStagingResolution", "publishStagedFileSet",
22
22
  "publishFileConditional", "removeEntryConditional", "removeTreeConditional", "writeFileConditional", "removeFileConditional", "createStagedFile", "publishStagedFile", "removeStagedFile", "prepareDirectory",
23
23
  "confineExtraction", "access", "appendFile", "canonicalizeMissingTarget", "capabilitiesFor", "chmod", "compareEntry",
24
24
  "copyFile", "link", "lstat", "mkdir", "openReadFile", "openResizeFile", "readFile", "readStream", "readdir",
@@ -262,7 +262,7 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
262
262
  if (property === "removeEntryConditional")
263
263
  args[1] = resizeOptions({ ...args[1] });
264
264
  let stagingSignal;
265
- if (property === "createStagedFile" || property === "publishStagedFile") {
265
+ if (property === "createStagedFile" || property === "publishStagedFile" || property === "publishStagedFileSet") {
266
266
  signal.throwIfAborted();
267
267
  if (property === "createStagedFile")
268
268
  args[3] = snapshotStagingCreation(args[3], args[0]);
@@ -281,6 +281,36 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
281
281
  if (property === "symlink" && typeof args[1] === "string")
282
282
  validatePath(args[1], maxPathComponents);
283
283
  }
284
+ if (property === "publishStagedFileSet") {
285
+ if (original.capabilities.readOnly === true)
286
+ throw new FsError("EROFS", { path: args[1] });
287
+ const supplied = args[2];
288
+ const callerGuard = supplied.commitGuard;
289
+ const entry = (value) => ({ path: value.path, stat: { ...value.stat } });
290
+ const input = args[0];
291
+ const staging = { ...input, parent: entry(input.parent), directory: entry(input.directory), file: entry(input.file) };
292
+ const controls = {
293
+ ...supplied,
294
+ signal: stagingSignal,
295
+ parent: { ...supplied.parent },
296
+ destination: supplied.destination === null ? null : { ...supplied.destination },
297
+ companions: supplied.companions.map(value => ({ ...value, expected: value.expected === null ? null : { ...value.expected } })),
298
+ ...(supplied.ancestors === undefined ? {} : { ancestors: snapshotDirectoryAncestry(supplied.ancestors) }),
299
+ commitGuard: () => {
300
+ assertOpen({ signal: stagingSignal });
301
+ if (callerGuard !== undefined)
302
+ runStagingGuard(callerGuard);
303
+ assertOpen({ signal: stagingSignal });
304
+ return true;
305
+ },
306
+ };
307
+ for (const path of [staging.parent.path, staging.directory.path, staging.file.path, args[1],
308
+ ...controls.companions.map(value => value.path), ...(controls.ancestors ?? []).map(value => value.path)]) {
309
+ validatePath(path, maxPathComponents);
310
+ }
311
+ assertOpen(controls);
312
+ return Reflect.apply(method, original, [staging, args[1], controls]);
313
+ }
284
314
  if (["writeFile", "appendFile", "writeStream", "mkdir"].includes(String(property))) {
285
315
  const index = property === "mkdir" ? 1 : 2;
286
316
  args[index] = creationOptions((args[index] ?? {}), property === "mkdir");
@@ -1,4 +1,5 @@
1
1
  export * from "./contracts/index.js";
2
+ export * from "./bridge/index.js";
2
3
  export { ObjectAuthority } from "./fs/object-authority.js";
3
4
  export * from "./python/index.js";
4
5
  export { openFileDescriptor } from "./fs/descriptor.js";
@@ -21,3 +22,4 @@ export * from "./node/index.js";
21
22
  export { createFileSystem, readConfigRecord, validateFileSystemConfig } from "./config.js";
22
23
  export type { FileSystemConfig, FileSystemAdapterDescriptor, FileSystemAdapterRegistry } from "./config.js";
23
24
  export { createNodeFileSystemAdapterRegistry } from "./config.node.js";
25
+ export { createPortableFileSystemAdapterRegistry } from "./config.portable.js";
@@ -1,4 +1,5 @@
1
1
  export * from "./contracts/index.js";
2
+ export * from "./bridge/index.js";
2
3
  export { ObjectAuthority } from "./fs/object-authority.js";
3
4
  export * from "./python/index.js";
4
5
  export { openFileDescriptor } from "./fs/descriptor.js";
@@ -17,3 +18,4 @@ export { scopeFileSystem, retainFileSystemCleanup } from "./fs/scoped.js";
17
18
  export * from "./node/index.js";
18
19
  export { createFileSystem, readConfigRecord, validateFileSystemConfig } from "./config.js";
19
20
  export { createNodeFileSystemAdapterRegistry } from "./config.node.js";
21
+ export { createPortableFileSystemAdapterRegistry } from "./config.portable.js";
@@ -1,5 +1,6 @@
1
1
  import type * as FsPromises from "node:fs/promises";
2
2
  import type { FileSystem, FsOptions } from "../contracts/filesystem.js";
3
+ import { FileSystemBridge } from "../bridge/filesystem.js";
3
4
  export type NodeFsImplementation = Pick<typeof FsPromises, "access" | "appendFile" | "chmod" | "copyFile" | "cp" | "link" | "lstat" | "mkdir" | "mkdtemp" | "readFile" | "readdir" | "readlink" | "realpath" | "unlink" | "rename" | "rm" | "rmdir" | "stat" | "symlink" | "truncate" | "utimes" | "writeFile">;
4
5
  export interface NodeFsBridgeOptions {
5
6
  readonly cwd?: string;
@@ -14,5 +15,5 @@ export interface NodeFsBridgeOptions {
14
15
  export interface NodeFsBridgeFileSystem extends FileSystem {
15
16
  rmdir?(path: string, options?: FsOptions): Promise<void>;
16
17
  }
17
- export declare function getNodeFsBridgeProvider(bridge: object): FileSystem | undefined;
18
+ export declare const getNodeFsBridgeProvider: typeof FileSystemBridge.getProvider;
18
19
  export declare function createNodeFsBridge(fs: FileSystem, options?: NodeFsBridgeOptions): NodeFsImplementation;
@@ -3,10 +3,7 @@ import { randomBytes } from "node:crypto";
3
3
  import { posix } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { FileSystemBridge } from "../bridge/filesystem.js";
6
- const providers = new WeakMap();
7
- export function getNodeFsBridgeProvider(bridge) {
8
- return providers.get(bridge);
9
- }
6
+ export const getNodeFsBridgeProvider = FileSystemBridge.getProvider;
10
7
  export function createNodeFsBridge(fs, options = {}) {
11
8
  const bridge = new FileSystemBridge(fs, options, {
12
9
  codec: {
@@ -29,6 +26,5 @@ export function createNodeFsBridge(fs, options = {}) {
29
26
  randomSuffix() { return randomBytes(6).toString("hex").slice(0, 6); },
30
27
  paths: posix
31
28
  });
32
- providers.set(bridge, fs);
33
29
  return bridge;
34
30
  }
@@ -1,6 +1,7 @@
1
1
  export * from "./contracts/errors.js";
2
2
  export * from "./contracts/filesystem.js";
3
3
  export * from "./contracts/io.js";
4
+ export { compareIdentity, compareFileVersion } from "./fs/mount/identity.js";
4
5
  export { openFileDescriptor } from "./fs/descriptor.js";
5
6
  export type { DescriptorBackend, DescriptorOpenOptions } from "./fs/descriptor.js";
6
7
  export { assertPathWithin, isPathWithin, normalizePath, relativePath, resolvePath, validatePath } from "./contracts/virtual-path.js";
@@ -1,6 +1,7 @@
1
1
  export * from "./contracts/errors.js";
2
2
  export * from "./contracts/filesystem.js";
3
3
  export * from "./contracts/io.js";
4
+ export { compareIdentity, compareFileVersion } from "./fs/mount/identity.js";
4
5
  export { openFileDescriptor } from "./fs/descriptor.js";
5
6
  export { assertPathWithin, isPathWithin, normalizePath, relativePath, resolvePath, validatePath } from "./contracts/virtual-path.js";
6
7
  export { basename, dirname, extname, isAbsolutePath, joinPath, posixPath } from "./contracts/portable-path.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poe-platform/safe-fs",
3
- "version": "0.1.805",
3
+ "version": "0.1.807",
4
4
  "description": "Composable filesystem with a portable core and explicit Node adapters",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -130,6 +130,10 @@
130
130
  "./runtime-core": {
131
131
  "types": "./dist/safe-fs/runtime-core.d.ts",
132
132
  "import": "./dist/safe-fs/runtime-core.js"
133
+ },
134
+ "./bridge": {
135
+ "types": "./dist/safe-fs/bridge/index.d.ts",
136
+ "import": "./dist/safe-fs/bridge/index.js"
133
137
  }
134
138
  },
135
139
  "repository": {