@openclaw/fs-safe 0.9.0 → 0.10.0

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 (178) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +17 -0
  3. package/dist/advanced.d.ts +1 -0
  4. package/dist/advanced.d.ts.map +1 -1
  5. package/dist/advanced.js +1 -0
  6. package/dist/archive-crc32.d.ts.map +1 -1
  7. package/dist/archive-crc32.js +6 -1
  8. package/dist/archive-deadline.d.ts.map +1 -1
  9. package/dist/archive-deadline.js +3 -4
  10. package/dist/archive-gzip-tail.d.ts +2 -0
  11. package/dist/archive-gzip-tail.d.ts.map +1 -1
  12. package/dist/archive-gzip-tail.js +20 -3
  13. package/dist/archive-merge.js +1 -1
  14. package/dist/archive-native.d.ts.map +1 -1
  15. package/dist/archive-native.js +3 -2
  16. package/dist/archive-read.d.ts.map +1 -1
  17. package/dist/archive-read.js +67 -66
  18. package/dist/archive-tar-stream.d.ts +11 -4
  19. package/dist/archive-tar-stream.d.ts.map +1 -1
  20. package/dist/archive-tar-stream.js +20 -8
  21. package/dist/archive-tar-wasm.d.ts.map +1 -1
  22. package/dist/archive-tar-wasm.js +2 -1
  23. package/dist/bounded-read.d.ts +7 -0
  24. package/dist/bounded-read.d.ts.map +1 -1
  25. package/dist/bounded-read.js +73 -43
  26. package/dist/clone-metadata.d.ts +19 -0
  27. package/dist/clone-metadata.d.ts.map +1 -0
  28. package/dist/clone-metadata.js +32 -0
  29. package/dist/copy-file-input.d.ts +23 -0
  30. package/dist/copy-file-input.d.ts.map +1 -0
  31. package/dist/copy-file-input.js +91 -0
  32. package/dist/copy-policy.d.ts +3 -0
  33. package/dist/copy-policy.d.ts.map +1 -0
  34. package/dist/copy-policy.js +8 -0
  35. package/dist/copy-publication.d.ts +10 -1
  36. package/dist/copy-publication.d.ts.map +1 -1
  37. package/dist/copy-publication.js +27 -0
  38. package/dist/copy-tree-portable.d.ts +9 -0
  39. package/dist/copy-tree-portable.d.ts.map +1 -0
  40. package/dist/copy-tree-portable.js +191 -0
  41. package/dist/copy.d.ts +16 -0
  42. package/dist/copy.d.ts.map +1 -0
  43. package/dist/copy.js +123 -0
  44. package/dist/durability.d.ts +1 -1
  45. package/dist/durability.d.ts.map +1 -1
  46. package/dist/error-detail.d.ts.map +1 -1
  47. package/dist/error-detail.js +4 -1
  48. package/dist/file-hash.d.ts +6 -2
  49. package/dist/file-hash.d.ts.map +1 -1
  50. package/dist/file-hash.js +44 -8
  51. package/dist/filename.d.ts.map +1 -1
  52. package/dist/filename.js +2 -12
  53. package/dist/guarded-mkdir.d.ts +1 -0
  54. package/dist/guarded-mkdir.d.ts.map +1 -1
  55. package/dist/guarded-mkdir.js +1 -0
  56. package/dist/guarded-mutation.d.ts +2 -0
  57. package/dist/guarded-mutation.d.ts.map +1 -1
  58. package/dist/guarded-mutation.js +8 -4
  59. package/dist/home-dir.d.ts.map +1 -1
  60. package/dist/home-dir.js +9 -7
  61. package/dist/index.d.ts +1 -1
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/install-path.d.ts.map +1 -1
  64. package/dist/install-path.js +2 -6
  65. package/dist/json-durable-queue.d.ts.map +1 -1
  66. package/dist/json-durable-queue.js +13 -14
  67. package/dist/local-roots.d.ts.map +1 -1
  68. package/dist/local-roots.js +22 -24
  69. package/dist/move-path-stage.d.ts +8 -0
  70. package/dist/move-path-stage.d.ts.map +1 -0
  71. package/dist/move-path-stage.js +55 -0
  72. package/dist/move-path.d.ts.map +1 -1
  73. package/dist/move-path.js +26 -31
  74. package/dist/mutation-authority.d.ts +8 -0
  75. package/dist/mutation-authority.d.ts.map +1 -0
  76. package/dist/mutation-authority.js +36 -0
  77. package/dist/native-binding.d.ts +24 -1
  78. package/dist/native-binding.d.ts.map +1 -1
  79. package/dist/native-operations.d.ts +1 -1
  80. package/dist/native-operations.d.ts.map +1 -1
  81. package/dist/native-operations.js +11 -5
  82. package/dist/native-pinned-write-windows.d.ts.map +1 -1
  83. package/dist/native-pinned-write-windows.js +18 -7
  84. package/dist/native-pinned-write.d.ts.map +1 -1
  85. package/dist/native-pinned-write.js +1 -0
  86. package/dist/native-staged-file.d.ts +3 -3
  87. package/dist/native-staged-file.d.ts.map +1 -1
  88. package/dist/native-staged-file.js +35 -8
  89. package/dist/path.d.ts.map +1 -1
  90. package/dist/path.js +3 -1
  91. package/dist/permissions-windows.d.ts.map +1 -1
  92. package/dist/permissions-windows.js +38 -48
  93. package/dist/pinned-operation.js +1 -1
  94. package/dist/pinned-write.d.ts +5 -1
  95. package/dist/pinned-write.d.ts.map +1 -1
  96. package/dist/pinned-write.js +50 -30
  97. package/dist/positional-read.d.ts +9 -0
  98. package/dist/positional-read.d.ts.map +1 -0
  99. package/dist/positional-read.js +36 -0
  100. package/dist/publish-copy-stage.d.ts +13 -0
  101. package/dist/publish-copy-stage.d.ts.map +1 -0
  102. package/dist/publish-copy-stage.js +47 -0
  103. package/dist/publish-file.d.ts.map +1 -1
  104. package/dist/publish-file.js +6 -5
  105. package/dist/replace-file-temp-owner.d.ts.map +1 -1
  106. package/dist/replace-file-temp-owner.js +6 -2
  107. package/dist/root-context.d.ts +3 -0
  108. package/dist/root-context.d.ts.map +1 -1
  109. package/dist/root-context.js +51 -26
  110. package/dist/root-directory-list.d.ts +24 -0
  111. package/dist/root-directory-list.d.ts.map +1 -0
  112. package/dist/root-directory-list.js +201 -0
  113. package/dist/root-errors.d.ts +1 -0
  114. package/dist/root-errors.d.ts.map +1 -1
  115. package/dist/root-errors.js +3 -0
  116. package/dist/root-file.d.ts +2 -0
  117. package/dist/root-file.d.ts.map +1 -1
  118. package/dist/root-file.js +12 -4
  119. package/dist/root-impl.d.ts +6 -50
  120. package/dist/root-impl.d.ts.map +1 -1
  121. package/dist/root-impl.js +330 -210
  122. package/dist/root-options.d.ts +66 -0
  123. package/dist/root-options.d.ts.map +1 -0
  124. package/dist/root-options.js +18 -0
  125. package/dist/root-path-existing.d.ts +5 -0
  126. package/dist/root-path-existing.d.ts.map +1 -1
  127. package/dist/root-path-existing.js +56 -1
  128. package/dist/root-path.d.ts +1 -0
  129. package/dist/root-path.d.ts.map +1 -1
  130. package/dist/root-path.js +74 -162
  131. package/dist/root-symlink-policy.d.ts +13 -0
  132. package/dist/root-symlink-policy.d.ts.map +1 -0
  133. package/dist/root-symlink-policy.js +34 -0
  134. package/dist/root-walk.d.ts +5 -4
  135. package/dist/root-walk.d.ts.map +1 -1
  136. package/dist/root-walk.js +99 -50
  137. package/dist/root-write-mode.d.ts.map +1 -1
  138. package/dist/root-write-mode.js +1 -4
  139. package/dist/root.d.ts +3 -1
  140. package/dist/root.d.ts.map +1 -1
  141. package/dist/secure-file.d.ts.map +1 -1
  142. package/dist/secure-file.js +4 -4
  143. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  144. package/dist/sidecar-lock-acquire.js +2 -1
  145. package/dist/sidecar-lock-policy.d.ts.map +1 -1
  146. package/dist/sidecar-lock-policy.js +3 -1
  147. package/dist/temp-cleanup.d.ts.map +1 -1
  148. package/dist/temp-cleanup.js +3 -2
  149. package/dist/timing.d.ts +1 -0
  150. package/dist/timing.d.ts.map +1 -1
  151. package/dist/timing.js +25 -6
  152. package/dist/trash.js +2 -2
  153. package/dist/walk.d.ts.map +1 -1
  154. package/dist/walk.js +6 -26
  155. package/dist/windows-owner.d.ts +9 -12
  156. package/dist/windows-owner.d.ts.map +1 -1
  157. package/dist/windows-owner.js +24 -58
  158. package/dist/write-file-handle.d.ts +6 -0
  159. package/dist/write-file-handle.d.ts.map +1 -0
  160. package/dist/write-file-handle.js +25 -0
  161. package/docs/advanced.md +18 -4
  162. package/docs/archive.md +22 -7
  163. package/docs/atomic.md +7 -0
  164. package/docs/contributing.md +7 -0
  165. package/docs/copy.md +86 -0
  166. package/docs/durability.md +17 -2
  167. package/docs/errors.md +1 -1
  168. package/docs/local-roots.md +8 -1
  169. package/docs/native.md +14 -1
  170. package/docs/permissions.md +20 -10
  171. package/docs/positional-read.md +63 -0
  172. package/docs/root.md +166 -4
  173. package/docs/security-model.md +14 -0
  174. package/docs/timing.md +2 -0
  175. package/docs/types.md +18 -11
  176. package/docs/walk.md +54 -5
  177. package/docs/writing.md +20 -2
  178. package/package.json +13 -8
package/docs/root.md CHANGED
@@ -18,6 +18,7 @@ const fs = await root("/srv/workspace", {
18
18
  function root(rootDir: string, defaults?: RootDefaults): Promise<Root>;
19
19
 
20
20
  type RootDefaults = {
21
+ assertBeforeMutation?: () => void; // synchronous caller authority check at mutation dispatch
21
22
  durable?: boolean; // fsync write/create/writeJson/createJson/append/copyIn; default true
22
23
  hardlinks?: "reject" | "allow"; // refuse files with nlink > 1 on read; defaults to "reject"
23
24
  denyMutations?: DenyMutationPolicy; // absolute paths/prefixes mutation methods may not change
@@ -26,7 +27,8 @@ type RootDefaults = {
26
27
  mode?: number; // file mode applied to new writes; per-call override available
27
28
  nonBlockingRead?: boolean; // compatibility hint; safe opens are already nonblocking where supported
28
29
  renameIdentity?: "strict" | "verify-content-with-lock"; // default "strict"
29
- symlinks?: "reject" | "follow-within-root"; // policy when a path component is a symlink
30
+ symlinks?: "reject" | "follow-within-root" | "follow-parents-within-root"; // read policy
31
+ mutationSymlinks?: "reject" | "follow-parents-within-root"; // opt-in mutation policy
30
32
  };
31
33
 
32
34
  type DenyMutationPolicy = {
@@ -37,7 +39,9 @@ type DenyMutationPolicy = {
37
39
 
38
40
  `root()` resolves the directory through the real filesystem. A symlinked input becomes the canonical path; a non-existent root throws `FsSafeError` with code `not-found`, and malformed or non-directory roots throw `invalid-path`.
39
41
 
40
- `defaults` apply to every method on the returned handle. Per-call options on individual methods override the defaults for that call only, except `denyMutations`: root and per-call deny entries are merged so a call cannot clear a root-level deny.
42
+ The root directory is pinned with exact bigint device/inode identities. A changed root rejects subsequent operations; an unknown Windows identity that remains unverifiable after bounded reinspection rejects construction with `path-mismatch`.
43
+
44
+ `defaults` apply to every method on the returned handle. Per-call options on individual methods override the defaults for that call only, except `denyMutations` and `assertBeforeMutation`: deny entries are merged, and the root assertion runs before the per-call assertion. A call cannot clear either root-level restriction.
41
45
 
42
46
  Every `maxBytes` value must be a non-negative safe integer or positive `Infinity`. Zero is an active zero-byte cap; `Infinity` explicitly disables the cap. An omitted or explicitly `undefined` per-call value preserves the configured Root default instead of clearing it.
43
47
 
@@ -60,7 +64,12 @@ fs.walk(rel, options) // root-bounded AsyncIterable<{ relativePath, kin
60
64
 
61
65
  `walk()` is the incremental, root-bounded recursive scan. It supports entry and
62
66
  depth budgets, cancellation, and `symlinkPolicy: "skip" |
63
- "follow-within-root"`. Budget exhaustion yields a `"truncated"` marker by
67
+ "follow-within-root"`. With an entry budget, sorted walks prepare small metadata
68
+ batches within the remaining budget; unbounded sorted walks reuse the full
69
+ directory snapshot.
70
+ The default `order: "sorted"` enumerates and sorts each directory's names;
71
+ `order: "filesystem"` streams names in filesystem order for bounded work in
72
+ wide directories. Budget exhaustion yields a `"truncated"` marker by
64
73
  default or throws `FsSafeError("too-large")` with `limitBehavior: "throw"`.
65
74
  Use `entryFilter(entry)` to return `"include"`, `"skip"`, or
66
75
  `"skip-subtree"`. `"skip"` omits the current entry but still descends into a
@@ -111,6 +120,10 @@ fs.ensureRoot(options?) // accepts "" / "." as the root itself
111
120
 
112
121
  `write`, `create`, `append`, `writeJson`, and `createJson` accept `mode?: number`; use `0o600` for credentials and other private state. `writeJson` also accepts the same options as `JSON.stringify` plus `trailingNewline?: boolean` (defaults `true` so the file ends in `\n`).
113
122
 
123
+ `append` accepts `prependNewlineIfNeeded: true` to separate text from existing
124
+ content when neither side supplies a newline. String data uses its `encoding`
125
+ for the newline check, including UTF-16LE; Buffer data uses a single LF byte.
126
+
114
127
  These five methods and `copyIn` also accept `durable?: boolean`: the per-call value overrides
115
128
  `Root.defaults.durable`, which defaults to `true` when omitted. An explicitly
116
129
  `undefined` per-call value preserves the root default. `durable: false` keeps
@@ -119,7 +132,76 @@ and parent-directory fsync calls. Use it only for reconstructible data: a crash
119
132
  may lose the write or leave the previous file. See [Writing](writing.md#write-options)
120
133
  for platform details.
121
134
 
122
- `copyIn` is a one-shot ingest from a trusted absolute source path: it streams the source through the boundary, atomically renames into the root, and respects `maxBytes`.
135
+ `copyIn` accepts a `RootCopySource`: a trusted absolute source path or a file
136
+ within another Root. The guarded form supplies `root` with only its `open` and
137
+ `stat` read capabilities, plus `relativePath`:
138
+
139
+ ```ts
140
+ const source = await root("/srv/templates");
141
+ const destination = await root("/srv/workspace");
142
+ await destination.copyIn("config/settings.json", {
143
+ root: source,
144
+ relativePath: "config/settings.json",
145
+ }, {
146
+ overwrite: false,
147
+ clone: "auto",
148
+ mode: 0o600,
149
+ signal: AbortSignal.timeout(30_000),
150
+ });
151
+ ```
152
+
153
+ The source Root applies its read policies, including confinement and symlink
154
+ handling. `sourceHardlinks` overrides its hardlink policy only when supplied;
155
+ otherwise the source Root default is retained. The admitted source
156
+ descriptor stays open through copying and source-identity verification; copying
157
+ does not consume its current file position. Both forms enforce `maxBytes` while
158
+ reading, including when a file grows after admission, and use bounded buffers.
159
+ Copies have independent file data; changing either file cannot change the other.
160
+ Set `preserveSourceMode: true` to select the mode from the admitted source
161
+ descriptor. An explicit numeric `mode`, including `Root.defaults.mode`, takes
162
+ precedence. By default, copying retains the existing destination-mode rules.
163
+ The operation verifies source identity, not a coherent snapshot of concurrent
164
+ in-place edits. Keep the source unchanged when snapshot consistency is required.
165
+
166
+ `overwrite` defaults to `true`, preserving the existing replacement behavior.
167
+ With `overwrite: false`, an existing destination produces `already-exists` and
168
+ is never altered. Copying prepares a private sibling file before publishing its
169
+ completed contents. Native mode uses no-replace rename. The guarded JavaScript
170
+ fallback links the completed stage and removes its temporary name in the same
171
+ JavaScript turn; the filesystem must support hardlinks. Other processes can
172
+ briefly observe both names. The source is never hardlinked to the destination.
173
+
174
+ `clone` chooses the file-data transfer strategy through `CopyCloneMode`, shared
175
+ with [`copyTree`](copy.md#api). File copies default to `"never"`; tree copies
176
+ default to `"auto"`:
177
+
178
+ | Value | Behavior |
179
+ | --- | --- |
180
+ | `never` | Copy regular file bytes using reads and writes, without explicit cloning or copy offload. |
181
+ | `auto` | Try native file cloning, then copy offload or ordinary byte copying when cloning is unavailable. |
182
+ | `always` | Require native cloning; fail when the binding or filesystem cannot provide it. |
183
+
184
+ Native file cloning supports APFS and supported Linux filesystems. Windows
185
+ currently uses byte copying for `never` and `auto`; `always` fails. Clone choice
186
+ does not change modes, durability, root confinement, or source and publication
187
+ identity checks. The shared strategy does not replace Root's guarded regular-file
188
+ contract with `copyTree`'s caller-owned immutable-tree and metadata contract.
189
+
190
+ An already aborted `signal` prevents I/O. Cancellation during copying waits for
191
+ admitted reads and native work to settle, then cleans only the owned unpublished
192
+ stage. The final authority check runs before publication. Once publication has
193
+ occurred, later cancellation or verification failure preserves the destination.
194
+ The synchronous optional `onDestinationPublished` callback receives a frozen
195
+ `RootCopyPublicationReceipt` containing `{ path, dev, ino }`, with exact bigint identity immediately after
196
+ publication, before later checks can fail. Callback errors also preserve the
197
+ published file. This receipt records an outcome; it does not authorize removing
198
+ a file that another actor may have edited. Application recovery and cooperative
199
+ locking remain caller-owned.
200
+
201
+ Existing `copyIn` callers must account for completed destinations retained after
202
+ a post-publication source-verification failure, even without the new options.
203
+ Recovery must inspect current destination state rather than assume a rejected
204
+ copy left no file.
123
205
 
124
206
  Root operations that choose a new destination reject a leading Windows
125
207
  drive-relative spelling such as `C:name` on every platform. This applies to
@@ -131,8 +213,59 @@ basename first.
131
213
 
132
214
  `openWritable` opens a writable file with options `mode?: number` and `writeMode?: "replace" | "append" | "update"`. `replace` truncates existing files and is the default; `update` keeps existing contents. Use it for streaming output. Prefer `await using` for cleanup.
133
215
 
216
+ ### Live mutation authority
217
+
218
+ All mutation methods accept `assertBeforeMutation?: () => void`. Use it when a
219
+ lease, operation owner, or cancellation state can expire while filesystem
220
+ preparation is awaiting I/O:
221
+
222
+ ```ts
223
+ const controller = new AbortController();
224
+ await fs.write("config.json", "{}\n", {
225
+ assertBeforeMutation: () => controller.signal.throwIfAborted(),
226
+ });
227
+ ```
228
+
229
+ The callback runs synchronously after awaited preparation, immediately before
230
+ each Root-owned mutation is dispatched: parent creation, file creation and
231
+ content writes (including private staging and streamed chunks), publication,
232
+ truncation, append, move, and removal. Buffered writes use bounded chunks and
233
+ recheck before every partial-write submission; file removal submits a direct
234
+ unlink request. Native calls that perform multiple filesystem steps are one
235
+ dispatch. No asynchronous wait separates the check
236
+ from that dispatch. A thrown value rejects the operation unchanged; an async
237
+ or thenable-returning callback rejects with `TypeError` before that mutation.
238
+ Synchronous return values are ignored. Callbacks can run multiple times and
239
+ must inspect current authority each time.
240
+
241
+ Already dispatched I/O cannot be revoked. Identity-checked cleanup, final
242
+ permissions, and durability finish under the existing operation owner even
243
+ after authority expires. Sidecar lock acquisition, recovery, and release for
244
+ `renameIdentity: "verify-content-with-lock"` are lock bookkeeping outside this
245
+ callback; content mutations still recheck after the lock is acquired. An
246
+ operation may leave already-created parent directories when a later check
247
+ rejects. A no-op such as `ensureRoot()` on the existing root does not require a
248
+ callback invocation. This is a dispatch fence, not a filesystem transaction or
249
+ a replacement for root confinement.
250
+
251
+ If cleanup also fails or cannot prove ownership of an entry, the existing
252
+ structured cleanup error takes precedence and retains the authority refusal
253
+ as its cause.
254
+
255
+ For `openWritable()`, the callback covers the library's parent creation,
256
+ exclusive creation, and truncation. The returned raw `FileHandle` belongs to
257
+ the caller, which must check authority before its own later writes.
258
+
134
259
  All mutation methods accept `denyMutations?: { paths?: string[]; prefixes?: string[] }`. Entries must be absolute paths. `paths` blocks those exact paths; `prefixes` blocks those paths and their descendants. fs-safe preserves path strings exactly and canonicalizes through existing ancestors before comparing, so a symlinked ancestor to a denied location is still denied. Denied mutations throw `FsSafeError` with code `denied-path`. Use this for caller-specific sensitive paths, not as a replacement for the root boundary, symlink, or hardlink checks.
135
260
 
261
+ All mutation methods also accept `mutationSymlinks`. `"reject"` rejects symlink
262
+ components; `"follow-parents-within-root"` resolves contained parent directory
263
+ aliases but rejects the final component if it is a symlink, including a dangling
264
+ link. Missing parent directories can still be created through a contained alias.
265
+ `move()` applies the policy to both source and destination. An omitted value
266
+ preserves existing behavior, including `remove()` unlinking a final symlink.
267
+ The read-only `symlinks` default does not change mutation behavior.
268
+
136
269
  ### Inspection (advisory)
137
270
 
138
271
  ```ts
@@ -218,6 +351,35 @@ await fs.readText("config.toml");
218
351
  await fs.readText("links/current.log", { symlinks: "follow-within-root" });
219
352
  ```
220
353
 
354
+ With `follow-within-root`, parent components after a symlink are applied to the
355
+ symlink's resolved target. Reads use that checked canonical path, including
356
+ after home expansion and through `readAbsolute` and `reader`; the default policy still rejects a symlink
357
+ even when a later `..` would hide it in a purely lexical normalization.
358
+
359
+ Use `follow-parents-within-root` when directory aliases are allowed but a final
360
+ file symlink should fail. Set each policy at the root to share that contract
361
+ between reads and mutations:
362
+
363
+ ```ts
364
+ const workspace = await root("/srv/workspace", {
365
+ symlinks: "follow-parents-within-root",
366
+ mutationSymlinks: "follow-parents-within-root",
367
+ });
368
+ await workspace.readText("directory-alias/notes.txt");
369
+ await workspace.write("directory-alias/notes.txt", "updated\n");
370
+ ```
371
+
372
+ The library uses the resolved parent for the operation and checks the final
373
+ component again before publication or removal. These checks preserve the existing
374
+ [platform containment guarantees](security-model.md#symlinks-write-side);
375
+ they do not make check-and-rename atomic against another process. Callers do not
376
+ need a separate `realpath()` or final `lstat()` preflight.
377
+
378
+ For methods that accept absolute paths, the same final-component rule applies
379
+ when a path enters the root through an alias outside its lexical spelling. A directory alias may
380
+ lead to a regular file inside the root; an absolute final file or directory
381
+ symlink is rejected before its canonical target replaces the original path.
382
+
221
383
  Text helpers default to UTF-8. Pass `encoding` per call to `readText`, `readJson`, `write`, `create`, or `append` when you need another encoding.
222
384
 
223
385
  ## Common patterns
@@ -53,6 +53,11 @@ Every path is resolved against the canonicalized real path of the root, then che
53
53
 
54
54
  Per-call `symlinks: "follow-within-root"` allows symlinks whose final target is still inside the root. The default is `"reject"`.
55
55
 
56
+ `symlinks: "follow-parents-within-root"` allows contained parent directory aliases
57
+ while rejecting a final symlink, including dangling links. Reads open the checked
58
+ canonical parent plus the final basename with the usual no-follow and identity
59
+ checks, so callers do not need their own parent canonicalization.
60
+
56
61
  Guarded root reads compare lossless bigint identities from before open, the opened
57
62
  descriptor, the input path, and the canonical target; numeric public `Stats`
58
63
  receipts are not used as identity evidence. Unknown Windows device/inode values
@@ -70,6 +75,15 @@ directory descriptors. Replacement uses descriptor-relative rename just like
70
75
  no-replace publication, so replacing the parent pathname does not divert the
71
76
  mutation.
72
77
 
78
+ The opt-in `mutationSymlinks` policy applies independently of read policy.
79
+ `"reject"` rejects symlink components; `"follow-parents-within-root"` resolves
80
+ contained directory aliases and rejects final symlinks. Publication checks the
81
+ final component again after awaited staging and parent fences, immediately before
82
+ the rename or exclusive open. These are best-effort symlink checks, not an atomic
83
+ expected-entry/CAS replacement: a concurrent process can still replace the final
84
+ entry between its check and rename. Existing parent containment guarantees remain
85
+ as described below. Omitting `mutationSymlinks` preserves existing mutation behavior.
86
+
73
87
  The JavaScript fallback used by `off`, by `auto` when no binding loads, and by
74
88
  the explicit `renameIdentity: "verify-content-with-lock"` compatibility policy
75
89
  cannot provide that guarantee because Node exposes no `mkdirat` or `renameat`.
package/docs/timing.md CHANGED
@@ -22,6 +22,8 @@ function withTimeout<T>(
22
22
 
23
23
  If `timeoutMs` is `0`, negative, `Infinity`, or `NaN`, the helper is a no-op and simply awaits the original promise.
24
24
 
25
+ Finite delays above Node's single-timer limit (2,147,483,647 ms, about 24.9 days) are scheduled in bounded intervals without expiring early. The timer is still cleared if the wrapped promise settles first.
26
+
25
27
  ## Examples
26
28
 
27
29
  ### Simple ceiling
package/docs/types.md CHANGED
@@ -99,6 +99,7 @@ type ReadResult = {
99
99
  type RenameIdentityPolicy = "strict" | "verify-content-with-lock";
100
100
 
101
101
  type RootDefaults = {
102
+ assertBeforeMutation?: () => void;
102
103
  denyMutations?: DenyMutationPolicy;
103
104
  durable?: boolean; // default true for write/create/writeJson/createJson/append
104
105
  hardlinks?: "reject" | "allow";
@@ -107,7 +108,8 @@ type RootDefaults = {
107
108
  mode?: number;
108
109
  nonBlockingRead?: boolean;
109
110
  renameIdentity?: RenameIdentityPolicy;
110
- symlinks?: "reject" | "follow-within-root";
111
+ symlinks?: "reject" | "follow-within-root" | "follow-parents-within-root";
112
+ mutationSymlinks?: MutationSymlinkPolicy;
111
113
  };
112
114
 
113
115
  type DenyMutationPolicy = {
@@ -121,20 +123,20 @@ type RootOptions = {
121
123
  };
122
124
  ```
123
125
 
124
- `RootDefaults` is what `root(rootDir, defaults)` accepts. See [`root()`](root.md) for the per-method options that override these. `denyMutations` is the exception: root and per-call deny entries are merged.
126
+ `RootDefaults` is what `root(rootDir, defaults)` accepts. See [`root()`](root.md) for the per-method options that override these. `denyMutations` and `assertBeforeMutation` are exceptions: deny entries are merged, and the root authority assertion runs before the per-call assertion.
125
127
 
126
128
  ## `RootReadOptions` / `RootWriteOptions` / `RootCopyOptions`
127
129
 
128
130
  ```ts
129
131
  type RootReadOptions = Pick<RootDefaults, "hardlinks" | "maxBytes" | "nonBlockingRead" | "symlinks">;
130
- type RootWriteOptions = Pick<RootDefaults, "denyMutations" | "durable" | "mkdir" | "mode" | "renameIdentity"> & {
132
+ type RootWriteOptions = Pick<RootDefaults, "assertBeforeMutation" | "denyMutations" | "durable" | "mkdir" | "mode" | "renameIdentity" | "mutationSymlinks"> & {
131
133
  encoding?: BufferEncoding;
132
134
  overwrite?: boolean;
133
135
  };
134
- type RootCopyOptions = Pick<RootDefaults, "denyMutations" | "maxBytes" | "mkdir" | "mode"> & {
136
+ type RootCopyOptions = Pick<RootDefaults, "assertBeforeMutation" | "denyMutations" | "durable" | "maxBytes" | "mkdir" | "mode" | "mutationSymlinks"> & {
135
137
  sourceHardlinks?: "reject" | "allow";
136
138
  };
137
- type RootOpenWritableOptions = Pick<RootDefaults, "denyMutations" | "mkdir" | "mode"> & {
139
+ type RootOpenWritableOptions = Pick<RootDefaults, "assertBeforeMutation" | "denyMutations" | "mkdir" | "mode" | "mutationSymlinks"> & {
138
140
  writeMode?: "replace" | "append" | "update";
139
141
  };
140
142
  type RootWriteJsonOptions = RootWriteOptions & {
@@ -145,23 +147,28 @@ type RootWriteJsonOptions = RootWriteOptions & {
145
147
  type RootAppendOptions = RootWriteOptions & {
146
148
  prependNewlineIfNeeded?: boolean;
147
149
  };
148
- type RootMoveOptions = Pick<RootDefaults, "denyMutations"> & {
150
+ type RootMoveOptions = Pick<RootDefaults, "assertBeforeMutation" | "denyMutations" | "mutationSymlinks"> & {
149
151
  overwrite?: boolean;
150
152
  };
151
- type RootRemoveOptions = Pick<RootDefaults, "denyMutations">;
152
- type RootMkdirOptions = Pick<RootDefaults, "denyMutations">;
153
+ type RootRemoveOptions = Pick<RootDefaults, "assertBeforeMutation" | "denyMutations" | "mutationSymlinks">;
154
+ type RootMkdirOptions = Pick<RootDefaults, "assertBeforeMutation" | "denyMutations" | "mutationSymlinks">;
153
155
  ```
154
156
 
155
157
  Per-method option shapes. Each picks the `RootDefaults` keys that apply, plus method-specific extras.
156
158
 
157
- ## `SymlinkPolicy` / `HardlinkPolicy`
159
+ ## `SymlinkPolicy` / `MutationSymlinkPolicy` / `HardlinkPolicy`
158
160
 
159
161
  ```ts
160
- type SymlinkPolicy = "reject" | "follow-within-root";
162
+ type SymlinkPolicy = "reject" | "follow-within-root" | "follow-parents-within-root";
163
+ type MutationSymlinkPolicy = "reject" | "follow-parents-within-root";
161
164
  type HardlinkPolicy = "reject" | "allow";
162
165
  ```
163
166
 
164
- The two policy unions you'll see throughout. `"reject"` is conservative; `"follow-within-root"` allows symlinks whose final target is still inside the root; `"allow"` (hardlinks only) is permissive. Defaults for both symlinks and hardlinks are `"reject"`; switch hardlinks to `"allow"` only when you intentionally accept hardlink aliases.
167
+ `"reject"` is conservative; `"follow-within-root"` allows symlinks whose final target is still inside the root; `"allow"` (hardlinks only) is permissive. Defaults for read symlinks and hardlinks are `"reject"`; switch hardlinks to `"allow"` only when you intentionally accept hardlink aliases.
168
+
169
+ `"follow-parents-within-root"` allows contained parent directory aliases while
170
+ rejecting final symlinks. Mutation policy is opt-in and independent of read
171
+ policy; omission preserves each mutation method's existing behavior.
165
172
 
166
173
  ## `FsSafeErrorCode` / `FsSafeErrorCategory`
167
174
 
package/docs/walk.md CHANGED
@@ -72,10 +72,57 @@ Unreadable directories are skipped rather than throwing, but every skipped direc
72
72
  `Root.walk(rel, options)` is the root-bounded counterpart to these standalone
73
73
  inventory helpers. It yields `{ relativePath, kind, size }` incrementally and
74
74
  accepts `maxDepth`, `maxEntries`, `symlinkPolicy: "skip" |
75
- "follow-within-root"`, and an `AbortSignal`. The default budget behavior yields
75
+ "follow-within-root"`, `order: "sorted" | "filesystem"`, and an `AbortSignal`. The default budget behavior yields
76
76
  one `kind: "truncated"` marker and ends; pass `limitBehavior: "throw"` for a
77
77
  typed `FsSafeError("too-large")` instead.
78
78
 
79
+ For followed symlinks, both `kind` and `size` describe the resolved target.
80
+
81
+ The default `order: "sorted"` visits each directory's names in lexicographic
82
+ order before descending depth first. It reads and sorts all names in each
83
+ visited directory. With `maxEntries`, it prepares small metadata batches capped
84
+ by the remaining global entry budget. Every batch stops at the first directory
85
+ or symlink, so recursive descent cannot spend a budget already used by later
86
+ siblings. An early `break` may leave metadata from the current batch unused;
87
+ the total still stays within `maxEntries`. Filtering requires metadata and
88
+ consumes the entry budget, including entries skipped by the filter.
89
+
90
+ Without `maxEntries`, sorted walks reuse a full directory metadata snapshot
91
+ from the `Root.list()` owner. This preserves the existing fast complete-scan
92
+ behavior and its snapshot semantics: changes made after a directory is listed
93
+ do not alter its already-captured entries. Supply an entry budget or use
94
+ filesystem order when metadata work must remain incremental. Sorted entries
95
+ describe the observations captured in their directory snapshot or batch.
96
+
97
+ Use `order: "filesystem"` when a wide directory must not be fully enumerated:
98
+
99
+ ```ts
100
+ for await (const entry of capability.walk("", {
101
+ order: "filesystem",
102
+ maxEntries: 128,
103
+ symlinkPolicy: "skip",
104
+ })) {
105
+ consume(entry);
106
+ }
107
+ ```
108
+
109
+ This order follows the filesystem's directory stream and is not deterministic.
110
+ It reads one entry at a time, including one name of lookahead to distinguish an
111
+ exactly exhausted budget from truncation. The lookahead does not request full
112
+ entry metadata from fs-safe, and an early `break` does not prefetch later child
113
+ metadata. If a filesystem does not supply directory-entry
114
+ types, Node may classify that one extra entry with a synchronous `lstat`.
115
+ Handles close on completion, truncation, cancellation, errors, or an
116
+ early `break`. Both orders keep the same depth-first traversal, entry filtering,
117
+ and truncation rules. Cancellation is checked between entries, with event-loop
118
+ handoffs between budgeted sorted batches. Root and directory checks and admitted
119
+ child metadata reads are synchronous; no mode can interrupt a filesystem
120
+ syscall already in progress or the sorted mode's name sorting.
121
+
122
+ If a thrown walk failure and directory close both fail, disposal throws a
123
+ `SuppressedError` with the close failure in `error` and the original failure in
124
+ `suppressed`, preserving both causes.
125
+
79
126
  `entryFilter` is evaluated for each resolved file, directory, or other entry:
80
127
 
81
128
  ```ts
@@ -109,10 +156,12 @@ Every examined directory entry consumes `maxEntries` before filtering, so
109
156
  `"truncated"` markers describe already-reached state and do not authorize
110
157
  further descent.
111
158
 
112
- The pure-Node path validates every directory canonically inside the root,
113
- revalidates each listing through the normal `Root.list()` boundary, and tracks
114
- canonical directories to stop symlink cycles. It does not hold a descriptor
115
- for the entire tree, so it is not a process sandbox against a hostile peer that
159
+ The pure-Node path validates every directory through the Root boundary, pins
160
+ its exact identity, and rechecks it and the Root identity around each metadata
161
+ batch or individual filesystem-order observation. Sorted batches contain no
162
+ await or caller code between their before/after checks. It tracks canonical
163
+ directories to stop symlink cycles.
164
+ Neither mode holds a descriptor for every path component, so it is not a process sandbox against a hostile peer that
116
165
  can continuously swap and restore directories. Each individual lookup retains
117
166
  the documented Node `Root` boundary checks.
118
167
 
package/docs/writing.md CHANGED
@@ -38,6 +38,12 @@ await fs.mkdir("snapshots/2026/05");
38
38
  Private sibling temporary names are independent of the destination basename,
39
39
  so staging does not add a suffix to an otherwise valid long filename.
40
40
 
41
+ Write paths reject `path-alias` when symlinks and parent components select a
42
+ different target before and after lexical normalization, such as `link/../file`
43
+ where `link` points into a deeper directory. This avoids silently modifying the
44
+ wrong file. Resolve an intended alias explicitly with `Root.resolve()` before
45
+ passing its canonical path to a mutation.
46
+
41
47
  A failure before the final rename leaves the destination at its previous
42
48
  contents. A successful rename publishes the complete replacement. This
43
49
  old-or-new guarantee does not apply to `append()` or `openWritable()`, which
@@ -48,6 +54,10 @@ Post-publication verification can still reject after a complete replacement has
48
54
  been committed. Rejection does not promise that a successful rename was rolled
49
55
  back; the published file or a raced replacement may remain at the destination.
50
56
 
57
+ Failed-write cleanup compares exact parent and file identities, including large
58
+ Windows file indexes. Replaced paths and paths whose ownership cannot be verified
59
+ are preserved.
60
+
51
61
  ## Denying mutations
52
62
 
53
63
  All mutation verbs accept `denyMutations?: DenyMutationPolicy`, either as a root default or per-call option:
@@ -99,8 +109,9 @@ writes but skips file and parent-directory fsync calls. Create-only and append
99
109
  publication behavior, permissions, identity checks, and error codes are unchanged.
100
110
  Use it only for reconstructible data: a crash may lose the write or leave the
101
111
  previous file. `move` and streaming `openWritable` do not use this option.
102
- The existing pure-JavaScript Windows writer performs no fsync calls in either
103
- setting; native Windows writes honor the option. Directory sync remains best-effort.
112
+ Native and pure-JavaScript Windows writers honor the option. Replacement writes
113
+ sync staged content before rename and the final mode through the retained file
114
+ handle. Directory sync remains best-effort.
104
115
 
105
116
  POSIX modes without read permission, including `0o000` and `0o200`, succeed:
106
117
  final verification uses a descriptor retained by the writer rather than reopening
@@ -202,10 +213,17 @@ await fs.move("incoming/foo.txt", "archive/foo.txt", { overwrite: true });
202
213
 
203
214
  Both `from` and `to` are bounded; `..` in either is rejected.
204
215
 
216
+ The JavaScript fallback checks both parent directories before and after the
217
+ rename. A failed post-operation check rejects even though the rename may
218
+ already have completed; rejection does not imply rollback.
219
+
205
220
  ### `fs.remove(rel)`
206
221
 
207
222
  Unlink a file or `rmdir` an empty directory. Non-empty directories throw `not-empty`. For atomic directory replacement, use [`replaceDirectoryAtomic`](atomic.md#replacedirectoryatomic).
208
223
 
224
+ The JavaScript fallback reports failed parent-directory checks after removal.
225
+ The entry may already have been removed when this verification rejects.
226
+
209
227
  ```ts
210
228
  await fs.remove("logs/yesterday.log");
211
229
  await fs.remove("snapshots/empty-dir"); // ok
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "keywords": [
6
6
  "filesystem",
@@ -46,6 +46,10 @@
46
46
  "types": "./dist/root.d.ts",
47
47
  "default": "./dist/root.js"
48
48
  },
49
+ "./copy": {
50
+ "types": "./dist/copy.d.ts",
51
+ "default": "./dist/copy.js"
52
+ },
49
53
  "./config": {
50
54
  "types": "./dist/config.d.ts",
51
55
  "default": "./dist/config.js"
@@ -126,6 +130,7 @@
126
130
  },
127
131
  "scripts": {
128
132
  "benchmark": "node scripts/benchmark.mjs",
133
+ "benchmark:methods": "node benchmarks/runner.mjs",
129
134
  "benchmark:publish": "pnpm build && node scripts/bench-publish.mjs",
130
135
  "build": "node scripts/prepack-build.mjs",
131
136
  "lint:file-size": "node scripts/check-file-size.mjs",
@@ -156,13 +161,13 @@
156
161
  "archive:producer-smoke": "node scripts/archive-producer-smoke.mjs"
157
162
  },
158
163
  "optionalDependencies": {
159
- "@openclaw/fs-safe-darwin-arm64": "0.9.0",
160
- "@openclaw/fs-safe-darwin-x64": "0.9.0",
161
- "@openclaw/fs-safe-linux-arm64-gnu": "0.9.0",
162
- "@openclaw/fs-safe-linux-arm64-musl": "0.9.0",
163
- "@openclaw/fs-safe-linux-x64-gnu": "0.9.0",
164
- "@openclaw/fs-safe-linux-x64-musl": "0.9.0",
165
- "@openclaw/fs-safe-win32-x64-msvc": "0.9.0",
164
+ "@openclaw/fs-safe-darwin-arm64": "0.10.0",
165
+ "@openclaw/fs-safe-darwin-x64": "0.10.0",
166
+ "@openclaw/fs-safe-linux-arm64-gnu": "0.10.0",
167
+ "@openclaw/fs-safe-linux-arm64-musl": "0.10.0",
168
+ "@openclaw/fs-safe-linux-x64-gnu": "0.10.0",
169
+ "@openclaw/fs-safe-linux-x64-musl": "0.10.0",
170
+ "@openclaw/fs-safe-win32-x64-msvc": "0.10.0",
166
171
  "jszip": "^3.10.2"
167
172
  },
168
173
  "devDependencies": {