@poe-platform/safe-fs 0.1.721 → 0.1.723

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 +67 -21
  2. package/dist/safe-fs/bridge/filesystem.d.ts +4 -0
  3. package/dist/safe-fs/bridge/filesystem.js +49 -10
  4. package/dist/safe-fs/bridge/types.d.ts +6 -0
  5. package/dist/safe-fs/contracts/descriptor.d.ts +5 -0
  6. package/dist/safe-fs/contracts/filesystem.d.ts +15 -0
  7. package/dist/safe-fs/contracts/io.d.ts +3 -1
  8. package/dist/safe-fs/contracts/io.js +48 -16
  9. package/dist/safe-fs/core.d.ts +2 -1
  10. package/dist/safe-fs/core.js +1 -1
  11. package/dist/safe-fs/fs/capabilities.js +6 -3
  12. package/dist/safe-fs/fs/descriptor.js +9 -2
  13. package/dist/safe-fs/fs/devices/index.d.ts +1 -0
  14. package/dist/safe-fs/fs/devices/index.js +24 -6
  15. package/dist/safe-fs/fs/memory/index.d.ts +2 -0
  16. package/dist/safe-fs/fs/memory/index.js +95 -4
  17. package/dist/safe-fs/fs/memory/limits.js +6 -4
  18. package/dist/safe-fs/fs/mount/index.d.ts +4 -0
  19. package/dist/safe-fs/fs/mount/index.js +76 -12
  20. package/dist/safe-fs/fs/object-publication/index.d.ts +4 -0
  21. package/dist/safe-fs/fs/object-publication/index.js +9 -8
  22. package/dist/safe-fs/fs/overlay/index.d.ts +4 -2
  23. package/dist/safe-fs/fs/overlay/index.js +92 -36
  24. package/dist/safe-fs/fs/quota/index.js +83 -23
  25. package/dist/safe-fs/fs/readonly/index.js +5 -3
  26. package/dist/safe-fs/fs/real/index.d.ts +21 -1
  27. package/dist/safe-fs/fs/real/index.js +266 -22
  28. package/dist/safe-fs/fs/s3/filesystem.d.ts +8 -0
  29. package/dist/safe-fs/fs/s3/filesystem.js +59 -12
  30. package/dist/safe-fs/fs/s3/http/request.d.ts +1 -1
  31. package/dist/safe-fs/fs/s3/http/request.js +1 -1
  32. package/dist/safe-fs/fs/s3/http/transport.js +8 -6
  33. package/dist/safe-fs/fs/s3/http/xml.d.ts +4 -1
  34. package/dist/safe-fs/fs/s3/http/xml.js +6 -2
  35. package/dist/safe-fs/fs/s3/manifest-admission.d.ts +8 -0
  36. package/dist/safe-fs/fs/s3/manifest-admission.js +171 -0
  37. package/dist/safe-fs/fs/s3/namespace.js +13 -10
  38. package/dist/safe-fs/fs/scoped.d.ts +1 -0
  39. package/dist/safe-fs/fs/scoped.js +54 -35
  40. package/dist/safe-fs/fs/webdav/webdav.d.ts +4 -1
  41. package/dist/safe-fs/fs/webdav/webdav.js +101 -23
  42. package/dist/safe-fs/node/filesystem.d.ts +8 -1
  43. package/dist/safe-fs/node/filesystem.js +7 -1
  44. package/dist/safe-fs/node/host.d.ts +3 -0
  45. package/dist/safe-fs/node/host.js +78 -0
  46. package/dist/safe-fs/node/index.d.ts +2 -1
  47. package/dist/safe-fs/node/index.js +2 -1
  48. package/dist/safe-fs/platform/browser.d.ts +1 -0
  49. package/dist/safe-fs/platform/browser.js +1 -0
  50. package/dist/safe-fs/platform/node.d.ts +1 -0
  51. package/dist/safe-fs/platform/node.js +1 -0
  52. package/dist/safe-fs/python/emscripten.d.ts +1 -0
  53. package/dist/safe-fs/python/emscripten.js +5 -5
  54. package/dist/safe-fs/python/filesystem.js +5 -5
  55. package/dist/safe-fs/python/native.d.ts +1 -0
  56. package/dist/safe-fs/python/native.js +3 -2
  57. package/dist/safe-fs/xml.js +32 -21
  58. package/package.json +1 -1
package/README.md CHANGED
@@ -23,9 +23,11 @@ await fs.appendFile("note.txt", " world", "utf8");
23
23
  console.log(await fs.readFile("note.txt", "utf8"));
24
24
  ```
25
25
 
26
- Output: `hello`, then `hello world`. Nothing touches the host filesystem. Raw adapters exchange `Uint8Array` values; the Node bridge adds strings, encodings, `Buffer` results, and stat predicates. Its `cwd` is both the relative-path base and the confinement boundary.
26
+ Output: `hello`, then `hello world`. Nothing touches the host filesystem. Raw adapters exchange `Uint8Array` values; the Node bridge adds strings, encodings, `Buffer` results, and stat predicates. Its `cwd` is the relative-path base and, by default, the confinement boundary. Set an explicit `root` to use a different boundary, for example `{ cwd: "/work", root: "/" }` to address a complete provider namespace. The bridge supports `unlink` when the provider offers atomic file removal; unsupported methods fail without host fallback. `createHostFileSystem()` provides trusted, unrestricted native host access when a rooted adapter is not desired.
27
27
 
28
- For host storage, use `await createRealFileSystem({ root: "/absolute/existing/directory" })` instead. The root must already exist; virtual `/` maps to that directory. Read the safety boundary below before exposing it to untrusted code.
28
+ For host storage, use `await createRealFileSystem({ root: "/absolute/existing/directory" })` instead. The root must already exist; virtual `/` maps to that directory. ZIP creation and updates can use this adapter's private owned staging in an isolated host tree. Unzip extraction requires atomic ancestry verification, currently supported by MemoryFileSystem; the real adapter and mount views refuse it. Its `trustedOwnedStaging` capability checks original entries before publication and cleanup, and preserves foreign staging children. It does not advertise atomic conditional mutations: keep external writers and other in-flight writes away from the tree during these operations. Read the safety boundary below before exposing it to untrusted code.
29
+
30
+ To create new outputs with Safe Bash's `dos2unix`, `unix2dos`, or compression commands, host adapters need atomic no-replace publication. Supply `createRealFileSystem({ root, renameNoReplace })` only when that callback binds a qualified native primitive such as Linux `renameat2` with `RENAME_NOREPLACE`. It receives resolved absolute host paths and an optional signal in its third argument. Existing destinations must reject `EEXIST` atomically; existence checks followed by rename and copy/delete are insufficient. Without this binding the adapter explicitly refuses those operations. See the [no-replace contract](src/contracts/filesystem.md#atomic-no-replace-rename).
29
31
 
30
32
  ## Entry points and shared identity
31
33
 
@@ -47,6 +49,10 @@ Existing `@poe-platform/safe-js/fs`, `/fs/core`, and `/fs/node` imports remain
47
49
  re-exports, so `FsError` identity is shared; do not copy its implementation or the
48
50
  `FileSystem` declaration. `FileSystem` is an interface, not a runtime class.
49
51
 
52
+ Custom filesystem views can use `registerEntryView(view, async path => ({ filesystem: backing, path }))`
53
+ from `/core` so `compareEntries` recognizes their backing entries in either direction.
54
+ The resolver can also return `readOnly: true`; each view can be registered once.
55
+
50
56
  Legacy `poe-code/safe-fs` imports still use the CLI's bundled runtime. Keep an
51
57
  application's factories, shell, and errors in one import family. At a host-owned
52
58
  legacy adapter boundary, normalize foreign filesystem failures with `toFsError`
@@ -64,6 +70,7 @@ cause. Preserve cancellation/control exceptions separately. Cross-family
64
70
  | Remove an empty directory | Optional `rmdir`; never a recursive-delete fallback |
65
71
  | Links and metadata | Optional `readlink`, `symlink`, `link`, `chmod`, `utimes`, `truncate` |
66
72
  | Stream bytes | Optional `readStream`, `writeStream`, using async iterables of byte chunks |
73
+ | Retain an open file | Optional `open` with positioned I/O and synchronization; memory and real backends support `noFollow: true` to atomically refuse a final symlink |
67
74
  | Publish immutable objects atomically | Optional `publishFileConditional` with opaque identity/version stats and authoritative compare-and-publish |
68
75
  | Compare backing entries | Optional `compareEntry`, returning `same`, `distinct`, or `unknown` |
69
76
 
@@ -76,7 +83,7 @@ writes when the host supplies `store.createStaging`: private externally backed
76
83
  pages keep working memory bounded without publishing the growing file after
77
84
  every write. Conditional publication still occurs at sync/close, and retained
78
85
  readers keep their old versions. Without that optional backend primitive, the
79
- default 8 MiB dirty-page budget still limits unflushed output. See the
86
+ optional dirty-page budget limits unflushed output only when configured. See the
80
87
  [object descriptor and spill contract](src/contracts/object-publication.md) for
81
88
  backend methods, failure semantics and qualification; no provider storage is
82
89
  configured automatically.
@@ -93,7 +100,7 @@ before implementing the host operation; ordinary writes do not provide it.
93
100
 
94
101
  | Backend or wrapper | Use it for |
95
102
  | --- | --- |
96
- | `createMemoryFileSystem()` | Isolated, nonpersistent storage with links, permissions, timestamps, and streams |
103
+ | `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 |
97
104
  | `createRealFileSystem({ root })` | An existing host directory, with virtual paths rooted inside it; Node only |
98
105
  | `new S3FileSystem({ transport, bucket, … })` | Bucket/prefix storage through an explicitly supplied transport; Node only |
99
106
  | `new WebDavFileSystem({ baseUrl, fetch, … })` | A WebDAV namespace through an explicitly supplied Fetch implementation |
@@ -102,7 +109,7 @@ before implementing the host operation; ordinary writes do not provide it.
102
109
  | `createOverlayFileSystem({ upper, lower })` | Reading through to a lower layer and writing changes to an upper layer |
103
110
  | `withFileSystemQuota(filesystem, { maxBytes })` | Enforcing a cumulative logical-byte ceiling across every write path |
104
111
 
105
- `createNodeFsBridge` offers a promises-shaped subset, including recursive `cp` and `mkdtemp`. The portable `createFsBridge` from `@poe-platform/safe-fs/core` instead requires a caller-supplied text codec and returns `Uint8Array` values.
112
+ `createNodeFsBridge` offers a promises-shaped subset, including recursive `cp` and `mkdtemp`. A trusted `reserveReadFile` callback can reserve host resources and return a release function; cancellation retains the reservation until the backend read settles. The portable `createFsBridge` from `@poe-platform/safe-fs/core` instead requires a caller-supplied text codec and returns `Uint8Array` values.
106
113
 
107
114
  ## Write an adapter
108
115
 
@@ -219,15 +226,15 @@ There are no package environment variables, implicit credentials, or automatic `
219
226
 
220
227
  | API | Options and defaults |
221
228
  | --- | --- |
222
- | `createFileSystem(config, { registry })` | Required `config.type`; `config.options` defaults to an empty record. `registry` is required. Built-in `memory` accepts no options; built-in `real` requires `root`. |
229
+ | `createFileSystem(config, { registry })` | Required `config.type`; `config.options` defaults to an empty record. `registry` is required. Built-in `memory` accepts optional file, retained-byte, metadata and total-byte quotas; built-in `real` requires `root`. |
223
230
  | `createNodeFileSystemAdapterRegistry(extensions?)` | Optional map of additional adapter descriptors; defaults to only `memory` and `real`. |
224
- | Memory / read-only | Memory takes no options. Read-only takes the backing filesystem, without an options object. |
231
+ | 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. |
225
232
  | Real | Required `root`: existing absolute host directory; the constructor/factory also accepts the root string directly. |
226
233
  | Mount | Required `root`: fallback filesystem. `mounts` defaults to `{}` and maps absolute virtual paths to filesystems. |
227
- | Overlay | Required `upper` and `lower`; `maxBufferBytes` defaults to 64 MiB. |
234
+ | Overlay | Required `upper` and `lower`; `maxBufferBytes` is unlimited unless configured. |
228
235
  | Quota | `withFileSystemQuota` requires a nonnegative safe-integer `maxBytes`. It serializes mutations and counts files, symlinks, copies, hard links, truncation, and streaming writes. |
229
- | Node bridge | `cwd` defaults to `/`, must be an absolute virtual path; optional lifetime `signal`. |
230
- | Portable bridge | Same `cwd` and `signal`, plus required `codec` with `isEncoding`, `encode`, and `decode` functions. |
236
+ | 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
+ | Portable bridge | Same `cwd`, `signal` and `readFileMaxBytes`, plus required `codec` with `isEncoding`, `encode`, and `decode` functions. |
231
238
  | Catalog example | Required `files`: a plain record of single-component filenames to text strings; no other options. |
232
239
 
233
240
  ### Per-operation options
@@ -246,6 +253,8 @@ Every raw filesystem operation accepts an optional `signal`. Additional fields a
246
253
 
247
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).
248
255
 
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.
257
+
249
258
  <details>
250
259
  <summary>S3 filesystem and HTTP transport options</summary>
251
260
 
@@ -261,11 +270,39 @@ For in-memory S3 simulations, `new MockS3Client({ buckets, pageSize?, now?, auth
261
270
  | `readOnly` | `false` |
262
271
  | `allowNonAtomicRename` | `true`; rename copies then deletes, without rollback guarantees |
263
272
  | `pageSize` | 1,000; range 1–1,000 |
264
- | `maxReadBytes` | 64 MiB |
265
- | `maxStreamBytes` | 5,000,000,000 bytes; also the maximum accepted value |
266
- | `maxListEntries` | 100,000 |
273
+ | `maxReadBytes` | Unlimited unless configured |
274
+ | `maxStreamBytes` | Unlimited unless configured |
275
+ | `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` |
267
279
  | `compareEntry` | Optional trusted backing-identity callback |
268
280
 
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.
282
+
283
+ 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
+
285
+ ```ts
286
+ async function removeBatch(transport, bucket, trustedDirectoryPrefix, abortSignal) {
287
+ const options = { abortSignal };
288
+ const page = await transport.listObjectsV2({
289
+ Bucket: bucket, Prefix: trustedDirectoryPrefix, MaxKeys: 16,
290
+ }, options);
291
+ const objects = page.Contents ?? [];
292
+ if (objects.length > 16 || page.CommonPrefixes?.length
293
+ || objects.some(object => !object.Key?.startsWith(trustedDirectoryPrefix))
294
+ || typeof page.IsTruncated !== "boolean") {
295
+ throw new Error("Invalid batch listing");
296
+ }
297
+ for (const object of objects) {
298
+ await transport.deleteObject({ Bucket: bucket, Key: object.Key }, options);
299
+ }
300
+ return { deleted: objects.length, done: !page.IsTruncated };
301
+ }
302
+ ```
303
+
304
+ Each batch lists from the beginning because previous keys have been deleted; it does not reuse continuation tokens across mutations. A job runner should cap the number of batches and schedule remaining work separately.
305
+
269
306
  `createS3HttpTransport` requires `endpoint` (an origin without path or credentials), `region`, and `credentials`. Credentials contain `accessKeyId`, `secretAccessKey`, and optional `sessionToken`, or come from an async provider receiving `{ signal }`.
270
307
 
271
308
  | Optional field | Default / meaning |
@@ -273,9 +310,9 @@ For in-memory S3 simulations, `new MockS3Client({ buckets, pageSize?, now?, auth
273
310
  | `addressingStyle` | `path`; alternative `virtual-hosted` requires a DNS endpoint |
274
311
  | `listUrlEncoding` | `percent`; alternative `form` |
275
312
  | `allowInsecureHttp` | `false`; HTTPS required unless explicitly enabled |
276
- | `maxPutBytes`, `maxGetBytes` | 64 MiB each |
277
- | `maxXmlBytes` | 4 MiB; maximum 16 MiB |
278
- | `requestTimeoutMs` | 30,000 |
313
+ | `maxPutBytes`, `maxGetBytes` | Unlimited unless configured |
314
+ | `maxXmlBytes` | Unlimited unless configured |
315
+ | `requestTimeoutMs` | Unlimited unless configured |
279
316
  | `enableCopy` | `true`; disabling uses a buffered GET/PUT fallback |
280
317
  | `verifiedConditionalOperations` | Optional `put`, `copy`, `delete` booleans, each defaulting to false; enable only after verifying the server's semantics |
281
318
  | `clock` | Current date/time function, used for signing |
@@ -292,14 +329,16 @@ For in-memory S3 simulations, `new MockS3Client({ buckets, pageSize?, now?, auth
292
329
  | --- | --- |
293
330
  | `headers` | Empty; explicit authentication/custom headers. Protocol-reserved headers are rejected; authorization and cookies require HTTPS. |
294
331
  | `requestStreamSupport` | `native` for global Fetch, otherwise false; accepts `native` or a boolean declaration for the injected transport |
295
- | `maxResponseBytes` | 64 MiB |
296
- | `maxXmlBytes` | 2 MiB |
297
- | `maxEntries` | 10,000 |
298
- | `timeoutMs` | 30,000 |
332
+ | `maxResponseBytes` | 16 MiB; applies to decoded response bytes |
333
+ | `maxXmlBytes` | 1 MiB before metadata decoding/parsing |
334
+ | `maxEntries` | Unlimited unless configured |
335
+ | `timeoutMs` | Unlimited unless configured |
299
336
  | `overwritePolicy` | `lock`; alternative `etag` uses conditional overwrites |
300
337
  | `atomicEmptyDirectory` | Optional trusted binding with the canonical `namespaceUrl` and `removeEmptyDirectory` callback; required for strict empty-only `rmdir` |
301
338
  | `compareEntry` | Optional trusted backing-identity callback on Node; unavailable under browser policy |
302
339
 
340
+ 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
+
303
342
  See the [binding types](src/fs/webdav/webdav.ts) before implementing atomic directory removal. A recursive WebDAV DELETE does not satisfy that contract.
304
343
 
305
344
  </details>
@@ -307,9 +346,16 @@ See the [binding types](src/fs/webdav/webdav.ts) before implementing atomic dire
307
346
  ## Safety boundary and limitations
308
347
 
309
348
  - **Not an OS sandbox.** Rooted storage and bridge confinement check paths and symlinks, but checks and host operations can race concurrent changes. Use a separate OS isolation boundary for hostile workloads. A custom adapter or transport is trusted code with its own host authority.
310
- - **A read-only view is not an immutable store.** Other references can still change the backing filesystem. Overlays are not transactions; cancellation and cleanup do not guarantee rollback. Cross-mount rename can fail with `EXDEV`.
349
+ - **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 are not transactions; cancellation and cleanup do not guarantee rollback. Cross-mount rename can fail with `EXDEV`.
311
350
  - **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.
312
351
  - **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.
313
352
  - **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.
314
353
  - **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`.
315
354
  - **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.
355
+
356
+ `MemoryFileSystem.confineExtraction(roots)` returns a view for archive extraction.
357
+ It retains each root directory and its ancestors, refuses symlink ancestry at
358
+ mutation commit, and keeps streamed writes attached to the opened file node.
359
+ Other adapters omit this operation unless they can enforce the same boundary.
360
+ Trusted host decorators must explicitly preserve confinement when forwarding it;
361
+ custom mutation hooks must preserve the atomic commit contract.
@@ -14,7 +14,10 @@ export declare class FileSystemBridge<Binary extends Uint8Array> {
14
14
  #private;
15
15
  constructor(fs: FileSystem, options: {
16
16
  readonly cwd?: string;
17
+ readonly root?: string;
17
18
  readonly signal?: AbortSignal;
19
+ readonly readFileMaxBytes?: number;
20
+ readonly reserveReadFile?: () => () => void;
18
21
  }, primitives: BridgePrimitives<Binary>);
19
22
  readFile(path: unknown, options?: {
20
23
  encoding?: null | undefined;
@@ -65,6 +68,7 @@ export declare class FileSystemBridge<Binary extends Uint8Array> {
65
68
  }) | null): Promise<void>;
66
69
  mkdir(path: unknown, options?: Mode | MakeDirectoryOptions | null): Promise<string | undefined>;
67
70
  access(path: unknown, mode?: number): Promise<void>;
71
+ unlink(path: unknown): Promise<void>;
68
72
  rm(path: unknown, value?: unknown): Promise<void>;
69
73
  rmdir(path: unknown, value?: unknown): Promise<void>;
70
74
  rename(source: unknown, destination: unknown): Promise<void>;
@@ -42,7 +42,10 @@ function timeValue(value) {
42
42
  export class FileSystemBridge {
43
43
  #fs;
44
44
  #cwd;
45
+ #root;
45
46
  #signal;
47
+ #readFileMaxBytes;
48
+ #reserveReadFile;
46
49
  #primitives;
47
50
  #codec;
48
51
  constructor(fs, options, primitives) {
@@ -63,7 +66,16 @@ export class FileSystemBridge {
63
66
  });
64
67
  this.#fs = fs;
65
68
  this.#cwd = primitives.paths.resolve("/", cwd);
69
+ this.#root = primitives.paths.resolve("/", options.root ?? cwd);
70
+ assertBridgePath(this.#root, this.#cwd);
66
71
  this.#signal = options.signal;
72
+ const maximum = options.readFileMaxBytes;
73
+ if (maximum !== undefined && (!Number.isSafeInteger(maximum) || maximum < 0))
74
+ throw new TypeError("Invalid readFileMaxBytes");
75
+ this.#readFileMaxBytes = maximum;
76
+ if (options.reserveReadFile !== undefined && typeof options.reserveReadFile !== "function")
77
+ throw new TypeError("Invalid reserveReadFile");
78
+ this.#reserveReadFile = options.reserveReadFile;
67
79
  }
68
80
  #encoding(value, fallback, names = false) {
69
81
  const selected = value == null ? fallback : value;
@@ -83,7 +95,7 @@ export class FileSystemBridge {
83
95
  throw fsError("ENOENT", "path", path);
84
96
  checkSignal(this.#signal);
85
97
  const absolute = this.#primitives.paths.isAbsolute(path) ? path : childPath(this.#cwd, path);
86
- assertBridgePath(this.#cwd, absolute);
98
+ assertBridgePath(this.#root, absolute);
87
99
  return absolute;
88
100
  }
89
101
  async #call(paths, operation, signal, noFollow = []) {
@@ -98,9 +110,9 @@ export class FileSystemBridge {
98
110
  return await withSignal(combined, async () => {
99
111
  const absolute = paths.map((path) => this.#path(path));
100
112
  const resolved = [...absolute];
101
- if (this.#cwd !== "/") {
113
+ if (this.#root !== "/") {
102
114
  for (let index = 0; index < absolute.length; index++) {
103
- resolved[index] = await checkedBridgePath(this.#fs, this.#cwd, absolute[index], options, !noFollow.includes(index));
115
+ resolved[index] = await checkedBridgePath(this.#fs, this.#root, absolute[index], options, !noFollow.includes(index));
104
116
  }
105
117
  }
106
118
  checkSignal(combined);
@@ -118,9 +130,29 @@ export class FileSystemBridge {
118
130
  if (options.encoding === "buffer")
119
131
  throw new TypeError("Invalid read encoding");
120
132
  const codec = this.#encoding(options.encoding, "buffer");
121
- const bytes = await this.#call([path], (signal, resolved) => this.#fs.readFile(resolved[0], signal), options.signal);
122
- const buffer = this.#primitives.copyBytes(bytes);
123
- return codec === "buffer" ? buffer : this.#codec.decode(buffer, codec);
133
+ const maxBytes = this.#readFileMaxBytes;
134
+ const release = this.#reserveReadFile?.();
135
+ let pending;
136
+ try {
137
+ const bytes = await this.#call([path], (signal, resolved) => {
138
+ pending = this.#fs.readFile(resolved[0], { ...signal, ...(maxBytes === undefined ? {} : { maxBytes }) });
139
+ return pending;
140
+ }, options.signal);
141
+ if (maxBytes !== undefined && bytes.byteLength > maxBytes)
142
+ throw fsError("EFBIG", "readFile");
143
+ const buffer = this.#primitives.copyBytes(bytes);
144
+ return codec === "buffer" ? buffer : this.#codec.decode(buffer, codec);
145
+ }
146
+ finally {
147
+ // Cancellation may reject #call before a backend acknowledges it. Keep
148
+ // its reservation until the actual read settles, as well as through decode.
149
+ if (release !== undefined) {
150
+ if (pending === undefined)
151
+ release();
152
+ else
153
+ void pending.then(release, release);
154
+ }
155
+ }
124
156
  }
125
157
  async #write(path, data, value, fallback) {
126
158
  const options = optionsRecord(value, ["encoding", "flag", "mode", "flush", "signal"]);
@@ -208,7 +240,7 @@ export class FileSystemBridge {
208
240
  if (!hasCode(error, "ENOENT"))
209
241
  throw error;
210
242
  firstCreated = candidate;
211
- if (candidate === this.#cwd)
243
+ if (candidate === this.#root)
212
244
  break;
213
245
  const parent = this.#primitives.paths.dirname(candidate);
214
246
  if (parent === candidate)
@@ -225,6 +257,13 @@ export class FileSystemBridge {
225
257
  throw new TypeError("Invalid access mode");
226
258
  await this.#call([path], (signal, resolved) => this.#fs.access(resolved[0], mode, signal));
227
259
  }
260
+ async unlink(path) {
261
+ await this.#call([path], async (signal, resolved) => {
262
+ if (!this.#fs.unlink)
263
+ unsupported("unlink");
264
+ await this.#fs.unlink(resolved[0], signal);
265
+ }, undefined, [0]);
266
+ }
228
267
  async rm(path, value) {
229
268
  const options = optionsRecord(value, ["force", "recursive", "maxRetries", "retryDelay"]);
230
269
  if (options.maxRetries !== undefined && options.maxRetries !== 0)
@@ -275,7 +314,7 @@ export class FileSystemBridge {
275
314
  const from = this.#path(source);
276
315
  const to = this.#path(destination);
277
316
  const canonicalFrom = await this.realpath(from);
278
- let ancestor = to === this.#cwd ? to : this.#primitives.paths.dirname(to);
317
+ let ancestor = to === this.#root ? to : this.#primitives.paths.dirname(to);
279
318
  let canonicalAncestor;
280
319
  while (true) {
281
320
  try {
@@ -342,7 +381,7 @@ export class FileSystemBridge {
342
381
  async realpath(path, value) {
343
382
  const codec = this.#encoding(optionsRecord(value, ["encoding"]).encoding, "utf8", true);
344
383
  const target = await this.#call([path], (signal, resolved) => this.#fs.realpath(resolved[0], signal));
345
- assertBridgePath(this.#cwd, target);
384
+ assertBridgePath(this.#root, target);
346
385
  return codec === "buffer" ? this.#textBytes(target) : this.#codec.decode(this.#textBytes(target), codec);
347
386
  }
348
387
  async mkdtemp(prefix, value) {
@@ -371,7 +410,7 @@ export class FileSystemBridge {
371
410
  const destination = this.#path(path);
372
411
  const absoluteTarget = this.#path(this.#primitives.paths.isAbsolute(linkTarget)
373
412
  ? linkTarget : childPath(this.#primitives.paths.dirname(destination), linkTarget));
374
- if (this.#cwd !== "/" && this.#primitives.paths.isAbsolute(linkTarget)) {
413
+ if (this.#root !== "/" && this.#primitives.paths.isAbsolute(linkTarget)) {
375
414
  throw fsError("ENOTSUP", "symlink", destination);
376
415
  }
377
416
  const method = this.#fs.symlink;
@@ -7,7 +7,13 @@ export interface FsBridgeCodec {
7
7
  export interface FsBridgeOptions {
8
8
  readonly codec: FsBridgeCodec;
9
9
  readonly cwd?: string;
10
+ /** Optional confinement boundary; defaults to cwd. */
11
+ readonly root?: string;
10
12
  readonly signal?: AbortSignal;
13
+ /** Trusted per-read backend byte cap, enforced before copying or decoding. */
14
+ readonly readFileMaxBytes?: number;
15
+ /** Reserve host resources; release after decoding and actual backend settlement, including cancellation. */
16
+ readonly reserveReadFile?: () => () => void;
11
17
  }
12
18
  export interface FsBridgeFileSystem extends FileSystem {
13
19
  rmdir?(path: string, options?: FsOptions): Promise<void>;
@@ -4,10 +4,15 @@ export interface OpenFileOptions extends FsOptions {
4
4
  readonly creation?: "never" | "ifMissing" | "exclusive";
5
5
  readonly truncate?: boolean;
6
6
  readonly append?: boolean;
7
+ /** Atomically reject a final symlink; parent symlinks still follow provider policy. */
8
+ readonly noFollow?: boolean;
7
9
  readonly mode?: number;
10
+ /** Apply mode exactly to new entries, without an additional host umask. */
11
+ readonly exactMode?: boolean;
8
12
  readonly synchronization?: "data" | "all";
9
13
  }
10
14
  export interface FileDescriptorCapabilities {
15
+ readonly noFollow?: boolean;
11
16
  readonly publication?: "conditional";
12
17
  readonly position?: boolean;
13
18
  readonly readObservation?: boolean;
@@ -6,6 +6,8 @@ export type FileType = "file" | "directory" | "symlink" | "character";
6
6
  export type EntryComparison = "same" | "distinct" | "unknown";
7
7
  export interface FileStat {
8
8
  readonly type: FileType;
9
+ /** Backend-reported type of the containing filesystem; absence means unknown. */
10
+ readonly filesystemType?: string;
9
11
  readonly size: number;
10
12
  readonly allocatedBytes?: number;
11
13
  readonly ioBlockSize?: number;
@@ -65,7 +67,11 @@ export interface FileSystemCapabilities {
65
67
  readonly permissions?: boolean;
66
68
  readonly timestamps?: boolean;
67
69
  readonly atomicRename?: boolean;
70
+ /** Owned staging serialized within a trusted host; requires external tree isolation. */
71
+ readonly trustedOwnedStaging?: boolean;
68
72
  readonly atomicFileStaging?: boolean;
73
+ /** Atomically verifies every supplied root-to-parent directory identity at publication. */
74
+ readonly atomicStagingAncestry?: boolean;
69
75
  readonly atomicFilePublication?: boolean;
70
76
  readonly atomicFileMutation?: boolean;
71
77
  readonly atomicEntryRemoval?: boolean;
@@ -89,6 +95,7 @@ export interface OpenReadFileOptions extends FsOptions {
89
95
  }
90
96
  export interface CapabilityQueryOptions extends OpenReadFileOptions {
91
97
  readonly create?: boolean;
98
+ readonly creation?: OpenFileOptions["creation"];
92
99
  }
93
100
  export interface FileReadHandle {
94
101
  /** Exact operations on the same retained object; close owns both facets. */
@@ -150,6 +157,8 @@ export interface AppendFileOptions extends FsOptions {
150
157
  readonly mode?: number;
151
158
  }
152
159
  export interface MkdirOptions extends FsOptions {
160
+ /** Apply mode exactly to new entries, without an additional host umask. */
161
+ readonly exactMode?: boolean;
153
162
  readonly recursive?: boolean;
154
163
  readonly mode?: number;
155
164
  }
@@ -199,6 +208,7 @@ export interface CreateStagedFileOptions extends FsOptions {
199
208
  readonly mtimeMs?: number;
200
209
  }
201
210
  export interface PublishStagedFileOptions extends FsOptions {
211
+ readonly ancestors?: readonly FileStagingEntry[];
202
212
  readonly parent: FileStat;
203
213
  readonly destination: FileStat | null;
204
214
  }
@@ -217,6 +227,11 @@ export interface ConditionalFilePublicationOptions extends FsOptions {
217
227
  readonly mtimeMs?: number;
218
228
  }
219
229
  export interface FileSystem {
230
+ /** Retain each extraction root and enforce no-symlink ancestry atomically with
231
+ * every mutation (including metadata and both hardlink paths). Returned views
232
+ * must refuse unsupported mutations; no check-then-write emulation is allowed.
233
+ * Roots and their ancestors must remain the retained directories. */
234
+ confineExtraction?(roots: readonly string[], options?: FsOptions): Promise<FileSystem>;
220
235
  /** Explicit retained-object capability for qualified byte-path backends. */
221
236
  readonly objects?: ObjectFileSystem;
222
237
  /** Consume the complete source privately, then atomically compare/publish.
@@ -1,6 +1,8 @@
1
1
  export type ByteSource = AsyncIterable<Uint8Array>;
2
2
  export interface CollectOptions {
3
- readonly maxBytes: number;
3
+ readonly maxBytes?: number;
4
+ /** Owned capacity, current input backing storage, and replacement allocation peak. */
5
+ readonly maxMemoryBytes?: number;
4
6
  readonly signal?: AbortSignal;
5
7
  }
6
8
  export declare function toByteSource(input: string | Uint8Array): ByteSource;
@@ -1,5 +1,6 @@
1
1
  import { FsError } from "./errors.js";
2
2
  import { finishCleanup } from "./cleanup.js";
3
+ import { platform } from "#safe-fs-platform";
3
4
  export function toByteSource(input) {
4
5
  if (typeof input !== "string" && !(input instanceof Uint8Array)) {
5
6
  throw new TypeError("Byte source input must be a string or Uint8Array");
@@ -10,29 +11,60 @@ export function toByteSource(input) {
10
11
  yield bytes;
11
12
  })();
12
13
  }
14
+ let activeCollectionBytes = 0;
13
15
  export async function collectBytes(source, options) {
14
- if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0) {
16
+ if (options.maxBytes !== undefined && options.maxBytes !== Infinity && (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0)) {
15
17
  throw new RangeError("maxBytes must be a nonnegative safe integer");
16
18
  }
17
- const chunks = [];
19
+ if (options.maxMemoryBytes !== undefined && (!Number.isSafeInteger(options.maxMemoryBytes) || options.maxMemoryBytes < 0)) {
20
+ throw new RangeError("maxMemoryBytes must be a nonnegative safe integer");
21
+ }
22
+ const memoryLimit = options.maxMemoryBytes ?? Infinity;
23
+ let reserved = 0;
24
+ let inputBytes = 0;
25
+ let buffer = new Uint8Array(0);
18
26
  let size = 0;
19
- options.signal?.throwIfAborted();
20
- for await (const chunk of readBytes(source, options.signal)) {
21
- if (chunk.byteLength > options.maxBytes - size) {
22
- throw new FsError("EFBIG", { syscall: "collectBytes", message: "output exceeds maxBytes" });
27
+ function reserve(bytes) {
28
+ if (bytes > memoryLimit - reserved || bytes > platform.maxCollectionBytes - activeCollectionBytes) {
29
+ throw new FsError("EFBIG", { syscall: "collectBytes", message: "collection exceeds memory budget" });
23
30
  }
24
- if (chunk.byteLength > 0)
25
- chunks.push(new Uint8Array(chunk));
26
- size += chunk.byteLength;
31
+ reserved += bytes;
32
+ activeCollectionBytes += bytes;
27
33
  }
28
- options.signal?.throwIfAborted();
29
- const result = new Uint8Array(size);
30
- let offset = 0;
31
- for (const chunk of chunks) {
32
- result.set(chunk, offset);
33
- offset += chunk.byteLength;
34
+ try {
35
+ options.signal?.throwIfAborted();
36
+ for await (const chunk of readBytes(source, options.signal)) {
37
+ activeCollectionBytes -= inputBytes;
38
+ reserved -= inputBytes;
39
+ inputBytes = 0;
40
+ if (chunk.byteLength > (options.maxBytes ?? Infinity) - size) {
41
+ throw new FsError("EFBIG", { syscall: "collectBytes", message: "output exceeds maxBytes" });
42
+ }
43
+ // A small view can retain a large backing buffer. Keep this reservation
44
+ // while awaiting the next chunk, including while its source is suspended.
45
+ reserve(chunk.buffer.byteLength);
46
+ inputBytes = chunk.buffer.byteLength;
47
+ const nextSize = size + chunk.byteLength;
48
+ if (nextSize > buffer.byteLength) {
49
+ const capacity = Math.min(options.maxBytes ?? Infinity, Math.max(nextSize, buffer.byteLength * 2));
50
+ // Admit both generations before allocation. Do not fall back to repeated
51
+ // exact-size growth when geometric growth exceeds the budget.
52
+ reserve(capacity);
53
+ const replacement = new Uint8Array(capacity);
54
+ replacement.set(buffer.subarray(0, size));
55
+ activeCollectionBytes -= buffer.byteLength;
56
+ reserved -= buffer.byteLength;
57
+ buffer = replacement;
58
+ }
59
+ buffer.set(chunk, size);
60
+ size = nextSize;
61
+ }
62
+ options.signal?.throwIfAborted();
63
+ return buffer.subarray(0, size);
64
+ }
65
+ finally {
66
+ activeCollectionBytes -= reserved;
34
67
  }
35
- return result;
36
68
  }
37
69
  async function abortable(operation, signal) {
38
70
  signal?.throwIfAborted();
@@ -17,7 +17,8 @@ export type { RetainedFileSystemCleanupView, RetainedFileSystemCleanupOptions }
17
17
  export * from "./fs/webdav/index.js";
18
18
  export * from "./bridge/index.js";
19
19
  export * from "./python/index.js";
20
- export { compareEntries } from "./fs/mount/comparison.js";
20
+ export { compareEntries, registerEntryView } from "./fs/mount/comparison.js";
21
+ export type { EntryViewResolver } from "./fs/mount/comparison.js";
21
22
  export { parseXml, parseXmlSteps, XmlLimitError } from "./xml.js";
22
23
  export type { XmlName, XmlAttribute, XmlContent, XmlElement, XmlLimits } from "./xml.js";
23
24
  export * from "./contracts/object.js";
@@ -15,7 +15,7 @@ export { scopeFileSystem, retainFileSystemCleanup } from "./fs/scoped.js";
15
15
  export * from "./fs/webdav/index.js";
16
16
  export * from "./bridge/index.js";
17
17
  export * from "./python/index.js";
18
- export { compareEntries } from "./fs/mount/comparison.js";
18
+ 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";
@@ -108,7 +108,7 @@ export function readOnlyCapabilities(capabilities) {
108
108
  mkdir: false, recursiveMkdir: false, remove: false, removeDirectory: false, recursiveRemove: false,
109
109
  rename: false, copy: false, exclusiveCopy: false, truncate: false, streamingAppend: false,
110
110
  randomAccessWrite: false, hardlinks: false, permissions: false, timestamps: false,
111
- descriptorWriteStream: false, atomicResize: false, retainedResize: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicTreeRemoval: false, atomicFileStaging: false, atomicDirectoryMetadata: false,
111
+ descriptorWriteStream: false, atomicResize: false, retainedResize: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicTreeRemoval: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
112
112
  atomicFilePublication: false, atomicRename: false, atomicRenameNoReplace: false, streamingWrite: false,
113
113
  });
114
114
  }
@@ -116,7 +116,7 @@ export function quotaCapabilities(capabilities) {
116
116
  const streamingWrite = requireCapabilities(capabilities.write, capabilities.append, !capabilities.readOnly);
117
117
  const streamingAppend = requireCapabilities(capabilities.append, !capabilities.readOnly);
118
118
  const { streamingWrite: ignoredWrite, streamingAppend: ignoredAppend, ...rest } = capabilities;
119
- return Object.freeze({ ...rest, atomicFilePublication: false, descriptorWriteStream: false, atomicResize: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicFileStaging: false, atomicDirectoryMetadata: false,
119
+ return Object.freeze({ ...rest, atomicFilePublication: false, descriptorWriteStream: false, atomicResize: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
120
120
  ...(streamingWrite === undefined ? {} : { streamingWrite }),
121
121
  ...(streamingAppend === undefined ? {} : { streamingAppend }),
122
122
  });
@@ -136,12 +136,15 @@ export function ownedMutationCapabilities(filesystem, capabilities = filesystem.
136
136
  unavailable.atomicFileStaging = false;
137
137
  if (capabilities.atomicDirectoryMetadata === true && (capabilities.readOnly === true || typeof filesystem.prepareDirectory !== "function"))
138
138
  unavailable.atomicDirectoryMetadata = false;
139
+ if (capabilities.trustedOwnedStaging === true && (capabilities.readOnly === true
140
+ || ["createStagedFile", "publishStagedFile", "removeStagedFile", "writeFileConditional", "removeFileConditional", "prepareDirectory"].some(method => typeof filesystem[method] !== "function")))
141
+ unavailable.trustedOwnedStaging = false;
139
142
  return Object.keys(unavailable).length ? { ...capabilities, ...unavailable } : capabilities;
140
143
  }
141
144
  export async function requireOwnedMutation(filesystem, path, capability, options, create = false) {
142
145
  options.signal?.throwIfAborted();
143
146
  const capabilities = ownedMutationCapabilities(filesystem, await filesystem.capabilitiesFor?.(path, create ? { ...options, create: true } : options) ?? filesystem.capabilities);
144
147
  options.signal?.throwIfAborted();
145
- if (capabilities[capability] !== true)
148
+ if (capabilities[capability] !== true && !(capabilities.trustedOwnedStaging === true && ["atomicFileStaging", "atomicFileMutation", "atomicDirectoryMetadata"].includes(capability)))
146
149
  throw new FsError("ENOTSUP", { path, syscall: capability });
147
150
  }
@@ -2,6 +2,7 @@ import { FsError } from "../contracts/errors.js";
2
2
  import { finishCleanup } from "../contracts/cleanup.js";
3
3
  function admitCapabilities(path, options, capabilities) {
4
4
  if (![capabilities.positionedRead, capabilities.positionedWrite, capabilities.truncate].every(value => typeof value === "boolean")
5
+ || capabilities.noFollow !== undefined && typeof capabilities.noFollow !== "boolean"
5
6
  || capabilities.publication !== undefined && capabilities.publication !== "conditional"
6
7
  || capabilities.position !== undefined && typeof capabilities.position !== "boolean"
7
8
  || capabilities.readObservation !== undefined && typeof capabilities.readObservation !== "boolean"
@@ -10,7 +11,8 @@ function admitCapabilities(path, options, capabilities) {
10
11
  || capabilities.delegateZeroLengthWrite !== undefined && typeof capabilities.delegateZeroLengthWrite !== "boolean"
11
12
  || !["none", "volatile", "storage"].includes(capabilities.synchronization))
12
13
  throw new FsError("EINVAL", { syscall: "open", path });
13
- if (options.truncate && !(capabilities.openTruncate ?? capabilities.truncate) || options.synchronization !== undefined && capabilities.synchronization === "none") {
14
+ if (options.noFollow && capabilities.noFollow !== true
15
+ || options.truncate && !(capabilities.openTruncate ?? capabilities.truncate) || options.synchronization !== undefined && capabilities.synchronization === "none") {
14
16
  throw new FsError("ENOTSUP", { syscall: "open", path });
15
17
  }
16
18
  }
@@ -68,6 +70,7 @@ class ManagedFileDescriptor {
68
70
  this.#backend = backend;
69
71
  const positionedAppendWrite = capabilities.positionedAppendWrite === true && capabilities.positionedWrite && options.access !== "read";
70
72
  this.capabilities = Object.freeze({
73
+ ...(capabilities.noFollow === undefined ? {} : { noFollow: capabilities.noFollow }),
71
74
  ...(capabilities.publication === undefined ? {} : { publication: capabilities.publication }),
72
75
  ...(capabilities.position === undefined ? {} : { position: capabilities.position }),
73
76
  ...(capabilities.readObservation === undefined ? {} : { readObservation: capabilities.readObservation }),
@@ -206,11 +209,13 @@ export async function openFileDescriptor(path, options, capabilities, acquire) {
206
209
  throw new FsError("EINVAL", { syscall: "open", path });
207
210
  const signal = options.signal;
208
211
  signal?.throwIfAborted();
209
- const keys = ["access", "creation", "truncate", "append", "mode", "synchronization", "signal"];
212
+ const keys = ["access", "creation", "truncate", "append", "mode", "exactMode", "noFollow", "synchronization", "signal"];
210
213
  const { access, creation = "never", truncate = false, append = false, mode = 0o666, synchronization } = options;
211
214
  if (Object.keys(options).some(key => !keys.includes(key))
212
215
  || !["read", "write", "readwrite"].includes(access)
213
216
  || !["never", "ifMissing", "exclusive"].includes(creation)
217
+ || options.noFollow !== undefined && typeof options.noFollow !== "boolean"
218
+ || options.exactMode !== undefined && typeof options.exactMode !== "boolean"
214
219
  || typeof truncate !== "boolean" || typeof append !== "boolean"
215
220
  || access === "read" && (truncate || append)
216
221
  || synchronization !== undefined && !["data", "all"].includes(synchronization)
@@ -218,6 +223,8 @@ export async function openFileDescriptor(path, options, capabilities, acquire) {
218
223
  throw new FsError("EINVAL", { syscall: "open", path });
219
224
  }
220
225
  const admitted = Object.freeze({ access, creation, truncate, append, mode,
226
+ ...(options.noFollow === undefined ? {} : { noFollow: options.noFollow }),
227
+ ...(options.exactMode === undefined ? {} : { exactMode: options.exactMode }),
221
228
  ...(signal === undefined ? {} : { signal }), ...(synchronization === undefined ? {} : { synchronization }) });
222
229
  const admittedCapabilities = Object.freeze({ ...capabilities });
223
230
  admitCapabilities(path, admitted, admittedCapabilities);