@poe-platform/safe-fs 0.1.727 → 0.1.729

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 (58) hide show
  1. package/README.md +15 -8
  2. package/dist/safe-fs/contracts/errors.js +8 -1
  3. package/dist/safe-fs/contracts/filesystem.d.ts +68 -2
  4. package/dist/safe-fs/contracts/io.js +210 -28
  5. package/dist/safe-fs/contracts/virtual-path.d.ts +3 -1
  6. package/dist/safe-fs/contracts/virtual-path.js +20 -1
  7. package/dist/safe-fs/core.d.ts +1 -0
  8. package/dist/safe-fs/core.js +1 -0
  9. package/dist/safe-fs/fs/capabilities.d.ts +1 -1
  10. package/dist/safe-fs/fs/capabilities.js +59 -21
  11. package/dist/safe-fs/fs/conditional-chmod.d.ts +7 -0
  12. package/dist/safe-fs/fs/conditional-chmod.js +19 -0
  13. package/dist/safe-fs/fs/descriptor.js +51 -25
  14. package/dist/safe-fs/fs/devices/index.d.ts +7 -2
  15. package/dist/safe-fs/fs/devices/index.js +210 -29
  16. package/dist/safe-fs/fs/devices/path.d.ts +3 -1
  17. package/dist/safe-fs/fs/devices/path.js +70 -12
  18. package/dist/safe-fs/fs/memory/index.d.ts +70 -48
  19. package/dist/safe-fs/fs/memory/index.js +1595 -244
  20. package/dist/safe-fs/fs/memory/ledger.d.ts +8 -1
  21. package/dist/safe-fs/fs/memory/ledger.js +46 -6
  22. package/dist/safe-fs/fs/memory/missing-target.js +21 -1
  23. package/dist/safe-fs/fs/mount/index.d.ts +11 -3
  24. package/dist/safe-fs/fs/mount/index.js +326 -15
  25. package/dist/safe-fs/fs/object-publication/index.js +79 -3
  26. package/dist/safe-fs/fs/overlay/index.d.ts +2 -2
  27. package/dist/safe-fs/fs/overlay/index.js +27 -5
  28. package/dist/safe-fs/fs/overlay/memory-publication.d.ts +2 -1
  29. package/dist/safe-fs/fs/overlay/memory-publication.js +39 -2
  30. package/dist/safe-fs/fs/quota/index.js +5 -5
  31. package/dist/safe-fs/fs/readonly/index.d.ts +1 -0
  32. package/dist/safe-fs/fs/readonly/index.js +13 -0
  33. package/dist/safe-fs/fs/real/index.d.ts +6 -2
  34. package/dist/safe-fs/fs/real/index.js +78 -8
  35. package/dist/safe-fs/fs/s3/filesystem.d.ts +3 -0
  36. package/dist/safe-fs/fs/s3/filesystem.js +57 -25
  37. package/dist/safe-fs/fs/s3/http/transport.js +20 -3
  38. package/dist/safe-fs/fs/s3/mock.js +19 -2
  39. package/dist/safe-fs/fs/s3/namespace.js +8 -2
  40. package/dist/safe-fs/fs/s3/registry.js +2 -0
  41. package/dist/safe-fs/fs/scoped.d.ts +1 -0
  42. package/dist/safe-fs/fs/scoped.js +261 -74
  43. package/dist/safe-fs/fs/staging-ancestry.d.ts +8 -0
  44. package/dist/safe-fs/fs/staging-ancestry.js +115 -0
  45. package/dist/safe-fs/fs/staging-cleanup.d.ts +5 -0
  46. package/dist/safe-fs/fs/staging-cleanup.js +63 -0
  47. package/dist/safe-fs/fs/webdav/webdav.js +7 -5
  48. package/dist/safe-fs/platform/browser.d.ts +5 -0
  49. package/dist/safe-fs/platform/browser.js +17 -0
  50. package/dist/safe-fs/platform/node.d.ts +5 -0
  51. package/dist/safe-fs/platform/node.js +28 -0
  52. package/dist/safe-fs/platform/transport-budget.d.ts +7 -0
  53. package/dist/safe-fs/platform/transport-budget.js +4 -0
  54. package/dist/safe-fs/testing/object-io-metrics.d.ts +3 -0
  55. package/dist/safe-fs/testing/object-io-metrics.js +29 -0
  56. package/dist/safe-fs/testing/object-publication.d.ts +3 -1
  57. package/dist/safe-fs/testing/object-publication.js +2 -2
  58. package/package.json +1 -1
package/README.md CHANGED
@@ -98,6 +98,13 @@ creation and updates without inode numbers, staging directories or permission
98
98
  APIs. Read the [conditional publication contract](src/contracts/conditional-publication.md)
99
99
  before implementing the host operation; ordinary writes do not provide it.
100
100
 
101
+ For temporary staging that must be cleaned up after its parent moves, adapters
102
+ advertising `retainedStagingCleanup` accept `createStagedFile(..., { parent,
103
+ retainCleanup: true })`. Use the returned `staging.cleanup.remove()` and always
104
+ call `staging.cleanup.close()` in `finally`. Memory and its supported wrappers
105
+ retain only the owned staging entries; replacement entries remain protected.
106
+ See the [staging contract](src/contracts/filesystem.md#atomic-owned-staging).
107
+
101
108
  | Backend or wrapper | Use it for |
102
109
  | --- | --- |
103
110
  | `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 |
@@ -231,7 +238,7 @@ There are no package environment variables, implicit credentials, or automatic `
231
238
  | Memory / read-only | Memory accepts independent optional `maxFileBytes`, `maxRetainedBytes`, `maxMetadataUnits` and `maxBytes` quotas, all unlimited by default. Read-only takes the backing filesystem, without an options object. |
232
239
  | Real | Required `root`: existing absolute host directory; the constructor/factory also accepts the root string directly. |
233
240
  | Mount | Required `root`: fallback filesystem. `mounts` defaults to `{}` and maps absolute virtual paths to filesystems. |
234
- | Overlay | Required `upper` and `lower`; `maxBufferBytes` is unlimited unless configured. |
241
+ | Overlay | Required `upper` and `lower`; `maxBufferBytes` is unlimited unless configured; it and per-read `maxBytes` accept explicit `Infinity`. |
235
242
  | Quota | `withFileSystemQuota` requires a nonnegative safe-integer `maxBytes`. It serializes mutations and counts files, symlinks, copies, hard links, truncation, and streaming writes. |
236
243
  | Node bridge | `cwd` defaults to `/`, must be an absolute virtual path; optional lifetime `signal` and trusted `readFileMaxBytes` cap forwarded to backend reads before copy/decode. |
237
244
  | Portable bridge | Same `cwd`, `signal` and `readFileMaxBytes`, plus required `codec` with `isEncoding`, `encode`, and `decode` functions. |
@@ -251,9 +258,9 @@ Every raw filesystem operation accepts an optional `signal`. Additional fields a
251
258
  | `copyFile` | `exclusive` (default false) |
252
259
  | `readStream` | `start` (default 0), `endExclusive` (default end of file), `chunkSize` (built-in default 64 KiB) |
253
260
 
254
- `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). 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).
261
+ `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).
255
262
 
256
- `collectBytes(source, { maxBytes, maxMemoryBytes, signal })` snapshots streamed chunks into one growing buffer. `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.
263
+ `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.
257
264
 
258
265
  <details>
259
266
  <summary>S3 filesystem and HTTP transport options</summary>
@@ -273,12 +280,12 @@ For in-memory S3 simulations, `new MockS3Client({ buckets, pageSize?, now?, auth
273
280
  | `maxReadBytes` | Unlimited unless configured |
274
281
  | `maxStreamBytes` | Unlimited unless configured |
275
282
  | `maxListEntries` | Unlimited unless configured |
276
- | `removalLimits.maxRequests` | 32 transport calls per `rm`, including lookup, listing, and deletes |
277
- | `removalLimits.maxListEntries` | 32 returned listing entries in aggregate per `rm`, including lookup |
278
- | `removalLimits.maxDeleteObjects` | 16 objects per `rm` |
283
+ | `removalLimits.maxRequests` | Unlimited unless configured; transport calls per `rm`, including lookup, listing, and deletes |
284
+ | `removalLimits.maxListEntries` | Unlimited unless configured; returned listing entries in aggregate per `rm`, including lookup |
285
+ | `removalLimits.maxDeleteObjects` | Unlimited unless configured; objects per `rm` |
279
286
  | `compareEntry` | Optional trusted backing-identity callback |
280
287
 
281
- Removal limits apply even when shell filesystem-call limits admit a recursive `rm` as one operation. Each limit accepts a positive safe integer. Traversal stops at the listing/request cap and rejects with `EFBIG`; all delete requests must fit the remaining request budget before the first mutation. Nonrecursive removal checks for children using pages of at most two entries. Configure larger `removalLimits` only where the deployment can afford the corresponding work. These limits count adapter transport calls; retries inside a supplied transport need their own limit. Remote failures or concurrent writers can still cause partial deletion after preflight.
288
+ Removal limits apply even when shell filesystem-call limits admit a recursive `rm` as one operation. Removal limits default to unlimited and accept a positive safe integer or `Infinity`. Read, stream, listing, and request limits also accept explicit `Infinity`. Traversal stops at the listing/request cap and rejects with `EFBIG`; all delete requests must fit the remaining request budget before the first mutation. Nonrecursive removal checks for children using pages of at most two entries. Configure finite `removalLimits` to bound the work in deployments that need them. These limits count adapter transport calls; retries inside a supplied transport need their own limit. Remote failures or concurrent writers can still cause partial deletion after preflight.
282
289
 
283
290
  For larger trees, a trusted integration can process one bounded batch per request/job using its explicitly supplied transport. This example uses at most 17 transport calls and retains at most 16 summaries; repeat in a later job until `done`. The prefix must come from trusted deployment configuration, include the filesystem's configured prefix, and end in `/`. This deliberately bypasses filesystem collision checks and deletes directory markers as well as files; serialize it with writers when complete removal is required.
284
291
 
@@ -339,7 +346,7 @@ Each batch lists from the beginning because previous keys have been deleted; it
339
346
 
340
347
  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.
341
348
 
342
- 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.
349
+ 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.
343
350
 
344
351
  See the [binding types](src/fs/webdav/webdav.ts) before implementing atomic directory removal. A recursive WebDAV DELETE does not satisfy that contract.
345
352
 
@@ -44,7 +44,14 @@ export class FsError extends Error {
44
44
  const operation = options.syscall ? `, ${options.syscall}` : "";
45
45
  const path = options.path === undefined ? "" : ` '${options.path}'`;
46
46
  const destination = options.dest === undefined ? "" : ` -> '${options.dest}'`;
47
- super(`${code}: ${options.message ?? descriptions[code]}${operation}${path}${destination}`, options);
47
+ const prevStackLimit = Error.stackTraceLimit;
48
+ Error.stackTraceLimit = 0;
49
+ try {
50
+ super(`${code}: ${options.message ?? descriptions[code]}${operation}${path}${destination}`, options);
51
+ }
52
+ finally {
53
+ Error.stackTraceLimit = prevStackLimit;
54
+ }
48
55
  this.name = "FsError";
49
56
  this.code = code;
50
57
  this.errno = errno;
@@ -68,16 +68,29 @@ export interface FileSystemCapabilities {
68
68
  readonly symlinks?: boolean;
69
69
  readonly hardlinks?: boolean;
70
70
  readonly permissions?: boolean;
71
+ /** Enforces chmod target/ancestry receipts and a synchronous guard at the metadata commit.
72
+ * Host adapters retain their documented external-filesystem isolation requirements. */
73
+ readonly conditionalChmod?: boolean;
71
74
  readonly timestamps?: boolean;
72
75
  readonly atomicRename?: boolean;
73
76
  /** Owned staging serialized within a trusted host; requires external tree isolation. */
74
77
  readonly trustedOwnedStaging?: boolean;
75
78
  readonly atomicFileStaging?: boolean;
79
+ /** Creates a cleanup handle bound to owned staging entries, independent of ancestor paths. */
80
+ readonly retainedStagingCleanup?: boolean;
76
81
  /** Atomically verifies every supplied root-to-parent directory identity at publication. */
77
82
  readonly atomicStagingAncestry?: boolean;
83
+ /** Prepares directory guards that validate without yielding or mutating state. */
84
+ readonly synchronousDirectoryValidation?: boolean;
85
+ /** Captures a followed destination path with a synchronous commit validator. */
86
+ readonly synchronousStagingResolution?: boolean;
87
+ /** Invokes commitGuard in the same synchronous section as staged replacement. */
88
+ readonly guardedStagingPublication?: boolean;
78
89
  readonly atomicFilePublication?: boolean;
79
90
  readonly atomicFileMutation?: boolean;
80
91
  readonly atomicEntryRemoval?: boolean;
92
+ /** Returns an atomic post-unlink inode snapshot when explicitly requested. */
93
+ readonly atomicEntryRemovalReceipt?: boolean;
81
94
  readonly atomicTreeRemoval?: boolean;
82
95
  readonly atomicDirectoryMetadata?: boolean;
83
96
  readonly atomicRenameNoReplace?: boolean;
@@ -93,10 +106,24 @@ export interface FileSystemCapabilities {
93
106
  export interface FsOptions {
94
107
  readonly signal?: AbortSignal;
95
108
  }
109
+ export interface ChmodOptions extends FsOptions {
110
+ /** Conditional fields require conditionalChmod and a complete parent/target/ancestry receipt. */
111
+ readonly parent?: FileStat;
112
+ readonly expected?: FileStat;
113
+ readonly ancestors?: readonly FileStagingEntry[];
114
+ /** Trusted, mutation-free validation; must return literal true synchronously. */
115
+ readonly commitGuard?: () => true;
116
+ }
96
117
  export interface OpenReadFileOptions extends FsOptions {
97
118
  readonly allowDirectory?: boolean;
98
119
  }
99
120
  export interface CapabilityQueryOptions extends OpenReadFileOptions {
121
+ /** Include complete wrapper ancestry when checking conditional chmod support. */
122
+ readonly conditionalChmod?: boolean;
123
+ /** Inspect complete ancestry for staged publication, including other mounts.
124
+ * Omission retains the ordinary per-target query and its acquisition intent. */
125
+ readonly stagingAncestry?: boolean;
126
+ readonly stagingResolution?: boolean;
100
127
  readonly create?: boolean;
101
128
  readonly creation?: OpenFileOptions["creation"];
102
129
  }
@@ -182,20 +209,46 @@ export interface ConditionalWriteFileOptions extends FsOptions {
182
209
  readonly expected: FileStat | null;
183
210
  readonly append?: boolean;
184
211
  readonly mode?: number;
212
+ readonly atimeMs?: number;
213
+ readonly mtimeMs?: number;
185
214
  }
186
215
  export interface ConditionalRemoveEntryOptions extends FsOptions {
187
216
  readonly parent: FileStat;
188
217
  readonly expected: FileStat;
189
218
  }
219
+ export interface ConditionalRemoveEntryReceiptOptions extends ConditionalRemoveEntryOptions {
220
+ readonly returnRemainingStat?: boolean | undefined;
221
+ }
190
222
  export type ConditionalRemoveFileOptions = ConditionalRemoveEntryOptions;
191
223
  export interface FileStagingEntry {
192
224
  readonly path: string;
193
225
  readonly stat: FileStat;
194
226
  }
227
+ export interface FileResolutionStep extends FileStagingEntry {
228
+ readonly linkTarget?: string;
229
+ }
230
+ export interface FileStagingResolution {
231
+ readonly path: string;
232
+ readonly parent: FileStat;
233
+ readonly destination: FileStat | null;
234
+ readonly ancestors: readonly FileStagingEntry[];
235
+ readonly traversed: readonly FileResolutionStep[];
236
+ readonly validate: () => true;
237
+ }
238
+ /** Ownership acquired with staging creation. remove is one-shot with shared
239
+ * completion; close drains admitted removal and releases without new mutation.
240
+ * Both release retained resources even after a failed removal. Call close when
241
+ * abandoning the staging receipt. An explicit cleanup signal is independent of
242
+ * the creation signal; scoped cleanup consumes the cleanup operation budget. */
243
+ export interface FileStagingCleanup {
244
+ remove(options?: FsOptions): Promise<void>;
245
+ close(): Promise<void>;
246
+ }
195
247
  export interface FileStaging {
196
248
  readonly parent: FileStagingEntry;
197
249
  readonly directory: FileStagingEntry;
198
250
  readonly file: FileStagingEntry;
251
+ readonly cleanup?: FileStagingCleanup;
199
252
  }
200
253
  export type StagedFileContent = {
201
254
  readonly type: "file";
@@ -205,6 +258,8 @@ export type StagedFileContent = {
205
258
  readonly target: string;
206
259
  };
207
260
  export interface CreateStagedFileOptions extends FsOptions {
261
+ /** Requires retainedStagingCleanup; unsupported backends reject before creation. */
262
+ readonly retainCleanup?: boolean;
208
263
  readonly parent: FileStat;
209
264
  readonly mode?: number;
210
265
  readonly atimeMs?: number;
@@ -212,6 +267,9 @@ export interface CreateStagedFileOptions extends FsOptions {
212
267
  }
213
268
  export interface PublishStagedFileOptions extends FsOptions {
214
269
  readonly ancestors?: readonly FileStagingEntry[];
270
+ /** Trusted, mutation-free validation. Must return literal true synchronously;
271
+ * a promise is not acceptance. Requires guardedStagingPublication. */
272
+ readonly commitGuard?: () => true;
215
273
  readonly parent: FileStat;
216
274
  readonly destination: FileStat | null;
217
275
  }
@@ -241,7 +299,10 @@ export interface FileSystem {
241
299
  * Failures before commit preserve the destination. No stat/write fallback. */
242
300
  publishFileConditional?(path: string, source: ByteSource, options: ConditionalFilePublicationOptions): Promise<FileStat>;
243
301
  writeFileConditional?(path: string, data: Uint8Array, options: ConditionalWriteFileOptions): Promise<FileStat>;
244
- removeEntryConditional?(path: string, options: ConditionalRemoveEntryOptions): Promise<void>;
302
+ removeEntryConditional?(path: string, options: ConditionalRemoveEntryOptions & {
303
+ readonly returnRemainingStat?: false | undefined;
304
+ }): Promise<void>;
305
+ removeEntryConditional?(path: string, options: ConditionalRemoveEntryReceiptOptions): Promise<void | FileStat>;
245
306
  removeTreeConditional?(path: string, options: ConditionalRemoveEntryOptions): Promise<void>;
246
307
  removeFileConditional?(path: string, options: ConditionalRemoveFileOptions): Promise<void>;
247
308
  readonly capabilities: FileSystemCapabilities;
@@ -249,6 +310,11 @@ export interface FileSystem {
249
310
  prepareDirectory?(path: string, options: PrepareDirectoryOptions): Promise<FileStat>;
250
311
  createStagedFile?(directoryPath: string, name: string, content: StagedFileContent, options: CreateStagedFileOptions): Promise<FileStaging>;
251
312
  publishStagedFile?(staging: FileStaging, destination: string, options: PublishStagedFileOptions): Promise<void>;
313
+ /** Admit a complete canonical root-to-directory prefix and prepare a guard.
314
+ * The returned guard checks current identities/search access synchronously;
315
+ * preparation is not validation, and the guard's result is not an async lease. */
316
+ prepareDirectoryAncestry?(ancestors: readonly FileStagingEntry[], options?: FsOptions): Promise<() => true>;
317
+ prepareStagingResolution?(path: string, options?: FsOptions): Promise<FileStagingResolution>;
252
318
  removeStagedFile?(staging: FileStaging, options?: FsOptions): Promise<void>;
253
319
  openReadFile?(path: string, options?: OpenReadFileOptions): Promise<FileReadHandle>;
254
320
  openResizeFile?(path: string, options?: OpenResizeFileOptions): Promise<FileResizeHandle>;
@@ -275,7 +341,7 @@ export interface FileSystem {
275
341
  readlink?(path: string, options?: FsOptions): Promise<string>;
276
342
  symlink?(target: string, path: string, options?: FsOptions): Promise<void>;
277
343
  link?(existingPath: string, newPath: string, options?: FsOptions): Promise<void>;
278
- chmod?(path: string, mode: number, options?: FsOptions): Promise<void>;
344
+ chmod?(path: string, mode: number, options?: ChmodOptions): Promise<void>;
279
345
  utimes?(path: string, atimeMs: number, mtimeMs: number, options?: FsOptions): Promise<void>;
280
346
  truncate?(path: string, length?: number, options?: FsOptions): Promise<void>;
281
347
  readStream?(path: string, options?: ReadStreamOptions): ByteSource;
@@ -1,23 +1,75 @@
1
1
  import { FsError } from "./errors.js";
2
2
  import { finishCleanup } from "./cleanup.js";
3
3
  import { platform } from "#safe-fs-platform";
4
+ let sharedTextEncoder;
5
+ const EMPTY_BYTES = new Uint8Array(0);
6
+ const DONE_RESULT = Object.freeze({ done: true, value: undefined });
7
+ const RESOLVED_DONE = Promise.resolve(DONE_RESULT);
8
+ const readBytesSignal = Symbol.for("safe-fs.readBytesSignal");
9
+ const EMPTY_BYTE_ITERATOR = Object.freeze({
10
+ [Symbol.asyncIterator]() { return this; },
11
+ tryNextSync() { return DONE_RESULT; },
12
+ next() { return RESOLVED_DONE; },
13
+ return() { return RESOLVED_DONE; },
14
+ });
15
+ const EMPTY_BYTE_SOURCE = Object.freeze({
16
+ [Symbol.asyncIterator]() { return EMPTY_BYTE_ITERATOR; },
17
+ });
18
+ const noop = () => { };
19
+ class SingleChunkByteIterator {
20
+ constructor(bytes) {
21
+ this.bytes = bytes;
22
+ this.consumed = false;
23
+ }
24
+ [Symbol.asyncIterator]() {
25
+ return this;
26
+ }
27
+ tryNextSync() {
28
+ if (this.consumed)
29
+ return DONE_RESULT;
30
+ this.consumed = true;
31
+ return { done: false, value: this.bytes };
32
+ }
33
+ next() {
34
+ if (this.consumed)
35
+ return RESOLVED_DONE;
36
+ this.consumed = true;
37
+ return Promise.resolve({ done: false, value: this.bytes });
38
+ }
39
+ return() {
40
+ this.consumed = true;
41
+ return RESOLVED_DONE;
42
+ }
43
+ }
44
+ class SingleChunkByteSource {
45
+ constructor(bytes) {
46
+ this.bytes = bytes;
47
+ }
48
+ [Symbol.asyncIterator]() {
49
+ return new SingleChunkByteIterator(this.bytes);
50
+ }
51
+ }
4
52
  export function toByteSource(input) {
5
53
  if (typeof input !== "string" && !(input instanceof Uint8Array)) {
6
54
  throw new TypeError("Byte source input must be a string or Uint8Array");
7
55
  }
8
- const bytes = typeof input === "string" ? new TextEncoder().encode(input) : new Uint8Array(input);
9
- return (async function* () {
10
- if (bytes.byteLength > 0)
11
- yield bytes;
12
- })();
56
+ if (typeof input === "string") {
57
+ if (input.length === 0)
58
+ return EMPTY_BYTE_SOURCE;
59
+ }
60
+ else if (input.byteLength === 0) {
61
+ return EMPTY_BYTE_SOURCE;
62
+ }
63
+ const bytes = typeof input === "string" ? (sharedTextEncoder ??= new TextEncoder()).encode(input) : new Uint8Array(input);
64
+ return new SingleChunkByteSource(bytes);
13
65
  }
14
66
  let activeCollectionBytes = 0;
15
67
  export async function collectBytes(source, options) {
16
68
  if (options.maxBytes !== undefined && options.maxBytes !== Infinity && (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0)) {
17
69
  throw new RangeError("maxBytes must be a nonnegative safe integer");
18
70
  }
19
- if (options.maxMemoryBytes !== undefined && (!Number.isSafeInteger(options.maxMemoryBytes) || options.maxMemoryBytes < 0)) {
20
- throw new RangeError("maxMemoryBytes must be a nonnegative safe integer");
71
+ if (options.maxMemoryBytes !== undefined && options.maxMemoryBytes !== Infinity && (!Number.isSafeInteger(options.maxMemoryBytes) || options.maxMemoryBytes < 0)) {
72
+ throw new RangeError("maxMemoryBytes must be a nonnegative safe integer or Infinity");
21
73
  }
22
74
  const memoryLimit = options.maxMemoryBytes ?? Infinity;
23
75
  let reserved = 0;
@@ -91,34 +143,164 @@ async function abortable(operation, signal) {
91
143
  }
92
144
  });
93
145
  }
94
- export async function* readBytes(source, signal) {
95
- signal?.throwIfAborted();
96
- const iterator = source[Symbol.asyncIterator]();
97
- let finished = false;
98
- let failed = false;
99
- try {
100
- while (true) {
101
- const result = await abortable(() => iterator.next(), signal);
146
+ class ReadBytesGenerator {
147
+ constructor(source, signal) {
148
+ this.source = source;
149
+ this.abortSignal = signal;
150
+ this.iterator = undefined;
151
+ this.nativeAbort = false;
152
+ this.finished = false;
153
+ this.closing = undefined;
154
+ this.turn = undefined;
155
+ this.readingSync = false;
156
+ this.syncFailure = undefined;
157
+ }
158
+ get [readBytesSignal]() {
159
+ return this.abortSignal;
160
+ }
161
+ [Symbol.asyncIterator]() {
162
+ return this;
163
+ }
164
+ _schedule(action) {
165
+ const previous = this.turn;
166
+ let release;
167
+ const reserved = new Promise(resolve => { release = resolve; });
168
+ // Reserve before calling a producer, which may synchronously reenter us.
169
+ this.turn = reserved;
170
+ const result = previous
171
+ ? previous.then(action)
172
+ : this.readingSync
173
+ ? Promise.resolve().then(action)
174
+ : action();
175
+ const finish = () => {
176
+ if (this.turn === reserved)
177
+ this.turn = undefined;
178
+ release();
179
+ };
180
+ void result.then(finish, finish);
181
+ return result;
182
+ }
183
+ _ensureIterator() {
184
+ let it = this.iterator;
185
+ if (!it) {
186
+ const signal = this.abortSignal;
187
+ signal?.throwIfAborted();
188
+ it = this.source[Symbol.asyncIterator]();
189
+ this.iterator = it;
190
+ this.nativeAbort = signal !== undefined && it.abortSignal === signal;
191
+ }
192
+ return it;
193
+ }
194
+ async _cleanupIterator(failed) {
195
+ const signal = this.abortSignal;
196
+ if (!this.finished && this.iterator?.return) {
197
+ this.finished = true;
198
+ const it = this.iterator;
199
+ const cleanup = Promise.resolve().then(() => it.return());
200
+ if (signal?.aborted)
201
+ void cleanup.catch(noop);
202
+ else
203
+ this.closing = abortable(() => cleanup, signal).then(noop);
204
+ }
205
+ else {
206
+ this.finished = true;
207
+ }
208
+ if (this.closing) {
209
+ const pending = this.closing;
210
+ try {
211
+ await finishCleanup(() => pending, failed);
212
+ }
213
+ finally {
214
+ if (this.closing === pending)
215
+ this.closing = undefined;
216
+ }
217
+ }
218
+ }
219
+ tryNextSync() {
220
+ if (this.turn || this.readingSync || this.syncFailure)
221
+ return undefined;
222
+ if (this.finished)
223
+ return DONE_RESULT;
224
+ this.readingSync = true;
225
+ try {
226
+ const it = this._ensureIterator();
227
+ if (typeof it.tryNextSync !== "function")
228
+ return undefined;
229
+ const signal = this.abortSignal;
230
+ signal?.throwIfAborted();
231
+ const syncResult = it.tryNextSync();
232
+ signal?.throwIfAborted();
233
+ if (syncResult === undefined)
234
+ return undefined;
235
+ if (syncResult.done) {
236
+ this.finished = true;
237
+ return DONE_RESULT;
238
+ }
239
+ if (!(syncResult.value instanceof Uint8Array))
240
+ throw new TypeError("Byte sources must yield Uint8Array chunks");
241
+ return syncResult;
242
+ }
243
+ catch (error) {
244
+ this.syncFailure = { reason: error };
245
+ return undefined;
246
+ }
247
+ finally {
248
+ this.readingSync = false;
249
+ }
250
+ }
251
+ async _runNext() {
252
+ if (this.finished)
253
+ return DONE_RESULT;
254
+ try {
255
+ if (this.syncFailure) {
256
+ const { reason } = this.syncFailure;
257
+ this.syncFailure = undefined;
258
+ throw reason;
259
+ }
260
+ const it = this._ensureIterator();
261
+ const signal = this.abortSignal;
262
+ signal?.throwIfAborted();
263
+ const syncResult = typeof it.tryNextSync === "function" ? it.tryNextSync() : undefined;
264
+ const result = syncResult ?? (this.nativeAbort ? await it.next() : await abortable(() => it.next(), signal));
265
+ signal?.throwIfAborted();
102
266
  if (result.done) {
103
- finished = true;
104
- return;
267
+ this.finished = true;
268
+ return DONE_RESULT;
105
269
  }
106
270
  if (!(result.value instanceof Uint8Array))
107
271
  throw new TypeError("Byte sources must yield Uint8Array chunks");
108
- yield result.value;
272
+ return result;
273
+ }
274
+ catch (error) {
275
+ await this._cleanupIterator(true);
276
+ throw error;
109
277
  }
110
278
  }
111
- catch (error) {
112
- failed = true;
113
- throw error;
279
+ next() {
280
+ if (this.finished && !this.turn && !this.readingSync && !this.syncFailure && !this.closing)
281
+ return RESOLVED_DONE;
282
+ return this._schedule(() => this._runNext());
114
283
  }
115
- finally {
116
- if (!finished && iterator.return) {
117
- const cleanup = Promise.resolve().then(() => iterator.return());
118
- if (signal?.aborted)
119
- void cleanup.catch(() => { });
120
- else
121
- await finishCleanup(() => abortable(() => cleanup, signal), failed);
284
+ return(value) {
285
+ if ((this.finished || !this.iterator?.return) && !this.turn && !this.readingSync && !this.closing && value === undefined) {
286
+ this.finished = true;
287
+ return RESOLVED_DONE;
122
288
  }
289
+ return this._schedule(async () => {
290
+ await this._cleanupIterator(false);
291
+ return { done: true, value: await value };
292
+ });
293
+ }
294
+ throw(error) {
295
+ return this._schedule(async () => {
296
+ await this._cleanupIterator(true);
297
+ throw error;
298
+ });
299
+ }
300
+ }
301
+ export function readBytes(source, signal) {
302
+ if (signal !== undefined && source[readBytesSignal] === signal) {
303
+ return source;
123
304
  }
305
+ return new ReadBytesGenerator(source, signal);
124
306
  }
@@ -1,4 +1,6 @@
1
- export declare function validatePath(path: string): void;
1
+ export declare const MAX_PATH_BYTES = 65536;
2
+ export declare const MAX_PATH_COMPONENTS = 2048;
3
+ export declare function validatePath(path: string, maxComponents?: number): void;
2
4
  export declare function resolvePath(cwd: string, ...paths: string[]): string;
3
5
  export declare function normalizePath(path: string, cwd?: string): string;
4
6
  export declare function dirname(path: string): string;
@@ -1,8 +1,27 @@
1
1
  import { FsError } from "./errors.js";
2
- export function validatePath(path) {
2
+ export const MAX_PATH_BYTES = 65_536;
3
+ export const MAX_PATH_COMPONENTS = 2048;
4
+ export function validatePath(path, maxComponents = MAX_PATH_COMPONENTS) {
3
5
  if (typeof path !== "string" || path.includes("\0")) {
4
6
  throw new FsError("EINVAL", { syscall: "resolve", message: "paths must be strings without NUL bytes" });
5
7
  }
8
+ if (path.length > MAX_PATH_BYTES) {
9
+ throw new FsError("ENAMETOOLONG", { syscall: "resolve", path });
10
+ }
11
+ const limit = Math.min(maxComponents, MAX_PATH_COMPONENTS);
12
+ let components = 0;
13
+ let inComponent = false;
14
+ for (let i = 0; i < path.length; i++) {
15
+ if (path.charCodeAt(i) === 47) {
16
+ inComponent = false;
17
+ }
18
+ else if (!inComponent) {
19
+ inComponent = true;
20
+ if (++components > limit) {
21
+ throw new FsError("ENAMETOOLONG", { syscall: "resolve", path });
22
+ }
23
+ }
24
+ }
6
25
  }
7
26
  export function resolvePath(cwd, ...paths) {
8
27
  validatePath(cwd);
@@ -23,3 +23,4 @@ export { parseXml, parseXmlSteps, XmlLimitError } from "./xml.js";
23
23
  export type { XmlName, XmlAttribute, XmlContent, XmlElement, XmlLimits } from "./xml.js";
24
24
  export * from "./contracts/object.js";
25
25
  export { ObjectAuthority } from "./fs/object-authority.js";
26
+ export { bindConditionalMutation, type ConditionalMutationBinding } from "./fs/memory/index.js";
@@ -19,3 +19,4 @@ export { compareEntries, registerEntryView } from "./fs/mount/comparison.js";
19
19
  export { parseXml, parseXmlSteps, XmlLimitError } from "./xml.js";
20
20
  export * from "./contracts/object.js";
21
21
  export { ObjectAuthority } from "./fs/object-authority.js";
22
+ export { bindConditionalMutation } from "./fs/memory/index.js";
@@ -7,4 +7,4 @@ export declare function openRetainedResizeFile(filesystem: FileSystem, path: str
7
7
  export declare function readOnlyCapabilities(capabilities: FileSystemCapabilities): FileSystemCapabilities;
8
8
  export declare function quotaCapabilities(capabilities: FileSystemCapabilities): FileSystemCapabilities;
9
9
  export declare function ownedMutationCapabilities(filesystem: FileSystem, capabilities?: FileSystemCapabilities): FileSystemCapabilities;
10
- export declare function requireOwnedMutation(filesystem: FileSystem, path: string, capability: "atomicFilePublication" | "atomicEntryRemoval" | "atomicTreeRemoval" | "atomicFileMutation" | "atomicFileStaging" | "atomicDirectoryMetadata", options: FsOptions, create?: boolean): Promise<void>;
10
+ export declare function requireOwnedMutation(filesystem: FileSystem, path: string, capability: "retainedStagingCleanup" | "atomicFilePublication" | "atomicEntryRemoval" | "atomicEntryRemovalReceipt" | "atomicTreeRemoval" | "atomicFileMutation" | "atomicFileStaging" | "guardedStagingPublication" | "atomicStagingAncestry" | "atomicDirectoryMetadata", options: FsOptions, create?: boolean): Promise<void>;