@openclaw/fs-safe 0.10.0 → 0.11.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 (259) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/LICENSE +1 -0
  3. package/README.md +36 -5
  4. package/dist/absolute-path.d.ts.map +1 -1
  5. package/dist/absolute-path.js +3 -2
  6. package/dist/advanced.d.ts +4 -0
  7. package/dist/advanced.d.ts.map +1 -1
  8. package/dist/advanced.js +4 -0
  9. package/dist/archive-durability.d.ts +6 -6
  10. package/dist/archive-durability.d.ts.map +1 -1
  11. package/dist/archive-durability.js +1 -1
  12. package/dist/archive-entry.d.ts.map +1 -1
  13. package/dist/archive-entry.js +4 -5
  14. package/dist/archive-gzip-tail.d.ts +1 -0
  15. package/dist/archive-gzip-tail.d.ts.map +1 -1
  16. package/dist/archive-gzip-tail.js +3 -0
  17. package/dist/archive-input.d.ts.map +1 -1
  18. package/dist/archive-input.js +4 -2
  19. package/dist/archive-merge.d.ts +5 -1
  20. package/dist/archive-merge.d.ts.map +1 -1
  21. package/dist/archive-merge.js +15 -12
  22. package/dist/archive-native.js +4 -4
  23. package/dist/archive-parser.wasm +0 -0
  24. package/dist/archive-read.d.ts.map +1 -1
  25. package/dist/archive-read.js +17 -9
  26. package/dist/archive-staging.d.ts +6 -3
  27. package/dist/archive-staging.d.ts.map +1 -1
  28. package/dist/archive-staging.js +42 -22
  29. package/dist/archive-tar-stream.d.ts.map +1 -1
  30. package/dist/archive-tar-stream.js +7 -6
  31. package/dist/archive-tar-wasm.d.ts.map +1 -1
  32. package/dist/archive-tar-wasm.js +16 -13
  33. package/dist/archive-zip-admission.d.ts +1 -1
  34. package/dist/archive-zip-admission.d.ts.map +1 -1
  35. package/dist/archive-zip-admission.js +48 -12
  36. package/dist/archive-zip-loader.d.ts +6 -0
  37. package/dist/archive-zip-loader.d.ts.map +1 -0
  38. package/dist/archive-zip-loader.js +38 -0
  39. package/dist/archive-zip-names.d.ts.map +1 -1
  40. package/dist/archive-zip-names.js +13 -8
  41. package/dist/archive-zip-preflight.d.ts +2 -3
  42. package/dist/archive-zip-preflight.d.ts.map +1 -1
  43. package/dist/archive-zip-preflight.js +2 -34
  44. package/dist/archive.d.ts.map +1 -1
  45. package/dist/archive.js +12 -10
  46. package/dist/bounded-read.d.ts +5 -0
  47. package/dist/bounded-read.d.ts.map +1 -1
  48. package/dist/bounded-read.js +16 -9
  49. package/dist/copy-file-input.d.ts +0 -1
  50. package/dist/copy-file-input.d.ts.map +1 -1
  51. package/dist/copy-file-input.js +4 -26
  52. package/dist/copy-tree-portable.d.ts.map +1 -1
  53. package/dist/copy-tree-portable.js +57 -26
  54. package/dist/copy.d.ts +1 -1
  55. package/dist/copy.d.ts.map +1 -1
  56. package/dist/copy.js +3 -1
  57. package/dist/directory-durability.d.ts.map +1 -1
  58. package/dist/directory-durability.js +5 -4
  59. package/dist/directory-guard.d.ts +11 -1
  60. package/dist/directory-guard.d.ts.map +1 -1
  61. package/dist/directory-guard.js +53 -11
  62. package/dist/durability.d.ts +1 -1
  63. package/dist/durability.d.ts.map +1 -1
  64. package/dist/durability.js +1 -1
  65. package/dist/file-handle-transfer.d.ts +14 -0
  66. package/dist/file-handle-transfer.d.ts.map +1 -0
  67. package/dist/file-handle-transfer.js +64 -0
  68. package/dist/file-hash.d.ts +3 -0
  69. package/dist/file-hash.d.ts.map +1 -1
  70. package/dist/file-hash.js +91 -31
  71. package/dist/file-lock-sync.d.ts.map +1 -1
  72. package/dist/file-lock-sync.js +8 -4
  73. package/dist/file-store-boundary.d.ts.map +1 -1
  74. package/dist/file-store-boundary.js +7 -5
  75. package/dist/file-store-path.d.ts +3 -0
  76. package/dist/file-store-path.d.ts.map +1 -0
  77. package/dist/file-store-path.js +27 -0
  78. package/dist/file-store-prune.d.ts.map +1 -1
  79. package/dist/file-store-prune.js +6 -4
  80. package/dist/file-store-sync-write.d.ts.map +1 -1
  81. package/dist/file-store-sync-write.js +56 -44
  82. package/dist/file-store.d.ts.map +1 -1
  83. package/dist/file-store.js +2 -18
  84. package/dist/filename.d.ts.map +1 -1
  85. package/dist/filename.js +2 -1
  86. package/dist/guarded-mkdir.d.ts.map +1 -1
  87. package/dist/guarded-mkdir.js +3 -2
  88. package/dist/guest-dispatch-python.d.ts +2 -0
  89. package/dist/guest-dispatch-python.d.ts.map +1 -0
  90. package/dist/guest-dispatch-python.js +117 -0
  91. package/dist/guest-native-python.d.ts +4 -0
  92. package/dist/guest-native-python.d.ts.map +1 -0
  93. package/dist/guest-native-python.js +135 -0
  94. package/dist/guest.d.ts +9 -0
  95. package/dist/guest.d.ts.map +1 -0
  96. package/dist/guest.js +413 -0
  97. package/dist/index.d.ts +1 -1
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/install-path.d.ts.map +1 -1
  100. package/dist/install-path.js +3 -2
  101. package/dist/json-durable-queue-directory.js +3 -3
  102. package/dist/json-durable-queue.d.ts.map +1 -1
  103. package/dist/json-durable-queue.js +6 -4
  104. package/dist/json.d.ts.map +1 -1
  105. package/dist/json.js +2 -1
  106. package/dist/local-roots.d.ts.map +1 -1
  107. package/dist/local-roots.js +2 -1
  108. package/dist/move-path-stage.d.ts.map +1 -1
  109. package/dist/move-path-stage.js +2 -1
  110. package/dist/move-path.d.ts.map +1 -1
  111. package/dist/move-path.js +4 -3
  112. package/dist/mutation-authority.d.ts +1 -0
  113. package/dist/mutation-authority.d.ts.map +1 -1
  114. package/dist/mutation-authority.js +4 -4
  115. package/dist/native-binding.d.ts +7 -2
  116. package/dist/native-binding.d.ts.map +1 -1
  117. package/dist/native-pinned-write-windows.d.ts.map +1 -1
  118. package/dist/native-pinned-write-windows.js +3 -2
  119. package/dist/native-pinned-write.d.ts.map +1 -1
  120. package/dist/native-pinned-write.js +2 -1
  121. package/dist/opened-realpath.d.ts.map +1 -1
  122. package/dist/opened-realpath.js +5 -4
  123. package/dist/output.d.ts +2 -0
  124. package/dist/output.d.ts.map +1 -1
  125. package/dist/output.js +2 -0
  126. package/dist/overwrite-file-handle.d.ts +8 -0
  127. package/dist/overwrite-file-handle.d.ts.map +1 -0
  128. package/dist/overwrite-file-handle.js +42 -0
  129. package/dist/path-case.d.ts +7 -0
  130. package/dist/path-case.d.ts.map +1 -0
  131. package/dist/path-case.js +136 -0
  132. package/dist/path.d.ts.map +1 -1
  133. package/dist/path.js +2 -1
  134. package/dist/permissions-windows.d.ts +1 -1
  135. package/dist/permissions-windows.d.ts.map +1 -1
  136. package/dist/permissions-windows.js +48 -6
  137. package/dist/pinned-open.d.ts.map +1 -1
  138. package/dist/pinned-open.js +3 -1
  139. package/dist/pinned-write.d.ts +2 -2
  140. package/dist/pinned-write.d.ts.map +1 -1
  141. package/dist/pinned-write.js +2 -1
  142. package/dist/private-temp-workspace.d.ts.map +1 -1
  143. package/dist/private-temp-workspace.js +6 -4
  144. package/dist/realpath.d.ts +4 -0
  145. package/dist/realpath.d.ts.map +1 -0
  146. package/dist/realpath.js +43 -0
  147. package/dist/recursive-mkdir-path.d.ts +3 -0
  148. package/dist/recursive-mkdir-path.d.ts.map +1 -0
  149. package/dist/recursive-mkdir-path.js +8 -0
  150. package/dist/replace-directory.d.ts.map +1 -1
  151. package/dist/replace-directory.js +2 -1
  152. package/dist/replace-file-copy-fallback.d.ts.map +1 -1
  153. package/dist/replace-file-copy-fallback.js +23 -31
  154. package/dist/replace-file-copy-source.d.ts.map +1 -1
  155. package/dist/replace-file-copy-source.js +7 -12
  156. package/dist/replace-file-mode.d.ts +3 -0
  157. package/dist/replace-file-mode.d.ts.map +1 -0
  158. package/dist/replace-file-mode.js +10 -0
  159. package/dist/replace-file.d.ts +1 -0
  160. package/dist/replace-file.d.ts.map +1 -1
  161. package/dist/replace-file.js +14 -8
  162. package/dist/root-context.d.ts.map +1 -1
  163. package/dist/root-context.js +8 -8
  164. package/dist/root-create-input.d.ts +10 -0
  165. package/dist/root-create-input.d.ts.map +1 -0
  166. package/dist/root-create-input.js +80 -0
  167. package/dist/root-directory-list.d.ts +3 -1
  168. package/dist/root-directory-list.d.ts.map +1 -1
  169. package/dist/root-directory-list.js +21 -3
  170. package/dist/root-entries.d.ts +11 -0
  171. package/dist/root-entries.d.ts.map +1 -0
  172. package/dist/root-entries.js +61 -0
  173. package/dist/root-errors.d.ts +5 -5
  174. package/dist/root-errors.d.ts.map +1 -1
  175. package/dist/root-errors.js +13 -12
  176. package/dist/root-impl.d.ts +13 -3
  177. package/dist/root-impl.d.ts.map +1 -1
  178. package/dist/root-impl.js +40 -36
  179. package/dist/root-options.d.ts +12 -1
  180. package/dist/root-options.d.ts.map +1 -1
  181. package/dist/root-path-existing.d.ts.map +1 -1
  182. package/dist/root-path-existing.js +4 -3
  183. package/dist/root-path-symlink.d.ts.map +1 -1
  184. package/dist/root-path-symlink.js +3 -2
  185. package/dist/root-paths.d.ts.map +1 -1
  186. package/dist/root-paths.js +13 -9
  187. package/dist/root-remove.d.ts +5 -0
  188. package/dist/root-remove.d.ts.map +1 -0
  189. package/dist/root-remove.js +286 -0
  190. package/dist/root-symlink-policy.d.ts +2 -1
  191. package/dist/root-symlink-policy.d.ts.map +1 -1
  192. package/dist/root-symlink-policy.js +2 -2
  193. package/dist/root-write-mode.d.ts.map +1 -1
  194. package/dist/root-write-mode.js +2 -1
  195. package/dist/root.d.ts +2 -1
  196. package/dist/root.d.ts.map +1 -1
  197. package/dist/secret-file.d.ts.map +1 -1
  198. package/dist/secret-file.js +2 -1
  199. package/dist/secret-read-async.d.ts.map +1 -1
  200. package/dist/secret-read-async.js +2 -1
  201. package/dist/secure-file.d.ts.map +1 -1
  202. package/dist/secure-file.js +21 -4
  203. package/dist/secure-temp-dir.d.ts.map +1 -1
  204. package/dist/secure-temp-dir.js +2 -1
  205. package/dist/sibling-staged-file.d.ts +1 -0
  206. package/dist/sibling-staged-file.d.ts.map +1 -1
  207. package/dist/sibling-staged-file.js +42 -8
  208. package/dist/sibling-temp.d.ts +2 -0
  209. package/dist/sibling-temp.d.ts.map +1 -1
  210. package/dist/sibling-temp.js +6 -4
  211. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  212. package/dist/sidecar-lock-acquire.js +15 -4
  213. package/dist/sidecar-lock-policy.d.ts +2 -0
  214. package/dist/sidecar-lock-policy.d.ts.map +1 -1
  215. package/dist/sidecar-lock-policy.js +17 -0
  216. package/dist/staged-directory.d.ts.map +1 -1
  217. package/dist/staged-directory.js +4 -3
  218. package/dist/temp-target.d.ts +14 -12
  219. package/dist/temp-target.d.ts.map +1 -1
  220. package/dist/temp-target.js +12 -6
  221. package/dist/trash.d.ts.map +1 -1
  222. package/dist/trash.js +7 -5
  223. package/dist/unicode-path.d.ts +3 -0
  224. package/dist/unicode-path.d.ts.map +1 -0
  225. package/dist/unicode-path.js +13 -0
  226. package/dist/walk.d.ts.map +1 -1
  227. package/dist/walk.js +3 -2
  228. package/dist/write-file-handle.d.ts +1 -0
  229. package/dist/write-file-handle.d.ts.map +1 -1
  230. package/dist/write-file-handle.js +3 -2
  231. package/docs/advanced.md +4 -0
  232. package/docs/archive.md +25 -5
  233. package/docs/atomic.md +17 -1
  234. package/docs/config.md +1 -0
  235. package/docs/contributing.md +29 -1
  236. package/docs/copy.md +75 -6
  237. package/docs/directory-identity.md +85 -0
  238. package/docs/durability.md +36 -1
  239. package/docs/entries.md +109 -0
  240. package/docs/errors.md +3 -3
  241. package/docs/file-store.md +15 -0
  242. package/docs/guest.md +141 -0
  243. package/docs/in-place-write.md +81 -0
  244. package/docs/index.md +2 -0
  245. package/docs/install.md +31 -0
  246. package/docs/native-helper.md +10 -3
  247. package/docs/native.md +4 -0
  248. package/docs/output.md +32 -6
  249. package/docs/path-case.md +64 -0
  250. package/docs/path-scope.md +1 -1
  251. package/docs/permissions.md +11 -2
  252. package/docs/public-api.md +31 -2
  253. package/docs/root.md +30 -2
  254. package/docs/secure-file.md +2 -0
  255. package/docs/sidecar-lock.md +12 -3
  256. package/docs/temp.md +35 -6
  257. package/docs/types.md +1 -1
  258. package/docs/writing.md +153 -3
  259. package/package.json +14 -8
package/docs/archive.md CHANGED
@@ -126,6 +126,12 @@ untrusted authority rejects explicitly instead of silently accepting a wrong
126
126
  mode. Other unsupported search-only routes also fail closed. Windows retains
127
127
  its existing bounded lack of POSIX mode enforcement.
128
128
 
129
+ Extraction and TAR inspection first copy the admitted source into a private
130
+ staging file. This copy reuses at most 512 KiB of scratch space, reduced for
131
+ small inputs and capped by the archive byte limit plus one overflow-probe byte.
132
+ Each read stays within the remaining budget plus that probe; deadline checks
133
+ surround reads, and short writes finish before the buffer is reused.
134
+
129
135
  Native extraction is deliberately split into two phases. Rust first reports an
130
136
  entry manifest without creating paths. TypeScript validates paths, applies
131
137
  `stripComponents`, filters, limits, and mode policy, then passes an explicit
@@ -133,7 +139,7 @@ accepted-entry plan back to Rust. Rust owns raw-stream admission, decompression,
133
139
  and fd-relative `mkdirBeneath`/exclusive-open writes. This keeps filter policy identical
134
140
  between native and JavaScript paths rather than reimplementing it in Rust.
135
141
 
136
- ZIP extraction and bounded reads admit every physical central-directory record and its referenced local header before either decoder can normalize or collapse names. Raw names and valid Unicode Path names must pass traversal checks before stripping, filtering, or selecting a requested member; duplicate or colliding names reject with `entry-path`, even in unrelated or skipped members. Materially conflicting local/central or Unicode interpretations, malformed critical metadata, and ambiguous framing reject with `ArchiveFormatError`. Harmless separator and dot-component equivalence is allowed only after validation. Ordinary legacy filename decoding remains backend-selected.
142
+ ZIP extraction and bounded reads admit every physical central-directory record and its referenced local header before either decoder can normalize or collapse names. Raw names and valid Unicode Path names must pass traversal checks before stripping, filtering, or selecting a requested member; duplicate or colliding names reject with `entry-path`, even in unrelated or skipped members. Materially conflicting local/central or Unicode interpretations, malformed critical metadata, and ambiguous framing reject with `ArchiveFormatError`. Harmless separator and dot-component equivalence is allowed only after validation. Ordinary legacy filename decoding remains backend-selected. Native ZIP extraction groups nearby metadata reads into at most two 4 KiB read-ahead buffers per admission pass; larger records retain separately bounded reads. Buffered record views remain stable across eviction, and cached work periodically yields for deadline checks.
137
143
 
138
144
  `stripComponents` removes leading nonempty, non-`.` path components after
139
145
  normalizing separators. For example, `./pkg/hello.txt` with
@@ -222,6 +228,13 @@ record and are then cleared; local PAX on unsupported types and GNU sparse
222
228
 
223
229
  If `kind` is omitted, the helper calls `resolveArchiveKind(archivePath)` and throws if the extension is not recognized. Pass `kind` explicitly when the archive name doesn't carry the type (e.g. content-addressed names). Archive inputs must remain regular files from preview through descriptor admission; POSIX opens are no-follow and nonblocking, so a FIFO swap cannot stall before deadline checks resume. A positive finite `timeoutMs` is a wall-clock budget; zero, negative, `NaN`, and infinity disable the deadline. Non-mutating work rejects promptly when the budget expires. If a live destination mutation is already in flight, rejection waits only for that mutation and any rollback to finish; no later destination mutation can begin.
224
230
 
231
+ Extraction captures the destination's lossless filesystem identity before any
232
+ entry filter runs and retains that capability through final publication. If a
233
+ filter or concurrent actor renames or replaces the destination, extraction
234
+ rejects with `destination-symlink-traversal` before publishing into the
235
+ replacement. This check uses bigint device and inode identities so large native
236
+ identifiers cannot compare equal after JavaScript number rounding.
237
+
225
238
  The destination merge is nontransactional: each file is published atomically,
226
239
  but completed files and directories can remain when a later copy, post-copy
227
240
  check, mode application, or deadline fails. This also applies to
@@ -354,7 +367,9 @@ bypass validation. Decompression remains streaming; no complete decoded archive
354
367
  is retained in memory or written to a decoded spool.
355
368
 
356
369
  The WASM transport has a fixed 64 KiB input buffer, one pending member event,
357
- and a 256 MiB maximum linear memory per isolated parser instance. Metadata is
370
+ and a 256 MiB maximum linear memory per isolated parser instance. JavaScript
371
+ gzip decoding emits chunks of at most 64 KiB for both staged files and buffered
372
+ inputs, matching that input window. Metadata is
358
373
  bounded before allocation; allocation failure rejects. Stream backpressure
359
374
  bounds queued chunks, and completion/error destroys the instance's parser
360
375
  state. The manifest retains the existing charged budget below; linear memory
@@ -574,7 +589,9 @@ inputs retain the archive subpath's 256 MiB compressed-input ceiling.
574
589
  With a native binding it uses the same Rust decoders as extraction, including
575
590
  zstd and bzip2 TAR. Without native it retains the JS ZIP/TAR/gzip implementation.
576
591
  Archive member reads retain their private in-memory input without a disk
577
- snapshot. The native ZIP reader retains the private allocation and parsed directory across worker-thread
592
+ snapshot. JavaScript ZIP member reads reuse their completed physical admission
593
+ when loading the decoder, which still checks its decoded names and entry count.
594
+ The native ZIP reader retains the private allocation and parsed directory across worker-thread
578
595
  inspection and reading without an extra archive-byte copy.
579
596
  Decompression still allocates its bounded output; Node receives that native
580
597
  allocation without another copy where external buffers are supported.
@@ -582,8 +599,11 @@ Native TAR retains the fully admitted member offsets alongside the same input
582
599
  allocation. Plain TAR copies only the selected payload range after full archive
583
600
  validation. Gzip, zstd, and bzip2 replay bounded decompression and still validate
584
601
  all framing, trailers, and physical padding before returning. The JavaScript
585
- TAR/gzip fallback streams views of the private input into the shared WASM parser
586
- for admission and replay; WASM transport and selected output still require copies.
602
+ TAR/gzip fallback copies each input window into WASM once, consuming member
603
+ events at offsets within that window. After full admission, plain TAR copies the
604
+ selected range directly from its private snapshot; gzip still replays bounded
605
+ decompression through the parser. WASM transport and selected output still
606
+ require copies.
587
607
  Returned buffers own their bytes, so changing a result cannot modify an archive
588
608
  reader or retain an unrelated part of the input through its backing ArrayBuffer.
589
609
 
package/docs/atomic.md CHANGED
@@ -40,7 +40,7 @@ type ReplaceFileAtomicOptions = {
40
40
  content: string | Uint8Array;
41
41
  dirMode?: number; // parent-directory mode (POSIX; default 0o700)
42
42
  mode?: number; // new-file mode (default 0o600)
43
- preserveExistingMode?: boolean; // copy existing mode; default false
43
+ preserveExistingMode?: boolean; // inherit existing regular-file rwx bits; default false
44
44
  tempPrefix?: string; // default ".fs-safe-replace"
45
45
  renameMaxRetries?: number; // EBUSY retries; default 0
46
46
  renameRetryBaseDelayMs?: number; // exponential base; default 50
@@ -57,6 +57,17 @@ type ReplaceFileAtomicOptions = {
57
57
  };
58
58
  ```
59
59
 
60
+ `preserveExistingMode` snapshots only the ordinary rwx bits (`0o777`) from an
61
+ existing non-symlink regular destination. A final symlink fails with
62
+ `FsSafeError("symlink")`; a directory or other non-regular destination fails
63
+ with `FsSafeError("not-file")`. Set-user-ID, set-group-ID, and sticky bits are
64
+ never inherited. Mode inheritance does not copy ownership, ACLs, extended
65
+ attributes, or exact destination identity. Rename publication creates a new
66
+ inode; an in-place copy fallback can retain metadata already attached to its
67
+ pinned destination. The snapshot does not make replacement a compare-and-swap
68
+ operation, so the destination parent must still be protected from untrusted
69
+ concurrent namespace mutation.
70
+
60
71
  ### `beforeRename`
61
72
 
62
73
  Runs after the temp file is fully written and before the rename. Use it to take a backup snapshot, capture the about-to-be-replaced contents, or notify an observer. The helper retains the staged descriptor and exact bigint identity across the hook; replacing, deleting, hardlinking, or changing the temp entry to a non-regular file is rejected before publication:
@@ -135,6 +146,11 @@ than `maxRestoreBytes` fails with `too-large` before mutation. A missing
135
146
  destination has no original to restore and follows the exclusive-create copy
136
147
  fallback.
137
148
 
149
+ Restore snapshots use the pinned file's size as an allocation hint, with an
150
+ initial allocation capped at 16 MiB plus the overflow byte. Reads continue
151
+ through short reads and EOF, grow only as data arrives, and enforce the same
152
+ `maxRestoreBytes` budget even if the destination grows after its size was read.
153
+
138
154
  ### Sync variant
139
155
 
140
156
  `replaceFileAtomicSync` accepts the same base options, a synchronous
package/docs/config.md CHANGED
@@ -65,6 +65,7 @@ type FsSafeLockConfig = {
65
65
  Set process-wide defaults for sidecar lock options. This does **not** turn locking on globally; callers still need to pass `lock: true` or a lock options object for the specific JSON store/resource that needs cross-process coordination.
66
66
 
67
67
  `staleRecovery` defaults to `"fail-closed"`. The opt-in `"remove-if-unchanged"` mode requires caller approval and serializes the final snapshot check and unlink with an exclusive `.reclaim` guard. A reclaim guard left by a killed reclaimer fails closed and requires externally coordinated cleanup.
68
+ `staleMs` must be non-negative and not `NaN`; `Infinity` disables age-based staleness.
68
69
 
69
70
  For a daemon that should wait briefly for normal contention but never delete a
70
71
  stale owner without per-lock approval:
@@ -42,6 +42,32 @@ Vitest. Tests live in `test/` and follow `*.test.ts`. Run a single file with:
42
42
  pnpm test test/archive.test.ts
43
43
  ```
44
44
 
45
+ Guest filesystem tests and package smoke also need `python3` on Linux and
46
+ macOS. They execute the exported source on synthetic files; Windows checks
47
+ the import surface and leaves POSIX execution to the Linux/macOS lanes.
48
+ After building on Linux, `node scripts/check-pack.mjs --guest-cross-device`
49
+ also proves an installed-package directory move from temporary storage to
50
+ `/dev/shm`; the command requires those locations to be different filesystems.
51
+
52
+ With Bun 1.4.2 installed, build the host addon and run the native compatibility
53
+ lane in real Bun workers, then exercise the built package with JIT disabled:
54
+
55
+ ```bash
56
+ pnpm native:build
57
+ pnpm test:bun:native
58
+ bun --jitless scripts/bun-native-proof.mjs
59
+ ```
60
+
61
+ Keep the Node/pnpm build toolchain above. CI runs the native compatibility lane
62
+ on Linux, macOS, and Windows. The built-package proof checks `auto`/`require`,
63
+ native-off loading policy, and a separate installation without the addon.
64
+
65
+ `pnpm test:bun` runs the entire Node-oriented suite as a diagnostic. On released
66
+ Bun, its explicit native-off and missing-helper cases include unsupported
67
+ permission/path behavior described in [install](install.md#bun-runtime); this
68
+ command is not a passing compatibility gate. Node CI retains every fallback
69
+ assertion. Neither lane rewrites `off` to `auto` or marks defects as expected passes.
70
+
45
71
  Use `vi.mock` sparingly. Most tests should drive real disk operations in a `mkdtemp`-created scratch directory, asserting on observable behavior. The library has [test hooks](testing.md) for the rare cases where you need to inject a TOCTOU race deterministically.
46
72
 
47
73
  Vitest timeouts do not cancel filesystem promises. Shared fixtures with expensive
@@ -123,7 +149,9 @@ collection uses the actual seven collected native tarballs instead. Run it with
123
149
  `pnpm package:collect` after assembling all seven real bindings; missing targets
124
150
  fail collection. `pnpm package:collect --allow-host-only` exercises the same
125
151
  lifecycle boundary locally but proves only the host. Both collection commands
126
- require the pnpm lifecycle CLI path; direct `node` invocation is unsupported. Archive
152
+ require the pnpm lifecycle CLI path; JavaScript CLIs run through Node and standalone
153
+ `@pnpm/exe` binaries run directly. Shell/cmd shims and PATH fallback are not used;
154
+ direct `node` invocation without lifecycle metadata is unsupported. Archive
127
155
  codecs and their dependencies are packed from the installed dependency graph.
128
156
 
129
157
  PR CI builds and executes four host targets: Linux x64 glibc, Linux x64 musl
package/docs/copy.md CHANGED
@@ -26,8 +26,11 @@ if (backend) {
26
26
  | `btrfs` | One native writable subvolume snapshot | `createCloneSource` creates a subvolume; an ordinary directory is not a snapshot source. No `btrfs` executable is required. |
27
27
  | `refs` | Native directory traversal with parallel file block clones | `createCloneSource` creates an empty directory on ReFS, including Dev Drive volumes. |
28
28
  | `xfs` | Native directory traversal with parallel file reflinks | `createCloneSource` creates an empty directory. The XFS volume must support reflinks. |
29
+ | `zfs` | Native directory traversal with parallel file reflinks | `createCloneSource` creates an empty directory. Requires Linux OpenZFS file reflinks and the pool block-cloning feature. |
29
30
 
30
- Native cloning requires source and destination filesystems that support cloning between them. Automatic and ordinary copying can cross filesystems. The source repository used to populate a template can live elsewhere. ReFS and XFS share file data rather than the whole directory metadata tree, so creating many small files still has a cost.
31
+ Native cloning requires source and destination filesystems that support cloning between them. Automatic and ordinary copying can cross filesystems. The source repository used to populate a template can live elsewhere. ReFS, XFS, and ZFS share file data rather than the whole directory metadata tree, so creating many small files still has a cost.
32
+
33
+ ZFS uses strict file reflinks within one dataset, not dataset snapshots. The installed Linux OpenZFS version must implement `FICLONE`, and the pool must enable `feature@block_cloning`. The probe identifies ZFS even when that feature is unavailable; `clone: "always"` then fails and `"auto"` can copy bytes. Native cloning was verified on OpenZFS 2.4.1 with POSIX ACLs. See the [OpenZFS block-cloning contract](https://openzfs.github.io/openzfs-docs/Basic%20Concepts/Data%20Storage/Block%20Cloning.html) for filesystem limits and pool sharing counters.
31
34
 
32
35
  Btrfs preserves native subvolume snapshot semantics: nested subvolume contents are not included. Prepare source-only templates without nested subvolumes. This API does not recursively snapshot a hierarchy of subvolumes.
33
36
 
@@ -41,7 +44,7 @@ Apple [strongly discourages general directory cloning](https://github.com/apple-
41
44
 
42
45
  ## API
43
46
 
44
- `TreeCloneBackend` is the `"apfs" | "btrfs" | "refs" | "xfs"` union returned by the probe. `CopyTreeOptions` contains the optional `clone`, `signal`, and `concurrency` arguments. `CopyCloneMode` is the `"auto" | "always" | "never"` strategy shared with [`Root.copyIn`](root.md#writes); tree copies default to `"auto"`, while guarded file copies default to `"never"`.
47
+ `TreeCloneBackend` is the `"apfs" | "btrfs" | "refs" | "xfs" | "zfs"` union returned by the probe. `CopyTreeOptions` contains the optional `clone`, `signal`, and `concurrency` arguments. `CopyCloneMode` is the `"auto" | "always" | "never"` strategy shared with [`Root.copyIn`](root.md#writes); tree copies default to `"auto"`, while guarded file copies default to `"never"`.
45
48
 
46
49
  `probeTreeClone(parentPath)` synchronously inspects an existing directory and returns its supported backend name or `undefined`. It creates no probe artifacts. A filesystem name identifies a candidate backend; for example, an older XFS volume may have reflinks disabled. The actual operation determines availability. An unavailable native binding produces `undefined` in automatic mode; the package's explicit native `require` mode still reports a missing binding as an error.
47
50
 
@@ -57,18 +60,80 @@ Apple [strongly discourages general directory cloning](https://github.com/apple-
57
60
 
58
61
  Automatic copying does not recover from permission errors, I/O errors, cancellation, or rejected source contents such as ReFS named streams. A failed clone must leave the destination absent before fallback can create it; otherwise copying fails rather than merging into a partial tree.
59
62
 
60
- `concurrency` accepts integers from 1 through 32 and bounds active file copies. ReFS and XFS cloning default to 16 workers. Byte copying defaults to four concurrent files on Windows and one elsewhere. Btrfs uses its bulk operation. APFS uses a bulk clone followed by native directory-entry enumeration to restore directory timestamps; known regular files and symbolic links need no additional stat or open.
63
+ `concurrency` accepts integers from 1 through 32 and bounds active file copies. ReFS, XFS, and ZFS cloning default to 16 workers. Byte copying defaults to four concurrent files on Windows and one elsewhere. Btrfs uses its bulk operation. APFS uses a bulk clone followed by native directory-entry enumeration to restore directory timestamps; known regular files and symbolic links need no additional stat or open.
64
+
65
+ On Windows, automatic byte copying uses the native binding when available to transfer data between the already-checked file handles in chunks of at most 1 MiB, with smaller buffers for small files. This accelerates NTFS and cross-volume copies without reopening source or destination pathnames. The native worker finishes before its descriptors are closed or cancellation is reported. `clone: "never"` and native-disabled copies use JavaScript read/write loops with reusable buffers: at most 1 MiB per active file on Windows, or 128 KiB elsewhere. Both paths share the file-worker budget across sibling directories, wait for all admitted writes after cancellation or failure, and restore directory timestamps after their descendants finish. Completed directory traversals awaiting writes or metadata are bounded by concurrency; ancestors remain pinned while traversing their children.
61
66
 
62
- On Windows, automatic byte copying uses the native binding when available to transfer data between the already-checked file handles in 1 MiB chunks. This accelerates NTFS and cross-volume copies without reopening source or destination pathnames. The native worker finishes before its descriptors are closed or cancellation is reported. `clone: "never"` and native-disabled copies use JavaScript read/write loops with reusable buffers: at most 1 MiB per active file on Windows, or 128 KiB elsewhere. Both paths wait for all admitted writes after cancellation or failure and restore directory timestamps only after their file copies finish.
67
+ On Linux, automatic byte copying also uses the native binding when available. It reads in 1 MiB chunks and leaves leading and trailing zero-filled portions of each chunk unwritten in the new file, avoiding their allocation on filesystems that support sparse files. A final size update preserves trailing holes and all-zero files. This path uses reads and writes, without cloning or copy offload; it still reads the full logical contents and does not promise identical sparse extent layout. `clone: "never"` retains the JavaScript byte-copy path.
63
68
 
64
69
  Clones preserve file contents, empty directories, timestamps, executable modes where supported, and literal symbolic links. Editing a clone does not modify its source. Unsupported filesystem operations fail; callers may choose their own copy or checkout fallback after the failed operation has settled.
65
70
 
71
+ Native Windows byte copies can store large zero-filled chunks as sparse ranges when the destination is initially empty and its filesystem supports sparse files. This still reads every source byte and creates an independent copy.
72
+
66
73
  The ReFS backend rejects files with alternate data streams and unsupported reparse-point types instead of silently losing their contents. Symbolic links and junctions are preserved.
67
74
 
68
- XFS preserves regular-file and directory modes, timestamps, extended attributes, and ACLs. It rejects special files, symlink extended attributes, non-UTF-8 names, and directory nesting deeper than 128 levels. Hardlinked source files become independent reflinked files. Portable byte copying preserves file contents, empty directories, modes where supported, file and directory timestamps, and literal symbolic links; it does not promise ownership, ACL, extended-attribute, alternate-stream, or sparse-layout preservation. On Windows, byte copying rejects unresolved symbolic links because Node does not expose their file/directory link type; resolved links keep their literal target and source type. POSIX dangling links are preserved. Choose a copying policy that meets the caller's metadata requirements; automatic copying can select either path.
75
+ XFS and ZFS preserve regular-file and directory modes, timestamps, extended attributes, and ACLs. They reject special files, symlink extended attributes, non-UTF-8 names, and directory nesting deeper than 128 levels. Hardlinked source files become independent reflinked files. Portable byte copying preserves file contents, empty directories, modes where supported, file and directory timestamps, and literal symbolic links; it does not promise ownership, ACL, extended-attribute, alternate-stream, or sparse-layout preservation. On Windows, byte copying rejects unresolved symbolic links because Node does not expose their file/directory link type; resolved links keep their literal target and source type. POSIX dangling links are preserved. Choose a copying policy that meets the caller's metadata requirements; automatic copying can select either path.
69
76
 
70
77
  `readCloneFileMetadata(files)` asynchronously reads APFS data-stream identities and file metadata in one native batch. Results correspond to input order; missing or unsupported entries return `undefined`. The returned `CloneFileMetadata` includes clone ID, device/inode, size, mode, ownership, and timestamps. These are point-in-time observations, not authorization or proof that later reads remain unchanged. Consumers such as Git index adapters must validate their own content and timestamp invariants. The reader does not follow leaf symbolic links.
71
78
 
79
+ ## Borrowed FileHandle transfers
80
+
81
+ `copyFileHandle` from `@openclaw/fs-safe/advanced` copies bytes between two
82
+ already-open regular files. Use it when a snapshot or materialization owner
83
+ has admitted the source and opened its own destination:
84
+
85
+ ```ts
86
+ import { createHash } from "node:crypto";
87
+ import { copyFileHandle } from "@openclaw/fs-safe/advanced";
88
+
89
+ const digest = createHash("sha256");
90
+ const bytes = await copyFileHandle(sourceHandle, targetHandle, {
91
+ maxBytes: expectedSize,
92
+ signal: AbortSignal.timeout(30_000),
93
+ onChunk: (chunk) => { digest.update(chunk); },
94
+ assertBeforeMutation: assertSnapshotOwnerCurrent,
95
+ });
96
+ ```
97
+
98
+ `CopyFileHandleOptions` contains optional `maxBytes`, `signal`, `onChunk`, and
99
+ `assertBeforeMutation`. The result is the actual byte count copied through EOF.
100
+ The byte limit is not a prefix length: excess data rejects with `too-large`,
101
+ including data added after admission. Omitted limits are unlimited; Root's
102
+ default read cap does not apply. Zero accepts only an empty source. Invalid
103
+ limits reject before descriptor inspection.
104
+
105
+ The source and target must be distinct regular files; exact device/inode
106
+ aliases, including two handles to hardlinked names, reject before writing.
107
+ Both reads and writes start at position zero and preserve the handles' current
108
+ cursors. Existing destination bytes beyond the copied prefix remain intact.
109
+ The target must have been opened **without append mode**: some platforms ignore
110
+ positional writes on append handles, and Node exposes no portable open-flags
111
+ query. Keep both handles open and free of concurrent I/O through settlement.
112
+
113
+ The synchronous `onChunk` observer sees each source chunk before any target
114
+ write for that chunk. It receives a borrowed view reused by later reads; consume
115
+ it immediately without retaining or mutating it. This supports source hashing;
116
+ it does not verify bytes persisted by the destination. Callers that require a
117
+ destination digest must still hash the destination handle afterward. Observer
118
+ and authority callbacks may throw; thenable returns reject with `TypeError`
119
+ before the affected write. `assertBeforeMutation` runs immediately before every
120
+ partial-write submission and must inspect current authority each time.
121
+
122
+ The helper reuses Root copying's bounded read buffer and completes positive
123
+ short reads and writes. JavaScript file transfers use at most 512 KiB of scratch
124
+ space, reduced for smaller source-size hints and capped by a finite byte budget
125
+ plus its one-byte overflow probe. A zero-progress write rejects with `helper-failed`.
126
+ Cancellation is checked before I/O, after source reads, and before each write;
127
+ admitted reads and writes settle before rejection. A rejected operation can
128
+ leave a copied prefix. There is no rollback or pathname cleanup.
129
+
130
+ This helper never opens or closes a file, truncates, chmods, syncs, renames, or
131
+ publishes it. Source admission, immutability checks, destination preparation,
132
+ durability, publication, and failure recovery stay with the caller. Initial
133
+ descriptor inspection does not prove that the source remained unchanged while
134
+ copying. Keep existing source-fingerprint and publication checks around the
135
+ transfer when building snapshot operations.
136
+
72
137
  ## Ownership and cancellation
73
138
 
74
139
  These are low-level operations on caller-owned absolute paths, not Root-relative methods. The source and destination parent must be real directories. The library pins their descriptors and verifies their identities; it does not establish the caller's authorization to use them. Keep the source immutable for the operation, including writes through other aliases, and keep the destination namespace under the caller's control. Literal symlinks in the cloned contents are preserved rather than followed or sanitized.
@@ -77,10 +142,14 @@ An already aborted signal prevents dispatch. In-flight cancellation stops cancel
77
142
 
78
143
  Completion is not a crash-durability guarantee. The API is suitable for reconstructible templates and checkouts; it does not sync every file or replace application-level publication and recovery rules.
79
144
 
145
+ Byte copying retains fractional file and directory access/modification timestamps to the precision supported by Node's timestamp APIs and the destination filesystem. This includes dates before 1970 on Unix. On Windows, [Node's unsigned stat seconds](https://github.com/nodejs/node/blob/v26.8.2/src/node_file-inl.h#L93-L104) can report pre-1970 timestamps as dates about 136 years later; byte copying inherits that upstream limitation.
146
+
80
147
  ## Platform tests and benchmarks
81
148
 
82
- After building the host native binding, run `pnpm test test/clone.test.ts test/copy-tree.test.ts`. APFS tests can use the normal macOS temporary directory. For Btrfs, ReFS, or XFS, set `FS_SAFE_CLONE_TEST_ROOT` to an existing writable directory on that filesystem. The test creates and cleans only its own temporary children. An explicitly configured unsupported directory fails the test rather than silently skipping platform proof. XFS metadata tests require the `attr` and `acl` utilities.
149
+ After building the host native binding, run `pnpm test test/clone.test.ts test/copy-tree.test.ts`. APFS tests can use the normal macOS temporary directory. For Btrfs, ReFS, XFS, or ZFS, set `FS_SAFE_CLONE_TEST_ROOT` to an existing writable directory on that filesystem. The test creates and cleans only its own temporary children. An explicitly configured unsupported directory fails the test rather than silently skipping platform proof. XFS and ZFS metadata tests require the `attr` and `acl` utilities.
83
150
 
84
151
  Run `node scripts/clone-xfs-proof.mjs MOUNT` on a real XFS volume to verify the public API, hashes, independent writes, and shared physical extents. It requires `filefrag` from `e2fsprogs`. Add `no-reflink` for an XFS fixture formatted with reflinks disabled; strict copying must fail and automatic copying must succeed through byte copying.
85
152
 
153
+ Run `node scripts/clone-zfs-proof.mjs MOUNT POOL` on a dedicated, otherwise idle Linux ZFS pool with compression and deduplication disabled. It verifies both `copyTree` and `Root.copyIn` through hashes and changes in the documented `bclonesaved` pool counter. It requires `zfs`, `zpool`, and `findmnt`, including permission to run `zpool sync`. Add `no-reflink` for a pool without block cloning to verify strict refusal and automatic byte fallback. The script creates and removes only its temporary directory; it does not create pools or change their properties.
154
+
86
155
  Run `node benchmarks/clone.mjs SOURCE DESTINATION_PARENT` after `pnpm build` to compare one, four, and 16 workers on the same immutable source. Add `3 auto` or `3 never` to measure three samples of ordinary copying, including NTFS destinations. It records copying time separately from fixture preparation and full file-hash verification, and retains its uniquely named output directory for inspection. Prepare Btrfs sources with `createCloneSource` first.
@@ -0,0 +1,85 @@
1
+ ---
2
+ title: Directory identity
3
+ description: "Exact directory observations and synchronous identity assertions for application-owned workflows."
4
+ ---
5
+
6
+ # Directory identity
7
+
8
+ Use `readDirectoryIdentity()` and `assertDirectoryIdentitySync()` from
9
+ `@openclaw/fs-safe/advanced` when an application owns a staging or recovery flow
10
+ and needs to verify that a pathname still identifies an observed directory.
11
+
12
+ ```ts
13
+ import {
14
+ readDirectoryIdentity,
15
+ assertDirectoryIdentitySync,
16
+ } from "@openclaw/fs-safe/advanced";
17
+
18
+ const expected = await readDirectoryIdentity(directoryPath);
19
+ await prepareOutput();
20
+ assertDirectoryIdentitySync(directoryPath, expected);
21
+ ```
22
+
23
+ `readDirectoryIdentity(path)` returns a frozen `DirectoryIdentity`:
24
+
25
+ ```ts
26
+ type DirectoryIdentity = Readonly<{
27
+ dev: bigint;
28
+ ino: bigint;
29
+ realPath: string;
30
+ }>;
31
+ ```
32
+
33
+ Both operations reject a final symlink or a non-directory, including paths with
34
+ one or more trailing separators. Filesystem, drive, and UNC roots remain valid.
35
+ Parent aliases and `..` retain their filesystem traversal meaning; POSIX
36
+ backslashes and whitespace remain literal filename characters. These helpers do
37
+ not confine a path to a root or reject every symlink ancestor. Keep the
38
+ application's path policy, or use the [Root API](root.md) for paths that must
39
+ remain beneath a root.
40
+
41
+ ## Checking the selected path
42
+
43
+ `assertDirectoryIdentitySync(observedPath, expected)` reads the supplied path
44
+ and compares its exact `dev` and `ino` against the expected bigint values. It
45
+ returns `undefined` on success and throws synchronously on failure.
46
+
47
+ If `expected.realPath` is present, the current canonical path must also match
48
+ that string exactly. Pass the complete observation to keep both checks:
49
+
50
+ ```ts
51
+ assertDirectoryIdentitySync(newlyOpenedRootPath, expected);
52
+ ```
53
+
54
+ For a directory intentionally moved to another name, omit `realPath` while
55
+ retaining the expected identity:
56
+
57
+ ```ts
58
+ assertDirectoryIdentitySync(movedPath, { dev: expected.dev, ino: expected.ino });
59
+ ```
60
+
61
+ Only `dev`, `ino`, and optional `realPath` participate in the assertion. A
62
+ `MovePathPublicationReceipt` can supply the identity after a move; its `path`
63
+ does not implicitly require the previous pathname to remain current.
64
+
65
+ ## Errors and ownership
66
+
67
+ | Condition | Result |
68
+ |---|---|
69
+ | Final symlink or non-directory | `FsSafeError("not-file")` |
70
+ | Different expected identity or supplied canonical path | `FsSafeError("path-mismatch")` |
71
+ | Numeric expected identity or persistently unknown Windows identity | `FsSafeError("path-mismatch")` |
72
+ | Filesystem failure such as `ENOENT` or `EACCES` | The original error, unchanged |
73
+
74
+ Windows can temporarily report a zero device or inode. The shared identity
75
+ owner permits one re-inspection, retaining every known component so a later
76
+ observation cannot erase a definite mismatch. It rejects identities that remain
77
+ unknown and does not retry operational filesystem errors.
78
+
79
+ These helpers observe directory identity. They do not open a retained handle,
80
+ hold a lock, create or remove directories, change permissions, or authorize
81
+ application work. A pathname can change after an assertion; continue to use
82
+ guarded mutation APIs and recheck application authority at the operation's
83
+ existing submission point. Keep publication, rollback, and cleanup decisions
84
+ with the caller. Use [pinned directories](durability.md#pinned-directories) when
85
+ the operation specifically needs the directory synchronization lifecycle.
@@ -245,7 +245,42 @@ positioned reads in chunks of up to 256 KiB but updates Node's `Hash` on the Jav
245
245
  thread. Both paths stream constant-size buffers rather than loading the file
246
246
  into memory. Native mode `require` keeps its usual fail-closed loader semantics.
247
247
 
248
- If publication fails after this call created the target, it throws an
248
+ ### Synchronous hashing
249
+
250
+ `sha256FileSync()` accepts a pathname or a borrowed numeric file descriptor and
251
+ returns the same `{ bytes, digest }` result. It shares `Sha256FileOptions`,
252
+ including the default unlimited byte budget and `too-large` errors for growth
253
+ beyond `maxBytes`. It always reads from offset zero with bounded positional
254
+ `readSync` calls and leaves a borrowed descriptor open at its original position.
255
+ Path inputs use the same regular-file, final-symlink, nonblocking-open, and exact
256
+ bigint admission checks described above, then close their owned descriptor.
257
+ These checks do not provide ancestor confinement or a snapshot of concurrent edits.
258
+
259
+ ```ts
260
+ import { closeSync, openSync } from "node:fs";
261
+ import { sha256FileSync } from "@openclaw/fs-safe/durability";
262
+
263
+ const fd = openSync(stagedArchive, "r");
264
+ try {
265
+ const hash = sha256FileSync(fd, { maxBytes: manifest.sizeBytes });
266
+ if (hash.bytes !== manifest.sizeBytes || hash.digest !== manifest.sha256) {
267
+ throw new Error("staged backup does not match its manifest");
268
+ }
269
+ } finally {
270
+ closeSync(fd);
271
+ }
272
+ ```
273
+
274
+ The synchronous API uses Node's crypto implementation in every native mode,
275
+ including `require`; it never loads a native binding. It blocks the calling
276
+ thread until hashing finishes or throws. A pre-aborted signal fails before I/O,
277
+ and synchronous signal changes are checked between operations with the original
278
+ reason preserved. Timers and other JavaScript callbacks cannot run while the
279
+ hash is executing; use `sha256File()` when responsive cancellation is needed.
280
+
281
+ ## Publication failure receipts
282
+
283
+ If `publishFileExclusive()` fails after creating the target, it throws an
249
284
  `FsSafeError` with a `details` receipt:
250
285
 
251
286
  ```ts
@@ -0,0 +1,109 @@
1
+ # Directory entries
2
+
3
+ `Root.entries()` observes one directory at a time. Use it when the application
4
+ owns traversal order or must inspect symlinks itself, such as an installer that
5
+ validates selected dependency links or a manifest builder that rejects all links.
6
+
7
+ ```ts
8
+ import { root } from "@openclaw/fs-safe";
9
+
10
+ const workspace = await root("/srv/workspace");
11
+ for await (const entry of workspace.entries("plugins", {
12
+ maxEntries: 1_000,
13
+ signal: AbortSignal.timeout(5_000),
14
+ })) {
15
+ if (entry.isSymbolicLink) {
16
+ throw new Error(`unexpected link: ${entry.name}`);
17
+ }
18
+ console.log(entry.name, entry.size);
19
+ }
20
+ ```
21
+
22
+ ## API
23
+
24
+ ```ts
25
+ interface Root {
26
+ entries(relativePath: string, options?: RootEntriesOptions): AsyncIterableIterator<DirEntry>;
27
+ }
28
+
29
+ type RootEntriesOptions = {
30
+ maxEntries?: number;
31
+ order?: "filesystem" | "sorted";
32
+ signal?: AbortSignal;
33
+ symlinks?: "reject" | "follow-within-root" | "follow-parents-within-root";
34
+ };
35
+ ```
36
+
37
+ Each result is the existing [`DirEntry`](types.md) shape: a basename and advisory
38
+ `lstat` metadata, including `isFile`, `isDirectory`, `isSymbolicLink`, `size`,
39
+ `mode`, `nlink`, `dev`, and `ino`. Entries are immediate children; the iterator
40
+ never descends. An empty path or `"."` selects the root directory.
41
+
42
+ Child symlinks are always reported without following them, including dangling
43
+ links and links whose targets lie outside the root. Their metadata describes
44
+ the link, not its target. Hardlinked files are also reported regardless of the
45
+ Root's read hardlink policy. Reporting a name grants no read or mutation access.
46
+ Use the Root operation methods when consuming or changing an entry.
47
+
48
+ The `symlinks` option applies only to the path of the selected directory. It
49
+ inherits the Root's read policy and defaults to `"reject"`. The two follow
50
+ policies allow only contained aliases; `"follow-parents-within-root"` also
51
+ rejects a link as the final directory component. Child-link reporting is
52
+ independent of this path policy.
53
+
54
+ ## Work limits and ordering
55
+
56
+ `maxEntries` is an optional non-negative safe integer. Every child counts,
57
+ including directories, symlinks, special files, and entries the caller later
58
+ ignores. Omit it to leave the count unbounded. An empty directory satisfies a
59
+ zero limit. Exceeding the limit throws `FsSafeError` with code `"too-large"`;
60
+ there is no silent truncation or success marker for a partial scan.
61
+
62
+ The default `order: "filesystem"` reads names incrementally in the filesystem's
63
+ nondeterministic order. It observes one child's metadata per iterator step,
64
+ with one name of lookahead to distinguish an exact limit from an overflow.
65
+ Entries already yielded before overflow remain partial observations. A caller
66
+ that stops early has not established that the whole directory fits the limit.
67
+
68
+ `order: "sorted"` collects names first and orders them with JavaScript's default
69
+ string sort, not locale collation. With `maxEntries`, names are collected from
70
+ a bounded directory stream; overflow rejects before yielding any entries or
71
+ requesting their full metadata. Without a limit, sorted mode enumerates the
72
+ complete name list. Metadata is observed only as each sorted entry is consumed.
73
+ Applications that need locale-specific ordering can collect with an explicit
74
+ limit and apply their own comparator.
75
+
76
+ The count bounds logical directory reads and fs-safe metadata requests. Node
77
+ may classify a directory entry with `lstat` when the filesystem omits type
78
+ information, including the single lookahead entry. It does not bound elapsed
79
+ time for an individual filesystem operation or the size of one filename.
80
+
81
+ Cancellation is checked before setup and around awaited work. Directory
82
+ handles close on completion, overflow, cancellation, failure, and early
83
+ `break` or iterator return. In-flight filesystem work settles before rejection;
84
+ an individual syscall cannot be interrupted. If iteration and disposal both
85
+ fail, a `SuppressedError` retains the close failure in `error` and the original
86
+ failure in `suppressed`.
87
+
88
+ ## Identity and caller responsibilities
89
+
90
+ The iterator reuses Root's guarded directory-listing owner. It validates the
91
+ selected path, pins exact Root and directory identities, and checks them around
92
+ directory observations, including after control returns from the caller. A
93
+ replaced directory rejects instead of continuing under the replacement.
94
+
95
+ These are pure-Node, best-effort checks. The iterator does not hold descriptors
96
+ for every path component and cannot sandbox a hostile process that repeatedly
97
+ swaps and restores directories. Results are not an atomic snapshot, an exact
98
+ identity receipt, or permission to use the name later. Contents and metadata
99
+ can change between entries; a removed entry may cause iteration to reject.
100
+ Use an admitted descriptor for metadata and hashing that must refer to the same
101
+ opened file, and keep snapshot consistency or cooperative locking with its
102
+ application owner.
103
+
104
+ Traversal strategy, global budgets, ignored names, and approved external peers
105
+ remain application policy. A per-directory physical-entry limit cannot replace
106
+ a global file-only or unique-directory budget. Full metadata also requires a
107
+ child `lstat`; a caller that previously needed only `Dirent` types should assess
108
+ that cost. `Root.walk()` remains the recursive option for its supported link,
109
+ pruning, and error policies; `Root.list()` returns an eager advisory listing.
package/docs/errors.md CHANGED
@@ -117,11 +117,11 @@ type FsSafeErrorCode =
117
117
  | `helper-unavailable` | Required native binding could not be loaded. | Unsupported platform, omitted/missing/incompatible platform package, or `FS_SAFE_NATIVE_MODE=require`. `auto` falls back where possible; `require` fails closed. |
118
118
  | `insecure-permissions` | A secure file or path permission check found a mode/ACL that allows broader access than requested. | File or directory is group/world writable/readable; Windows ACL grants broad read. |
119
119
  | `invalid-path` | Input was empty, contained NUL, was an unparseable URL, or otherwise unusable; a FileStore key used a noncanonical spelling. | Noncanonical FileStore aliases, backslashes, or complete parent segments; a network path on Windows; a drive-relative segment in a portable relative path or store key; or a leading drive-relative spelling such as `C:name` used as a Root destination. Existing-object Root lookups retain broader confined path compatibility, including legal POSIX drive-like names. |
120
- | `not-empty` | `remove()` on a non-empty directory. | Use `replaceDirectoryAtomic` or remove children first. |
120
+ | `not-empty` | Nonrecursive `remove()` on a non-empty directory, or new children appeared during recursive removal. | Use bounded `recursive: true` removal or coordinate concurrent writers. |
121
121
  | `not-file` | Read or copy targeted a non-regular file, or a path walk found a non-directory ancestor. | Target was a directory, FIFO, socket, device, or an existing file was followed by another segment. |
122
122
  | `not-found` | The target does not exist (or its parent does not, with `mkdir: false`). | Typical missing-file case. |
123
123
  | `not-owned` | A secure file owner check failed. | File is owned by another UID. |
124
- | `not-removable` | `remove()` couldn't `unlink`/`rmdir` for a reason other than non-empty. | Permissions, device busy, immutable bit. |
124
+ | `not-removable` | `remove()` couldn't inspect a directory stream or `unlink`/`rmdir` for a reason other than non-empty. | Permissions, device busy, immutable bit; the original filesystem error remains in `cause`. |
125
125
  | `outside-workspace` | Path resolves outside the configured root. | `..` traversal; absolute path outside the root; symlink resolved out. |
126
126
  | `path-alias` | A path alias check failed (e.g. canonical-real-path moved out of the root). | Symlink resolution lands outside the root. |
127
127
  | `path-mismatch` | Post-open identity check failed: the opened fd does not match the resolved path. | TOCTOU — something else swapped the path between resolve and open. |
@@ -131,7 +131,7 @@ type FsSafeErrorCode =
131
131
  | `store-reentrant-update` | A `JsonStore.update()` callback called `update()` or `updateOr()` for the same canonical store before returning. | Reentrant mutation would deadlock or lose an update; return the complete next value from the outer callback. |
132
132
  | `symlink` | Path component is a symlink, policy is `reject`. | Caller followed a symlink they shouldn't have, or `symlinks: "reject"` is set. |
133
133
  | `timeout` | An operation with a wall-clock budget overran. | Secure file read or timed operation exceeded `timeoutMs`. |
134
- | `too-large` | A read or bounded walk exceeded its configured budget. | Caller gave a too-permissive file or traversal limit. |
134
+ | `too-large` | A read, bounded walk, or recursive removal exceeded its configured budget. | Review the expected file or tree size before increasing the limit; recursive removal may have completed earlier entries. |
135
135
  | `unsupported-platform` | The platform or filesystem cannot perform the requested operation. | `createCloneSource` and `copyTree({ clone: "always" })` require native cloning support. The default `copyTree({ clone: "auto" })` selects portable byte copying when cloning is unavailable; unsupported source contents or metadata still fail. See [directory copying](copy.md) for backend limits and fallback behavior. |
136
136
 
137
137
  Secret writes reject invalid `mode` / `dirMode` values with `invalid-path` before directory creation. Existing secret directories with a mode different from the requested `dirMode` report `insecure-permissions` without chmod; a created directory whose descriptor ownership no longer matches its initializing effective user reports `not-owned`.
@@ -121,6 +121,21 @@ metadata where lower latency matters more than crash-durability. Per-call
121
121
  `undefined` override preserves the store default. Modes, path confinement,
122
122
  and publication identity checks are unchanged.
123
123
 
124
+ Synchronous writes retain their original write-only descriptor through rename
125
+ and publication checks, using exact bigint file identities. When Windows cannot
126
+ report a pathname's identity, verification reopens the name only to compare its
127
+ descriptor with the retained writer; it never reads file contents. A substituted
128
+ file is rejected even if its bytes match, and a post-publication failure leaves
129
+ the published entry intact for caller-owned recovery. Ordinary write-only and
130
+ mode-000 outputs do not require a readable descriptor when pathname metadata is
131
+ available.
132
+
133
+ If an opaque pathname cannot be reopened because of an ACL denial or sharing
134
+ restriction, the synchronous writer intentionally rejects with `path-mismatch`:
135
+ its exact publication identity cannot be verified. There is no equal-content
136
+ fallback. The published entry remains present, so callers must inspect or
137
+ recover that outcome instead of assuming the write did not occur.
138
+
124
139
  | Method | Durability support |
125
140
  |---|---|
126
141
  | `write`, `writeText`, `writeJson` (async and sync) | Per-call option overrides store default. |