@poe-platform/safe-fs 0.1.735 → 0.1.737

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
@@ -41,10 +41,10 @@ To create new outputs with Safe Bash's `dos2unix`, `unix2dos`, or compression co
41
41
 
42
42
  | Import | Use |
43
43
  | --- | --- |
44
- | `@poe-platform/safe-fs` | Full Node API; selects the portable core under the `browser` condition. |
44
+ | `@poe-platform/safe-fs` | Full Node API; selects the portable core under the `browser` and `workerd` conditions. |
45
45
  | `@poe-platform/safe-fs/core` | Memory, mounts, overlays, read-only, WebDAV, portable bridges, types, and errors. Bundle with the `browser` condition for browsers and Workers. |
46
46
  | `@poe-platform/safe-fs/node` | Host filesystem, Node bridge, S3, and configuration helpers; unavailable in browsers. |
47
- | `@poe-platform/safe-fs/fs/*` | Individual adapters: `memory`, `readonly`, `mount`, `overlay`, `webdav`, `real`, `s3`, and `s3/http`. The last three are Node-only. |
47
+ | `@poe-platform/safe-fs/fs/*` | Individual adapters: `memory`, `readonly`, `mount`, `overlay`, `webdav`, `real`, `s3`, and `s3/http`. S3 and its mock accept an injected transport in browsers and Workers; `s3/http` is Node-only. |
48
48
 
49
49
  ```ts
50
50
  import { createMemoryFileSystem, FsError, type FileSystem } from "@poe-platform/safe-fs/core";
@@ -117,7 +117,7 @@ See the [staging contract](src/contracts/filesystem.md#atomic-owned-staging).
117
117
  | --- | --- |
118
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 |
119
119
  | `createRealFileSystem({ root })` | An existing host directory, with virtual paths rooted inside it; Node or qualified Workerd operations |
120
- | `new S3FileSystem({ transport, bucket, … })` | Bucket/prefix storage through an explicitly supplied transport; Node only |
120
+ | `new S3FileSystem({ transport, bucket, … })` | Bucket/prefix storage through an explicitly supplied transport in Node, browsers, and Workers |
121
121
  | `new WebDavFileSystem({ baseUrl, fetch, … })` | A WebDAV namespace through an explicitly supplied Fetch implementation |
122
122
  | `createReadOnlyFileSystem(filesystem)` | Rejecting writes through one view of an existing filesystem |
123
123
  | `createMountFileSystem({ root, mounts })` | Routing absolute virtual mount paths to different filesystems |
@@ -268,7 +268,7 @@ Every raw filesystem operation accepts an optional `signal`. Additional fields a
268
268
 
269
269
  `access` takes a separate mode bitmask from `ACCESS_MODES`. `chmod` takes a mode, `utimes` takes millisecond timestamps, and `truncate` takes a byte length (default 0). For conditional chmod, check `capabilitiesFor(path, { conditionalChmod: true })` (or `capabilities`) and require `conditionalChmod: true`; supply `parent`, `expected`, and complete root-to-parent `ancestors` together. An optional mutation-free `commitGuard` must return literal `true` synchronously. Memory validates at its metadata commit; mount and supported Memory overlays preserve wrapper ancestry. Real uses its existing externally isolated host-tree boundary and does not prevent races with other processes. Backend limits still apply. Node-shaped bridge methods translate their own options rather than accepting these raw option objects; see the [bridge signatures](src/bridge/filesystem.ts).
270
270
 
271
- `collectBytes(source, { maxBytes, maxMemoryBytes, signal })` snapshots streamed chunks into one growing buffer. `maxBytes` and `maxMemoryBytes` accept explicit `Infinity`. `maxMemoryBytes` limits owned capacity, the current input's full backing buffer, and overlapping allocations during growth; exhaustion throws `EFBIG`. Browser and Worker bundles additionally share a fixed 32 MiB budget across active collectors, even when byte limits are omitted. The returned view may retain geometric spare capacity. This budget covers collection, not caller-retained results, transport buffering, archive decoding, strings, or the rest of the runtime; use streaming APIs and limit concurrent workloads for larger inputs.
271
+ `collectBytes(source, { maxBytes, maxMemoryBytes, signal })` snapshots streamed chunks into one growing buffer. `maxBytes` and `maxMemoryBytes` accept explicit `Infinity`. `maxMemoryBytes` limits owned capacity, the current input's full backing buffer, and overlapping allocations during growth; exhaustion throws `EFBIG`. Collection limits are disabled by default on Node, browser, and Worker targets. The returned view may retain geometric spare capacity. Configured budgets cover collection, not caller-retained results, transport buffering, archive decoding, strings, or the rest of the runtime; use streaming APIs and limit concurrent workloads for larger inputs.
272
272
 
273
273
  <details>
274
274
  <summary>S3 filesystem and HTTP transport options</summary>
@@ -344,17 +344,18 @@ Each batch lists from the beginning because previous keys have been deleted; it
344
344
  | --- | --- |
345
345
  | `headers` | Empty; explicit authentication/custom headers. Protocol-reserved headers are rejected; authorization and cookies require HTTPS. |
346
346
  | `requestStreamSupport` | `native` for global Fetch, otherwise false; accepts `native` or a boolean declaration for the injected transport |
347
- | `maxResponseBytes` | 16 MiB; applies to decoded response bytes |
348
- | `maxXmlBytes` | 1 MiB before metadata decoding/parsing |
347
+ | `maxResponseBytes` | Unlimited unless configured; applies to decoded response bytes |
348
+ | `maxXmlBytes` | Unlimited unless configured; applies before metadata decoding/parsing |
349
+ | `xmlLimits` | Optional `maxNodes`, `maxContentNodes`, `maxDepth`, and `maxAttributes`; each accepts a positive safe integer or `Infinity`, and is unlimited unless configured |
349
350
  | `maxEntries` | Unlimited unless configured |
350
351
  | `timeoutMs` | Unlimited unless configured |
351
352
  | `overwritePolicy` | `lock`; alternative `etag` uses conditional overwrites |
352
353
  | `atomicEmptyDirectory` | Optional trusted binding with the canonical `namespaceUrl` and `removeEmptyDirectory` callback; required for strict empty-only `rmdir` |
353
354
  | `compareEntry` | Optional trusted backing-identity callback on Node; unavailable under browser policy |
354
355
 
355
- Known identity Content-Length responses use one result buffer; other responses grow storage up to the configured ceiling and return a view without a final copy. Growth can temporarily retain the old and new buffers (up to three times the response size), plus transport chunks. These per-response defaults leave headroom in Workers; hosts must still budget for metadata parsing, text decoding, transport buffers, and concurrent reads, especially when raising the limits. Use streaming reads for large files.
356
+ Known identity Content-Length responses use one result buffer; other responses grow storage up to the configured ceiling and return a view without a final copy. Growth can temporarily retain the old and new buffers (up to three times the response size), plus transport chunks. Hosts can configure per-response limits to budget for metadata parsing, text decoding, transport buffers, and concurrent reads. Use streaming reads for large files.
356
357
 
357
- WebDAV `maxEntries`, `maxResponseBytes`, `maxXmlBytes`, and per-read `maxBytes` accept explicit `Infinity`. WebDAV metadata parsing limits the document to 100,000 elements, 100,000 content nodes, 100,000 attributes, and 256 levels of nesting, independently of `maxEntries`. Text is bounded by `maxXmlBytes`. Exceeding a structural budget reports `EFBIG`; parsing yields cooperatively so caller cancellation and `timeoutMs` remain active.
358
+ WebDAV `maxEntries`, `maxResponseBytes`, `maxXmlBytes`, and per-read `maxBytes` accept explicit `Infinity`. WebDAV metadata structure limits are disabled by default; configure `xmlLimits` independently of `maxEntries`. Text is bounded by `maxXmlBytes`. Exceeding a structural budget reports `EFBIG`; parsing yields cooperatively so caller cancellation and `timeoutMs` remain active.
358
359
 
359
360
  See the [binding types](src/fs/webdav/webdav.ts) before implementing atomic directory removal. A recursive WebDAV DELETE does not satisfy that contract.
360
361
 
@@ -366,7 +367,7 @@ See the [binding types](src/fs/webdav/webdav.ts) before implementing atomic dire
366
367
  - **A read-only view is not an immutable store.** Mount views serialize guest symlink, rename, and removal operations against active path operations. Finish or close read streams before awaiting these namespace changes. Other references can still change the backing filesystem; this coordination applies only to operations through the same mount view. Overlay reads require retained handles and stable object and ancestor identities; unsupported backends or changed read admission fail with `ENOTSUP`. Overlays with two stock MemoryFileSystem layers support race-safe patch publication and confined directory creation/removal. Publication checks both layers, retains logical directory identity across copy-up, and leaves lower bytes intact; wrapped or customized layers remain unsupported. Overlays are not transactions; cancellation and cleanup do not guarantee rollback. Cross-mount rename can fail with `EXDEV`.
367
368
  - **Remote storage is not a POSIX disk.** S3 and WebDAV do not provide hardlinks or symlinks. S3 rename is non-atomic and may leave partial copies/deletions; `S3RenameError` reports the phase and affected keys. Strong empty-only removal and conditional writes depend on backend support, not a prior listing.
368
369
  - **The bridges are partial Node compatibility layers.** No synchronous/callback API, file handles, watchers, bigint stats, flush/retry support, or `cp` dereference/timestamp-preservation options. Missing optional operations fail rather than being approximated with destructive alternatives.
369
- - **Browser support is filesystem-only.** The `browser` export condition selects the portable surface: memory, mounts, overlays, read-only, WebDAV, and the codec-based bridge. Real storage, S3, the Node bridge, and the configuration registry are not browser exports. WebDAV still needs server CORS support; no OPFS or directory-handle adapter is included. This does not make the SafeJS runtime browser-compatible.
370
+ - **Browser support is filesystem-only.** The `browser` and `workerd` export conditions select the portable surface: memory, mounts, overlays, read-only, WebDAV, and the codec-based bridge. S3 is available through `fs/s3` with an explicit transport, including its portable mock. Real storage, the Node bridge, and the configuration registry are not browser exports. WebDAV still needs server CORS support; no OPFS or directory-handle adapter is included. This does not make the SafeJS runtime browser-compatible.
370
371
  - **Portable temporary names require secure randomness.** Browser overlay staging and portable bridge `mkdtemp` require `crypto.randomUUID` or `crypto.getRandomValues`; without either, they fail with `ENOTSUP` rather than use `Math.random`.
371
372
  - **Allocation and identity may be unknown.** Optional `FileStat.allocatedBytes` is provider-reported allocation, not logical length or reclaimable space. Do not infer identity from size, timestamps, or inode numbers across unrelated backends.
372
373
 
@@ -1,4 +1,4 @@
1
- import { posix } from "node:path";
1
+ import { posix } from "#safe-fs-platform";
2
2
  export { assertPathWithin, isPathWithin, normalizePath, relativePath, resolvePath, validatePath } from "./virtual-path.js";
3
3
  export declare const posixPath: typeof posix;
4
4
  export declare const basename: typeof posix.basename;
@@ -1,4 +1,4 @@
1
- import { posix } from "node:path";
1
+ import { posix } from "#safe-fs-platform";
2
2
  export { assertPathWithin, isPathWithin, normalizePath, relativePath, resolvePath, validatePath } from "./virtual-path.js";
3
3
  export const posixPath = posix;
4
4
  export const basename = posix.basename;
@@ -5,8 +5,8 @@ import type { WireObjectMetadata, OpenFileObjectOptions, WireExactFileStat, Wire
5
5
  * not bearer authorization. All admitted operations drain before disposal. */
6
6
  export declare class ObjectAuthority {
7
7
  #private;
8
- constructor(filesystem: FileSystem | Pick<FileSystem, "objects">, options: {
9
- readonly maxHandles: number;
8
+ constructor(filesystem: FileSystem | Pick<FileSystem, "objects">, options?: {
9
+ readonly maxHandles?: number;
10
10
  readonly maxIoBytes?: number;
11
11
  });
12
12
  open(path: BytePath, options?: OpenFileObjectOptions): Promise<WireObjectHandle>;
@@ -13,7 +13,7 @@ export class ObjectAuthority {
13
13
  #sequence = 0;
14
14
  #tail = Promise.resolve();
15
15
  #disposal;
16
- constructor(filesystem, options) {
16
+ constructor(filesystem, options = {}) {
17
17
  const backend = filesystem.objects;
18
18
  // One issued authority keeps its qualified namespace. Bound operations
19
19
  // observe live backend state without accepting replacement capabilities.
@@ -27,11 +27,12 @@ export class ObjectAuthority {
27
27
  specialFiles: Object.freeze({ ...specialFiles }),
28
28
  });
29
29
  }
30
- this.#maxHandles = options.maxHandles;
31
- this.#maxIoBytes = options.maxIoBytes ?? 1048576;
32
- if (!Number.isSafeInteger(this.#maxHandles) || this.#maxHandles < 1 ||
33
- !Number.isSafeInteger(this.#maxIoBytes) || this.#maxIoBytes < 1)
34
- throw new FsError("EINVAL");
30
+ this.#maxHandles = options.maxHandles ?? Infinity;
31
+ this.#maxIoBytes = options.maxIoBytes ?? Infinity;
32
+ for (const limit of [this.#maxHandles, this.#maxIoBytes]) {
33
+ if (limit !== Infinity && (!Number.isSafeInteger(limit) || limit < 1))
34
+ throw new FsError("EINVAL");
35
+ }
35
36
  }
36
37
  #run(operation) {
37
38
  if (this.#disposal)
@@ -3,5 +3,5 @@ import type { S3HeadOutput, S3ObjectInput } from "./transport.js";
3
3
  export { registerS3EntryOwner, getOwnedS3Entry, compareOwnedS3Entries } from "./registry.js";
4
4
  export type { OwnedS3Entry } from "./registry.js";
5
5
  export declare function recordMockS3Head(output: S3HeadOutput, input: S3ObjectInput, storage: object): void;
6
- export declare function queryS3Head(input: S3ObjectInput, action: () => Promise<S3HeadOutput>): Promise<S3HeadOutput>;
6
+ export declare function queryS3Head(input: S3ObjectInput, action: (query: S3ObjectInput) => Promise<S3HeadOutput>): Promise<S3HeadOutput>;
7
7
  export declare function recordS3Stat(filesystem: FileSystem, path: string, stat: FileStat, metadata: S3HeadOutput | undefined): void;
@@ -1,25 +1,32 @@
1
- import { AsyncLocalStorage } from "node:async_hooks";
2
1
  import { recordS3Observation } from "./registry.js";
3
2
  export { registerS3EntryOwner, getOwnedS3Entry, compareOwnedS3Entries } from "./registry.js";
4
- const queries = new AsyncLocalStorage();
3
+ const queryKey = Symbol("s3-head-query");
4
+ const queries = new WeakMap();
5
5
  const providerHeads = new WeakMap();
6
6
  const acceptedHeads = new WeakMap();
7
7
  export function recordMockS3Head(output, input, storage) {
8
- const query = queries.getStore();
8
+ const token = input[queryKey];
9
+ const query = token && queries.get(token);
9
10
  if (query && query.Bucket === input.Bucket && query.Key === input.Key) {
10
11
  providerHeads.set(output, { query, entry: { storage, key: input.Key } });
11
12
  }
12
13
  }
13
14
  export async function queryS3Head(input, action) {
14
- const query = { ...input };
15
- return queries.run(query, async () => {
16
- const output = await action();
15
+ const token = Object.freeze({});
16
+ const query = Object.freeze({ ...input });
17
+ const forwarded = { ...query, [queryKey]: token };
18
+ queries.set(token, query);
19
+ try {
20
+ const output = await action(forwarded);
17
21
  acceptedHeads.delete(output);
18
22
  const proof = providerHeads.get(output);
19
23
  if (proof?.query === query)
20
24
  acceptedHeads.set(output, proof.entry);
21
25
  return output;
22
- });
26
+ }
27
+ finally {
28
+ queries.delete(token);
29
+ }
23
30
  }
24
31
  export function recordS3Stat(filesystem, path, stat, metadata) {
25
32
  if (!metadata)
@@ -1,4 +1,5 @@
1
- import { posix } from "node:path";
1
+ import { posixPath as posix } from "../../contracts/portable-path.js";
2
+ import { compareKeys } from "./key-order.js";
2
3
  import { FsError, isFsError } from "../../contracts/errors.js";
3
4
  import { composeAbortSignals } from "../../contracts/abort.js";
4
5
  import { collectBytes, readBytes } from "../../contracts/io.js";
@@ -263,7 +264,7 @@ export class S3FileSystem {
263
264
  async head(key, path, options) {
264
265
  try {
265
266
  const input = { Bucket: this.bucket, Key: key };
266
- return await this.call("headObject", path, options, () => queryS3Head(input, () => this.transport.headObject(input, this.requestOptions(options))));
267
+ return await this.call("headObject", path, options, () => queryS3Head(input, query => this.transport.headObject(query, this.requestOptions(options))));
267
268
  }
268
269
  catch (error) {
269
270
  if (isFsError(error, "ENOENT") && serviceCode(error.cause) !== "NoSuchBucket")
@@ -593,7 +594,7 @@ export class S3FileSystem {
593
594
  for (const item of page.CommonPrefixes ?? [])
594
595
  add(item.Prefix.slice(prefix.length, -1), "directory");
595
596
  }
596
- return [...entries.values()].sort((left, right) => Buffer.compare(Buffer.from(left.name), Buffer.from(right.name)));
597
+ return [...entries.values()].sort((left, right) => compareKeys(left.name, right.name));
597
598
  }
598
599
  async tree(path, options) {
599
600
  const objects = new Map();
@@ -0,0 +1,2 @@
1
+ /** S3 orders keys lexicographically by their UTF-8 bytes. */
2
+ export declare function compareKeys(left: string, right: string): number;
@@ -0,0 +1,11 @@
1
+ /** S3 orders keys lexicographically by their UTF-8 bytes. */
2
+ export function compareKeys(left, right) {
3
+ const encoder = new TextEncoder();
4
+ const a = encoder.encode(left), b = encoder.encode(right);
5
+ for (let index = 0; index < Math.min(a.length, b.length); index++) {
6
+ const difference = a[index] - b[index];
7
+ if (difference)
8
+ return difference;
9
+ }
10
+ return a.length - b.length;
11
+ }
@@ -15,13 +15,10 @@ function utf8ByteLength(value) {
15
15
  }
16
16
  return bytes;
17
17
  }
18
- import { createHash } from "node:crypto";
18
+ import { compareKeys } from "./key-order.js";
19
19
  import { collectBytes } from "../../contracts/io.js";
20
20
  import { recordMockS3Head } from "./authority.js";
21
21
  import { S3ServiceError } from "./transport.js";
22
- function compareKeys(left, right) {
23
- return Buffer.compare(Buffer.from(left), Buffer.from(right));
24
- }
25
22
  export class MockS3Client {
26
23
  capabilities = Object.freeze({ conditionalPut: true, conditionalCopy: true, conditionalDelete: true, streamingRead: true, streamingWrite: true });
27
24
  buckets = new Map();
@@ -78,13 +75,13 @@ export class MockS3Client {
78
75
  Metadata: { ...object.metadata },
79
76
  };
80
77
  }
81
- store(body, metadata = {}) {
78
+ async store(body, metadata = {}) {
82
79
  const metadataBytes = Object.entries(metadata).reduce((total, [key, value]) => total + utf8ByteLength(key) + utf8ByteLength(value), 0);
83
80
  if (metadataBytes > 2048)
84
81
  throw new S3ServiceError("MetadataTooLarge", 400);
85
82
  return {
86
83
  body: new Uint8Array(body),
87
- etag: `"${createHash("md5").update(body).digest("hex")}"`,
84
+ etag: `"${Array.from(new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new Uint8Array(body))), byte => byte.toString(16).padStart(2, "0")).join("")}"`,
88
85
  modified: new Date(this.now()),
89
86
  metadata: { ...metadata },
90
87
  };
@@ -104,6 +101,7 @@ export class MockS3Client {
104
101
  if (!(input.Body instanceof Uint8Array))
105
102
  throw new S3ServiceError("InvalidArgument", 400);
106
103
  input = { ...input, Body: new Uint8Array(input.Body), ...(input.Metadata ? { Metadata: { ...input.Metadata } } : {}) };
104
+ const object = await this.store(input.Body, input.Metadata);
107
105
  await this.begin("putObject", input, options);
108
106
  const bucket = this.bucket(input.Bucket);
109
107
  const previous = bucket.get(input.Key);
@@ -113,7 +111,6 @@ export class MockS3Client {
113
111
  || (input.IfMatch !== undefined && previous?.etag !== input.IfMatch)) {
114
112
  throw new S3ServiceError("PreconditionFailed", 412);
115
113
  }
116
- const object = this.store(input.Body, input.Metadata);
117
114
  bucket.set(input.Key, object);
118
115
  return { ETag: object.etag };
119
116
  }
@@ -148,6 +145,8 @@ export class MockS3Client {
148
145
  const snapshot = { ...input, Body: new Uint8Array(), ...(input.Metadata ? { Metadata: { ...input.Metadata } } : {}) };
149
146
  await this.begin("putObject", snapshot, options);
150
147
  const body = await collectBytes(input.Body, { maxBytes: 5_000_000_000, ...(options?.abortSignal ? { signal: options.abortSignal } : {}) });
148
+ const object = await this.store(body, snapshot.Metadata);
149
+ options?.abortSignal?.throwIfAborted();
151
150
  const bucket = this.bucket(input.Bucket);
152
151
  const previous = bucket.get(input.Key);
153
152
  if (input.IfMatch !== undefined && !previous)
@@ -155,7 +154,6 @@ export class MockS3Client {
155
154
  if ((input.IfNoneMatch === "*" && previous) || (input.IfMatch !== undefined && previous?.etag !== input.IfMatch)) {
156
155
  throw new S3ServiceError("PreconditionFailed", 412);
157
156
  }
158
- const object = this.store(body, snapshot.Metadata);
159
157
  bucket.set(input.Key, object);
160
158
  return { ETag: object.etag };
161
159
  }
@@ -184,7 +182,9 @@ export class MockS3Client {
184
182
  if (separator < 1)
185
183
  throw new S3ServiceError("InvalidArgument", 400);
186
184
  const source = this.object({ Bucket: decoded.slice(0, separator), Key: decoded.slice(separator + 1) });
187
- if (input.CopySourceIfMatch !== undefined && input.CopySourceIfMatch !== source.etag) {
185
+ const copy = await this.store(source.body, input.MetadataDirective === "REPLACE" ? input.Metadata : source.metadata);
186
+ options?.abortSignal?.throwIfAborted();
187
+ if (input.CopySourceIfMatch !== undefined && input.CopySourceIfMatch !== this.object({ Bucket: decoded.slice(0, separator), Key: decoded.slice(separator + 1) }).etag) {
188
188
  throw new S3ServiceError("PreconditionFailed", 412);
189
189
  }
190
190
  const bucket = this.bucket(input.Bucket);
@@ -192,7 +192,6 @@ export class MockS3Client {
192
192
  throw new S3ServiceError("PreconditionFailed", 412);
193
193
  if (input.IfMatch !== undefined && bucket.get(input.Key)?.etag !== input.IfMatch)
194
194
  throw new S3ServiceError("PreconditionFailed", 412);
195
- const copy = this.store(source.body, input.MetadataDirective === "REPLACE" ? input.Metadata : source.metadata);
196
195
  bucket.set(input.Key, copy);
197
196
  return { CopyObjectResult: { ETag: copy.etag, LastModified: new Date(copy.modified) } };
198
197
  }
@@ -3,7 +3,7 @@ import { validatePath } from "../contracts/virtual-path.js";
3
3
  import { finishCleanup } from "../contracts/cleanup.js";
4
4
  import { registerEntryView } from "./mount/comparison.js";
5
5
  import { getScopedTransportBudget, runScopedTransportBudget, withScopedTransportBudget } from "#safe-fs-platform";
6
- import { hasRegisteredS3FileSystem } from "../platform/transport-budget.js";
6
+ import { hasRegisteredS3FileSystem, scopeTransportOptions } from "../platform/transport-budget.js";
7
7
  import { openRetainedResizeFile, retainedResizeCapabilities, ownedMutationCapabilities, requireOwnedMutation } from "./capabilities.js";
8
8
  import { createStagingCleanup, snapshotStagingCreation } from "./staging-cleanup.js";
9
9
  import { inspectStagingBindings, runStagingGuard, snapshotDirectoryAncestry, snapshotStagingResolution } from "./staging-ancestry.js";
@@ -400,9 +400,16 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
400
400
  const result = Reflect.apply(method, original, args);
401
401
  return property === "readStream" ? wrapStream(result, args[1]) : result;
402
402
  };
403
- const dispatch = (...args) => hasRegisteredS3FileSystem
404
- ? runScopedTransportBudget(admit, () => executeDispatch(args), credit)
405
- : executeDispatch(args);
403
+ const dispatch = (...args) => {
404
+ if (!hasRegisteredS3FileSystem)
405
+ return executeDispatch(args);
406
+ if (operations.has(property)) {
407
+ const optionIndex = ["compareEntry", "createStagedFile", "utimes"].includes(String(property)) ? 3
408
+ : ["writeFile", "appendFile", "writeStream", "rename", "copyFile", "link", "symlink", "access", "chmod", "truncate", "resizeFile", "publishFileConditional", "writeFileConditional", "publishStagedFile"].includes(String(property)) ? 2 : 1;
409
+ args[optionIndex] = scopeTransportOptions((args[optionIndex] ?? {}), admit, credit);
410
+ }
411
+ return runScopedTransportBudget(admit, () => executeDispatch(args), credit);
412
+ };
406
413
  const scoped = property === "prepareStagingResolution"
407
414
  ? async (path, options = {}) => {
408
415
  const controls = resizeOptions({ ...options });
@@ -2,6 +2,7 @@ import type { PlatformComparisonCallback } from "#safe-fs-platform";
2
2
  import type { ByteSource } from "../../contracts/io.js";
3
3
  import type { AppendFileOptions, CopyFileOptions, DirectoryEntry, EntryComparison, FileStat, FileSystem, FsOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions } from "../../contracts/filesystem.js";
4
4
  import type { FileDescriptor, OpenFileOptions } from "../../contracts/descriptor.js";
5
+ import type { XmlLimits } from "./xml.js";
5
6
  export type WebDavFetch = (url: string, init: RequestInit) => Promise<Response>;
6
7
  export interface WebDavAtomicEmptyDirectoryRequest {
7
8
  readonly operation: "atomic-empty-rmdir/v1";
@@ -24,11 +25,13 @@ export interface WebDavFileSystemOptions {
24
25
  readonly fetch: WebDavFetch;
25
26
  readonly requestStreamSupport?: "native" | boolean;
26
27
  readonly headers?: Readonly<Record<string, string>>;
27
- /** Response ceiling; defaults to 16 MiB. Hosts must budget for concurrent reads. */
28
+ /** Response ceiling; unlimited unless configured. */
28
29
  readonly maxResponseBytes?: number;
29
- /** Metadata response ceiling; defaults to 1 MiB before decoding/parsing. */
30
+ /** Metadata response ceiling before decoding/parsing; unlimited unless configured. */
30
31
  readonly maxXmlBytes?: number;
31
32
  readonly maxEntries?: number;
33
+ /** XML structure ceilings; unlimited unless configured. */
34
+ readonly xmlLimits?: Pick<XmlLimits, "maxNodes" | "maxContentNodes" | "maxDepth" | "maxAttributes">;
32
35
  /** Per-request and aggregate stat/write-preflight walk timeout; unlimited unless configured. */
33
36
  readonly timeoutMs?: number;
34
37
  readonly overwritePolicy?: "lock" | "etag";
@@ -78,6 +81,7 @@ export declare class WebDavFileSystem implements FileSystem {
78
81
  private readonly maxResponseBytes;
79
82
  private readonly maxXmlBytes;
80
83
  private readonly maxEntries;
84
+ private readonly xmlLimits;
81
85
  private readonly timeoutMs;
82
86
  private readonly walkDeadlines;
83
87
  private readonly overwritePolicy;
@@ -234,6 +234,7 @@ export class WebDavFileSystem {
234
234
  maxResponseBytes;
235
235
  maxXmlBytes;
236
236
  maxEntries;
237
+ xmlLimits;
237
238
  timeoutMs;
238
239
  walkDeadlines = new WeakMap();
239
240
  overwritePolicy;
@@ -288,8 +289,11 @@ export class WebDavFileSystem {
288
289
  removeDirectory: this.atomicEmptyDirectory !== undefined,
289
290
  streamingAppend: this.requestStreamSupport !== false,
290
291
  });
291
- this.maxResponseBytes = options.maxResponseBytes === undefined ? 16 * 1024 * 1024 : positive(options.maxResponseBytes, "maxResponseBytes", false, true);
292
- this.maxXmlBytes = options.maxXmlBytes === undefined ? 1024 * 1024 : positive(options.maxXmlBytes, "maxXmlBytes", false, true);
292
+ this.maxResponseBytes = options.maxResponseBytes === undefined ? Infinity : positive(options.maxResponseBytes, "maxResponseBytes", false, true);
293
+ this.maxXmlBytes = options.maxXmlBytes === undefined ? Infinity : positive(options.maxXmlBytes, "maxXmlBytes", false, true);
294
+ this.xmlLimits = Object.freeze({ ...options.xmlLimits });
295
+ for (const [name, value] of Object.entries(this.xmlLimits))
296
+ positive(value, name, false, true);
293
297
  this.maxEntries = options.maxEntries === undefined ? Infinity : positive(options.maxEntries, "maxEntries", false, true);
294
298
  this.timeoutMs = options.timeoutMs === undefined ? undefined : positive(options.timeoutMs, "timeoutMs");
295
299
  this.overwritePolicy = options.overwritePolicy ?? "lock";
@@ -565,8 +569,7 @@ export class WebDavFileSystem {
565
569
  ? "utf-16le" : (data[0] === 0xfe && data[1] === 0xff) || (data[0] === 0 && data[1] === 0x3c) ? "utf-16be" : "utf-8";
566
570
  const parser = parseXmlSteps(new TextDecoder(encoding, { fatal: true }).decode(data), {
567
571
  ...(maxResponses === undefined ? {} : { maxResponses }),
568
- maxNodes: 100_000, maxContentNodes: 100_000, maxDepth: 256,
569
- maxAttributes: 100_000, maxTextLength: this.maxXmlBytes,
572
+ ...this.xmlLimits, maxTextLength: this.maxXmlBytes,
570
573
  });
571
574
  let work = 0;
572
575
  try {
@@ -1,3 +1,4 @@
1
+ export { posixPath as posix } from "../contracts/portable-path.js";
1
2
  import type { FsOptions } from "../contracts/filesystem.js";
2
3
  import type { ScopedTransportBudgetFrame } from "./transport-budget.js";
3
4
  export type PlatformErrno = number | undefined;
@@ -23,4 +24,4 @@ export declare const comparisonContext: Readonly<{
23
24
  export declare function getScopedTransportBudget(): readonly ScopedTransportBudgetFrame[] | undefined;
24
25
  export declare function withScopedTransportBudget<Result>(frames: readonly ScopedTransportBudgetFrame[] | undefined, action: () => Result): Result;
25
26
  export declare function runScopedTransportBudget<Result>(_admit: (options?: FsOptions) => void, action: () => Result, _credit?: number): Result;
26
- export declare function chargeScopedTransportCall(options?: FsOptions): never;
27
+ export declare function chargeScopedTransportCall(options?: FsOptions): void;
@@ -1,7 +1,9 @@
1
1
  import { FsError } from "../contracts/errors.js";
2
+ export { posixPath as posix } from "../contracts/portable-path.js";
3
+ import { chargeTransportOptions } from "./transport-budget.js";
2
4
  export const platform = Object.freeze({
3
5
  nativeFileSystem: Object.freeze({ open: false, permissions: false, timestamps: false, conditionalChmod: false, trustedOwnedStaging: false, atomicRename: false }),
4
- maxCollectionBytes: 32 * 1024 * 1024,
6
+ maxCollectionBytes: Infinity,
5
7
  errno(_code) {
6
8
  return undefined;
7
9
  },
@@ -39,8 +41,7 @@ export const comparisonContext = Object.freeze({
39
41
  }
40
42
  }
41
43
  });
42
- // Scoped browser filesystems charge logical operations directly. S3 remains
43
- // Node-only; never pretend to provide its async transport budget context here.
44
+ // Browser operations carry request budgets in their options, including streams.
44
45
  export function getScopedTransportBudget() {
45
46
  return undefined;
46
47
  }
@@ -54,5 +55,5 @@ export function runScopedTransportBudget(_admit, action, _credit = 1) {
54
55
  }
55
56
  export function chargeScopedTransportCall(options) {
56
57
  options?.signal?.throwIfAborted();
57
- throw new FsError("ENOTSUP", { message: "S3 transport budget contexts require Node" });
58
+ chargeTransportOptions(options);
58
59
  }
@@ -1,3 +1,4 @@
1
+ export { posix } from "node:path";
1
2
  import type { FsOptions } from "../contracts/filesystem.js";
2
3
  import { type ScopedTransportBudgetFrame } from "./transport-budget.js";
3
4
  export type PlatformErrno = number;
@@ -1,4 +1,5 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
+ export { posix } from "node:path";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import * as util from "node:util";
4
5
  import { constants } from "node:os";
@@ -5,3 +5,5 @@ export interface ScopedTransportBudgetFrame {
5
5
  }
6
6
  export declare let hasRegisteredS3FileSystem: boolean;
7
7
  export declare function enableS3TransportBudget(): void;
8
+ export declare function scopeTransportOptions(options: FsOptions, admit: (options?: FsOptions) => void, credit: number): FsOptions;
9
+ export declare function chargeTransportOptions(options?: FsOptions): void;
@@ -2,3 +2,16 @@ export let hasRegisteredS3FileSystem = false;
2
2
  export function enableS3TransportBudget() {
3
3
  hasRegisteredS3FileSystem = true;
4
4
  }
5
+ const budgetKey = Symbol("scoped-transport-budget");
6
+ export function scopeTransportOptions(options, admit, credit) {
7
+ const parent = options[budgetKey] ?? [];
8
+ return { ...options, [budgetKey]: [...parent, { credit, admit }] };
9
+ }
10
+ export function chargeTransportOptions(options) {
11
+ for (const frame of options?.[budgetKey] ?? []) {
12
+ if (frame.credit > 0)
13
+ frame.credit--;
14
+ else
15
+ frame.admit(options);
16
+ }
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poe-platform/safe-fs",
3
- "version": "0.1.735",
3
+ "version": "0.1.737",
4
4
  "description": "Composable filesystem with a portable core and explicit Node adapters",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -13,17 +13,21 @@
13
13
  "exports": {
14
14
  ".": {
15
15
  "types": {
16
+ "workerd": "./dist/safe-fs/core.d.ts",
16
17
  "browser": "./dist/safe-fs/core.d.ts",
17
18
  "default": "./dist/safe-fs/index.d.ts"
18
19
  },
20
+ "workerd": "./dist/safe-fs/core.js",
19
21
  "browser": "./dist/safe-fs/core.js",
20
22
  "import": "./dist/safe-fs/index.js"
21
23
  },
22
24
  "./contracts": {
23
25
  "types": {
26
+ "workerd": "./dist/safe-fs/core.d.ts",
24
27
  "browser": "./dist/safe-fs/core.d.ts",
25
28
  "default": "./dist/safe-fs/contracts/index.d.ts"
26
29
  },
30
+ "workerd": "./dist/safe-fs/core.js",
27
31
  "browser": "./dist/safe-fs/core.js",
28
32
  "import": "./dist/safe-fs/contracts/index.js"
29
33
  },
@@ -86,11 +90,7 @@
86
90
  "import": "./dist/safe-fs/fs/s3/http/index.js"
87
91
  },
88
92
  "./fs/s3": {
89
- "types": {
90
- "browser": "./dist/safe-fs/node-unavailable.d.ts",
91
- "default": "./dist/safe-fs/fs/s3/index.d.ts"
92
- },
93
- "browser": null,
93
+ "types": "./dist/safe-fs/fs/s3/index.d.ts",
94
94
  "import": "./dist/safe-fs/fs/s3/index.js"
95
95
  },
96
96
  "./fs/webdav": {