@poe-platform/safe-fs 0.1.734 → 0.1.736

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,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ export { posix } from "node:path";
2
2
  import type { FsOptions } from "../contracts/filesystem.js";
3
3
  import { type ScopedTransportBudgetFrame } from "./transport-budget.js";
4
4
  export type PlatformErrno = number;
@@ -15,7 +15,7 @@ export declare const platform: Readonly<{
15
15
  maxCollectionBytes: number;
16
16
  errno(code: string): PlatformErrno;
17
17
  callbackAuthorities: true;
18
- randomUUID: typeof randomUUID;
18
+ randomUUID: () => string;
19
19
  }>;
20
20
  export declare const comparisonContext: Readonly<{
21
21
  active(_options: FsOptions): boolean;
@@ -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";
@@ -25,7 +26,7 @@ export const platform = Object.freeze({
25
26
  return errno;
26
27
  },
27
28
  callbackAuthorities: true,
28
- randomUUID
29
+ randomUUID: randomUUID
29
30
  });
30
31
  export const comparisonContext = Object.freeze({
31
32
  active(_options) {
@@ -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
+ }
@@ -6,21 +6,16 @@ export class XmlLimitError extends SyntaxError {
6
6
  }
7
7
  }
8
8
  function* find(source, needle, start) {
9
- let work = 0;
10
- for (let offset = start; offset < source.length; offset++) {
11
- if (source.startsWith(needle, offset)) {
12
- if (work)
13
- yield work;
14
- return offset;
15
- }
16
- if (++work === 512) {
17
- yield work;
18
- work = 0;
19
- }
9
+ const found = source.indexOf(needle, start);
10
+ const end = found < 0 ? source.length : found;
11
+ let remaining = end - start;
12
+ while (remaining >= 512) {
13
+ yield 512;
14
+ remaining -= 512;
20
15
  }
21
- if (work)
22
- yield work;
23
- return -1;
16
+ if (remaining > 0)
17
+ yield remaining;
18
+ return found;
24
19
  }
25
20
  const xmlNamespace = "http://www.w3.org/XML/1998/namespace";
26
21
  const xmlnsNamespace = "http://www.w3.org/2000/xmlns/";
@@ -47,6 +42,30 @@ function namePart(point) {
47
42
  || (point >= 48 && point <= 57) || (point >= 0x300 && point <= 0x36f)
48
43
  || (point >= 0x203f && point <= 0x2040);
49
44
  }
45
+ function qualifiedNameSync(name) {
46
+ let prefix = "";
47
+ let start = 0;
48
+ let first = true;
49
+ for (let offset = 0; offset < name.length;) {
50
+ const point = name.codePointAt(offset);
51
+ if (point === 58) {
52
+ if (start !== 0 || first)
53
+ invalid("invalid qualified name");
54
+ prefix = name.slice(0, offset);
55
+ start = offset + 1;
56
+ first = true;
57
+ }
58
+ else {
59
+ if (!(first ? nameStart(point) : namePart(point)))
60
+ invalid("invalid qualified name");
61
+ first = false;
62
+ }
63
+ offset += point > 0xffff ? 2 : 1;
64
+ }
65
+ if (first)
66
+ invalid("invalid qualified name");
67
+ return [prefix, name.slice(start)];
68
+ }
50
69
  function* qualifiedName(name) {
51
70
  let prefix = "";
52
71
  let start = 0;
@@ -258,67 +277,81 @@ export function* parseXmlSteps(input, limits = {}) {
258
277
  if (++contentNodes > maxContentNodes)
259
278
  throw new XmlLimitError("maxContentNodes", "XML content node limit exceeded");
260
279
  };
261
- const whitespace = function* () {
262
- let work = 0;
263
- while (offset < source.length && " \t\n\r".includes(source[offset])) {
280
+ let pendingWork = 0;
281
+ const skipWhitespace = () => {
282
+ const start = offset;
283
+ while (offset < source.length) {
284
+ const c = source.charCodeAt(offset);
285
+ if (c !== 32 && c !== 9 && c !== 10 && c !== 13)
286
+ break;
264
287
  offset++;
265
- if (++work === 512) {
266
- yield work;
267
- work = 0;
268
- }
269
288
  }
270
- if (work)
271
- yield work;
289
+ return offset - start;
272
290
  };
273
- const readName = function* () {
291
+ const scanName = () => {
274
292
  const start = offset;
275
- while (offset < source.length && !" \t\r\n/=>?".includes(source[offset])) {
293
+ while (offset < source.length) {
294
+ const c = source.charCodeAt(offset);
295
+ if (c === 32 || c === 9 || c === 13 || c === 10 || c === 47 || c === 61 || c === 62 || c === 63)
296
+ break;
276
297
  offset++;
277
- if ((offset - start) % 512 === 0)
278
- yield 512;
279
298
  }
280
- if ((offset - start) % 512)
281
- yield (offset - start) % 512;
282
299
  const name = source.slice(start, offset);
283
- yield* qualifiedName(name);
284
- return name;
300
+ const [prefix, localName] = qualifiedNameSync(name);
301
+ return [name, prefix, localName, (offset - start) * 2];
285
302
  };
286
- const appendText = function* (text, kind = "text") {
287
- admitText(text);
288
- const parent = stack.at(-1);
289
- if (parent) {
290
- parent.element.text += text;
291
- if (text.length || kind === "cdata") {
292
- admitContent();
293
- if (retainContent)
294
- parent.content.push({ kind, text });
303
+ while (offset < source.length) {
304
+ pendingWork += 1;
305
+ if (pendingWork >= 512) {
306
+ while (pendingWork >= 512) {
307
+ yield 512;
308
+ pendingWork -= 512;
295
309
  }
296
310
  }
297
- else {
298
- for (let index = 0; index < text.length; index++) {
299
- if (!" \t\r\n".includes(text[index]))
300
- invalid("text outside the root");
301
- if ((index + 1) % 512 === 0)
302
- yield 512;
303
- }
304
- if (text.length % 512)
305
- yield text.length % 512;
306
- if (text.length) {
307
- admitContent();
308
- if (retainContent)
309
- (root ? epilog : prolog).push({ kind, text });
311
+ if (source.charCodeAt(offset) !== 60) {
312
+ const next = source.indexOf("<", offset);
313
+ const endPos = next < 0 ? source.length : next;
314
+ const text = source.slice(offset, endPos);
315
+ pendingWork += (endPos - offset) + text.length;
316
+ while (pendingWork >= 512) {
317
+ yield 512;
318
+ pendingWork -= 512;
310
319
  }
311
- }
312
- };
313
- while (offset < source.length) {
314
- yield 1;
315
- if (source[offset] !== "<") {
316
- const next = yield* find(source, "<", offset);
317
- const text = source.slice(offset, next < 0 ? source.length : next);
318
- if ((yield* find(text, "]]>", 0)) >= 0)
320
+ if (text.indexOf("]]>") >= 0)
319
321
  invalid("CDATA terminator in text");
320
- yield* appendText(stack.length ? yield* entities(text) : text);
321
- offset += text.length;
322
+ const resolved = stack.length ? (text.indexOf("&") < 0 ? (pendingWork += text.length, text) : yield* entities(text)) : text;
323
+ while (pendingWork >= 512) {
324
+ yield 512;
325
+ pendingWork -= 512;
326
+ }
327
+ admitText(resolved);
328
+ const parent = stack.at(-1);
329
+ if (parent) {
330
+ parent.element.text += resolved;
331
+ if (resolved.length) {
332
+ admitContent();
333
+ if (retainContent)
334
+ parent.content.push({ kind: "text", text: resolved });
335
+ }
336
+ }
337
+ else {
338
+ for (let index = 0; index < resolved.length; index++) {
339
+ const c = resolved.charCodeAt(index);
340
+ if (c !== 32 && c !== 9 && c !== 13 && c !== 10)
341
+ invalid("text outside the root");
342
+ }
343
+ pendingWork += resolved.length;
344
+ while (pendingWork >= 512) {
345
+ yield 512;
346
+ pendingWork -= 512;
347
+ }
348
+ if (resolved.length) {
349
+ admitContent();
350
+ if (retainContent)
351
+ (root ? epilog : prolog).push({ kind: "text", text: resolved });
352
+ }
353
+ }
354
+ offset = endPos;
322
355
  }
323
356
  else if (source.startsWith("<!--", offset)) {
324
357
  const end = yield* find(source, "-->", offset + 4);
@@ -340,13 +373,24 @@ export function* parseXmlSteps(input, limits = {}) {
340
373
  const end = yield* find(source, "]]>", offset + 9);
341
374
  if (end < 0)
342
375
  invalid("unterminated CDATA");
343
- yield* appendText(source.slice(offset + 9, end), "cdata");
376
+ const cdataText = source.slice(offset + 9, end);
377
+ admitText(cdataText);
378
+ const parent = stack.at(-1);
379
+ parent.element.text += cdataText;
380
+ admitContent();
381
+ if (retainContent)
382
+ parent.content.push({ kind: "cdata", text: cdataText });
344
383
  offset = end + 3;
345
384
  }
346
385
  else if (source.startsWith("<?", offset)) {
347
386
  const start = offset;
348
387
  offset += 2;
349
- const target = yield* readName();
388
+ const [target, , , wName] = scanName();
389
+ pendingWork += wName;
390
+ while (pendingWork >= 512) {
391
+ yield 512;
392
+ pendingWork -= 512;
393
+ }
350
394
  const end = yield* find(source, "?>", offset);
351
395
  if (end < 0)
352
396
  invalid("unterminated processing instruction");
@@ -363,15 +407,15 @@ export function* parseXmlSteps(input, limits = {}) {
363
407
  invalid("invalid processing instruction");
364
408
  if (!(target.length === 3 && target.toLowerCase() === "xml")) {
365
409
  const parent = stack.at(-1);
366
- let start = 0;
367
- while (start < content.length && " \t\n\r".includes(content[start])) {
368
- start++;
369
- if (start % 512 === 0)
370
- yield 512;
410
+ let wsStart = 0;
411
+ while (wsStart < content.length && " \t\n\r".includes(content[wsStart]))
412
+ wsStart++;
413
+ pendingWork += wsStart;
414
+ while (pendingWork >= 512) {
415
+ yield 512;
416
+ pendingWork -= 512;
371
417
  }
372
- if (start % 512)
373
- yield start % 512;
374
- const text = content.slice(start);
418
+ const text = content.slice(wsStart);
375
419
  admitText(text);
376
420
  admitContent();
377
421
  if (retainContent) {
@@ -383,92 +427,129 @@ export function* parseXmlSteps(input, limits = {}) {
383
427
  else if (source.startsWith("<!", offset)) {
384
428
  invalid("DTD and entity declarations are forbidden");
385
429
  }
386
- else if (source.startsWith("</", offset)) {
430
+ else if (source.charCodeAt(offset + 1) === 47) {
387
431
  offset += 2;
388
- const name = yield* readName();
389
- yield* whitespace();
432
+ const [name, , , wName] = scanName();
433
+ pendingWork += wName + skipWhitespace();
434
+ while (pendingWork >= 512) {
435
+ yield 512;
436
+ pendingWork -= 512;
437
+ }
390
438
  if (source[offset++] !== ">" || stack.pop()?.name !== name)
391
439
  invalid("mismatched closing tag");
392
440
  }
393
441
  else {
394
442
  offset++;
395
- const name = yield* readName();
443
+ const [name, prefix, localName, wName] = scanName();
444
+ pendingWork += wName;
445
+ while (pendingWork >= 512) {
446
+ yield 512;
447
+ pendingWork -= 512;
448
+ }
396
449
  const attributes = new Map();
450
+ const attrMeta = [];
397
451
  let namespaces = stack.at(-1)?.namespaces ?? new Map([["xml", xmlNamespace]]);
398
452
  let ownsNamespaces = stack.length === 0;
399
453
  while (true) {
400
- const beforeSpace = offset;
401
- yield* whitespace();
402
- if (source[offset] === "/" || source[offset] === ">")
454
+ const ws = skipWhitespace();
455
+ pendingWork += ws;
456
+ while (pendingWork >= 512) {
457
+ yield 512;
458
+ pendingWork -= 512;
459
+ }
460
+ const ch = source.charCodeAt(offset);
461
+ if (ch === 47 || ch === 62)
403
462
  break;
404
- if (offset === beforeSpace)
463
+ if (ws === 0)
405
464
  invalid("attributes require whitespace");
406
- const attribute = yield* readName();
465
+ const [attribute, attrPrefix, attrLocal, wAttr] = scanName();
466
+ pendingWork += wAttr;
467
+ while (pendingWork >= 512) {
468
+ yield 512;
469
+ pendingWork -= 512;
470
+ }
407
471
  if (attributes.has(attribute))
408
472
  invalid("duplicate attribute");
409
473
  if (++attributeCount > maxAttributes)
410
474
  throw new XmlLimitError("maxAttributes", "XML attribute limit exceeded");
411
475
  if (attributes.size >= maxAttributesPerElement)
412
476
  throw new XmlLimitError("maxAttributesPerElement", "XML attribute limit exceeded");
413
- yield* whitespace();
477
+ pendingWork += skipWhitespace();
414
478
  if (source[offset++] !== "=")
415
479
  invalid("missing attribute equals");
416
- yield* whitespace();
480
+ pendingWork += skipWhitespace();
417
481
  const quote = source[offset++];
418
482
  if (quote !== '"' && quote !== "'")
419
483
  invalid("unquoted attribute");
420
- const end = yield* find(source, quote, offset);
484
+ const end = source.indexOf(quote, offset);
485
+ const scanLen = (end < 0 ? source.length : end) - offset;
486
+ pendingWork += scanLen;
487
+ while (pendingWork >= 512) {
488
+ yield 512;
489
+ pendingWork -= 512;
490
+ }
421
491
  if (end < 0)
422
492
  invalid("unterminated attribute");
423
493
  const raw = source.slice(offset, end);
424
- if ((yield* find(raw, "<", 0)) >= 0)
494
+ pendingWork += raw.length * 2;
495
+ while (pendingWork >= 512) {
496
+ yield 512;
497
+ pendingWork -= 512;
498
+ }
499
+ if (raw.indexOf("<") >= 0)
425
500
  invalid("less-than in attribute");
426
- let normalized = "";
427
- for (let index = 0; index < raw.length; index++) {
428
- const character = raw[index];
429
- normalized += character === "\t" || character === "\n" || character === "\r" ? " " : character;
430
- if ((index + 1) % 512 === 0)
431
- yield 512;
501
+ const normalized = /[\t\n\r]/.test(raw) ? raw.replace(/[\t\n\r]/g, " ") : raw;
502
+ const value = normalized.indexOf("&") < 0 ? (pendingWork += normalized.length, normalized) : yield* entities(normalized);
503
+ while (pendingWork >= 512) {
504
+ yield 512;
505
+ pendingWork -= 512;
432
506
  }
433
- if (raw.length % 512)
434
- yield raw.length % 512;
435
- const value = yield* entities(normalized);
436
507
  admitText(value);
437
508
  attributes.set(attribute, value);
509
+ attrMeta.push([attribute, attrPrefix, attrLocal]);
438
510
  offset = end + 1;
439
511
  if (attribute === "xmlns" || attribute.startsWith("xmlns:")) {
440
- const prefix = attribute === "xmlns" ? "" : attribute.slice(6);
441
- if (prefix === "xmlns" || value === xmlnsNamespace
442
- || (prefix === "xml") !== (value === xmlNamespace)
443
- || (prefix !== "" && value === ""))
512
+ const nsPrefix = attribute === "xmlns" ? "" : attribute.slice(6);
513
+ if (nsPrefix === "xmlns" || value === xmlnsNamespace
514
+ || (nsPrefix === "xml") !== (value === xmlNamespace)
515
+ || (nsPrefix !== "" && value === ""))
444
516
  invalid("invalid namespace binding");
445
517
  if (!ownsNamespaces) {
446
518
  const copy = new Map();
447
519
  for (const [key, uri] of namespaces) {
448
520
  copy.set(key, uri);
449
- yield 1;
521
+ pendingWork += 1;
450
522
  }
451
523
  namespaces = copy;
452
524
  ownsNamespaces = true;
453
525
  }
454
- namespaces.set(prefix, value);
526
+ namespaces.set(nsPrefix, value);
455
527
  if (namespaces.size > maxNamespaces)
456
528
  throw new XmlLimitError("maxNamespaces", "XML namespace scope limit exceeded");
457
529
  }
458
530
  }
459
- const expanded = new Set();
460
- for (const attribute of attributes.keys()) {
461
- if (attribute === "xmlns" || attribute.startsWith("xmlns:"))
462
- continue;
463
- const [prefix, localName] = yield* qualifiedName(attribute);
464
- if (prefix && !namespaces.has(prefix))
465
- invalid("unbound attribute prefix");
466
- const key = JSON.stringify([prefix ? namespaces.get(prefix) : "", localName]);
467
- if (expanded.has(key))
468
- invalid("duplicate expanded attribute");
469
- expanded.add(key);
531
+ if (attrMeta.length > 0) {
532
+ const expanded = attrMeta.length > 1 ? new Set() : undefined;
533
+ for (let i = 0; i < attrMeta.length; i++) {
534
+ const [attribute, attrPrefix, attrLocal] = attrMeta[i];
535
+ if (attribute === "xmlns" || attribute.startsWith("xmlns:"))
536
+ continue;
537
+ pendingWork += attribute.length;
538
+ if (attrPrefix && !namespaces.has(attrPrefix))
539
+ invalid("unbound attribute prefix");
540
+ if (expanded) {
541
+ const key = `${attrPrefix ? namespaces.get(attrPrefix) : ""}\0${attrLocal}`;
542
+ if (expanded.has(key))
543
+ invalid("duplicate expanded attribute");
544
+ expanded.add(key);
545
+ }
546
+ }
547
+ }
548
+ pendingWork += name.length;
549
+ while (pendingWork >= 512) {
550
+ yield 512;
551
+ pendingWork -= 512;
470
552
  }
471
- const [prefix, localName] = yield* qualifiedName(name);
472
553
  if (prefix === "xmlns" || (prefix && !namespaces.has(prefix)))
473
554
  invalid("unbound element prefix");
474
555
  if (++nodes > maxNodes)
@@ -479,12 +560,19 @@ export function* parseXmlSteps(input, limits = {}) {
479
560
  limits.onElement?.({ name, namespace, localName }, stack.at(-1)?.element, stack.length + 1);
480
561
  admitContent();
481
562
  const retainedAttributes = [];
482
- for (const [attribute, value] of retainContent ? attributes : []) {
483
- const [prefix, localName] = yield* qualifiedName(attribute);
484
- admitContent();
485
- retainedAttributes.push({ name: attribute, localName,
486
- namespace: attribute === "xmlns" || prefix === "xmlns" ? xmlnsNamespace : prefix ? namespaces.get(prefix) : "", value });
487
- yield 1;
563
+ if (retainContent) {
564
+ for (let i = 0; i < attrMeta.length; i++) {
565
+ const [attribute, attrPrefix, attrLocal] = attrMeta[i];
566
+ const value = attributes.get(attribute);
567
+ admitContent();
568
+ retainedAttributes.push({ name: attribute, localName: attrLocal,
569
+ namespace: attribute === "xmlns" || attrPrefix === "xmlns" ? xmlnsNamespace : attrPrefix ? namespaces.get(attrPrefix) : "", value });
570
+ pendingWork += attribute.length + 1;
571
+ }
572
+ }
573
+ while (pendingWork >= 512) {
574
+ yield 512;
575
+ pendingWork -= 512;
488
576
  }
489
577
  const content = retainContent ? [] : undefined;
490
578
  const element = { kind: "element", name, namespace, localName, children: [], text: "", content: content ?? emptyContent, attributes: retainContent ? retainedAttributes : emptyAttributes, namespaces: retainContent ? namespaces : emptyNamespaces, ...(root === undefined && retainContent ? { prolog, epilog, ...(declaration === undefined ? {} : { declaration }) } : {}) };
@@ -506,6 +594,10 @@ export function* parseXmlSteps(input, limits = {}) {
506
594
  stack.push({ element, content, name, namespaces });
507
595
  }
508
596
  }
597
+ if (pendingWork > 0) {
598
+ yield pendingWork;
599
+ pendingWork = 0;
600
+ }
509
601
  if (stack.length || !root)
510
602
  invalid("incomplete document");
511
603
  return root;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poe-platform/safe-fs",
3
- "version": "0.1.734",
3
+ "version": "0.1.736",
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": {