@openclaw/fs-safe 0.10.0 → 0.12.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 (302) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/LICENSE +1 -0
  3. package/README.md +39 -6
  4. package/dist/absolute-path.d.ts.map +1 -1
  5. package/dist/absolute-path.js +3 -2
  6. package/dist/advanced.d.ts +5 -1
  7. package/dist/advanced.d.ts.map +1 -1
  8. package/dist/advanced.js +5 -1
  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 +8 -8
  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 +16 -9
  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 +54 -42
  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 +26 -9
  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 +18 -11
  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/device-path.d.ts.map +1 -1
  58. package/dist/device-path.js +5 -3
  59. package/dist/directory-durability.d.ts.map +1 -1
  60. package/dist/directory-durability.js +5 -4
  61. package/dist/directory-guard.d.ts +11 -1
  62. package/dist/directory-guard.d.ts.map +1 -1
  63. package/dist/directory-guard.js +53 -11
  64. package/dist/durability.d.ts +1 -1
  65. package/dist/durability.d.ts.map +1 -1
  66. package/dist/durability.js +1 -1
  67. package/dist/file-handle-transfer.d.ts +14 -0
  68. package/dist/file-handle-transfer.d.ts.map +1 -0
  69. package/dist/file-handle-transfer.js +64 -0
  70. package/dist/file-hash.d.ts +3 -0
  71. package/dist/file-hash.d.ts.map +1 -1
  72. package/dist/file-hash.js +99 -32
  73. package/dist/file-lock-sync.d.ts.map +1 -1
  74. package/dist/file-lock-sync.js +8 -4
  75. package/dist/file-store-boundary.d.ts.map +1 -1
  76. package/dist/file-store-boundary.js +7 -5
  77. package/dist/file-store-path.d.ts +3 -0
  78. package/dist/file-store-path.d.ts.map +1 -0
  79. package/dist/file-store-path.js +27 -0
  80. package/dist/file-store-prune.d.ts.map +1 -1
  81. package/dist/file-store-prune.js +15 -5
  82. package/dist/file-store-sync-write.d.ts.map +1 -1
  83. package/dist/file-store-sync-write.js +56 -44
  84. package/dist/file-store.d.ts.map +1 -1
  85. package/dist/file-store.js +2 -18
  86. package/dist/filename.d.ts +1 -0
  87. package/dist/filename.d.ts.map +1 -1
  88. package/dist/filename.js +32 -14
  89. package/dist/guarded-mkdir.d.ts +2 -0
  90. package/dist/guarded-mkdir.d.ts.map +1 -1
  91. package/dist/guarded-mkdir.js +52 -16
  92. package/dist/guest-dispatch-python.d.ts +2 -0
  93. package/dist/guest-dispatch-python.d.ts.map +1 -0
  94. package/dist/guest-dispatch-python.js +117 -0
  95. package/dist/guest-native-python.d.ts +4 -0
  96. package/dist/guest-native-python.d.ts.map +1 -0
  97. package/dist/guest-native-python.js +135 -0
  98. package/dist/guest.d.ts +9 -0
  99. package/dist/guest.d.ts.map +1 -0
  100. package/dist/guest.js +421 -0
  101. package/dist/index.d.ts +1 -1
  102. package/dist/index.d.ts.map +1 -1
  103. package/dist/install-path.d.ts +6 -0
  104. package/dist/install-path.d.ts.map +1 -1
  105. package/dist/install-path.js +16 -2
  106. package/dist/json-durable-queue-directory.js +3 -3
  107. package/dist/json-durable-queue-ownership.d.ts +2 -0
  108. package/dist/json-durable-queue-ownership.d.ts.map +1 -1
  109. package/dist/json-durable-queue-ownership.js +47 -3
  110. package/dist/json-durable-queue-read.d.ts +6 -0
  111. package/dist/json-durable-queue-read.d.ts.map +1 -0
  112. package/dist/json-durable-queue-read.js +59 -0
  113. package/dist/json-durable-queue.d.ts +1 -1
  114. package/dist/json-durable-queue.d.ts.map +1 -1
  115. package/dist/json-durable-queue.js +43 -84
  116. package/dist/json.d.ts.map +1 -1
  117. package/dist/json.js +2 -1
  118. package/dist/local-roots.d.ts.map +1 -1
  119. package/dist/local-roots.js +2 -1
  120. package/dist/move-path-stage.d.ts.map +1 -1
  121. package/dist/move-path-stage.js +2 -1
  122. package/dist/move-path.d.ts.map +1 -1
  123. package/dist/move-path.js +4 -3
  124. package/dist/mutation-authority.d.ts +1 -0
  125. package/dist/mutation-authority.d.ts.map +1 -1
  126. package/dist/mutation-authority.js +4 -4
  127. package/dist/native-binding.d.ts +14 -2
  128. package/dist/native-binding.d.ts.map +1 -1
  129. package/dist/native-pinned-write-windows.d.ts.map +1 -1
  130. package/dist/native-pinned-write-windows.js +3 -2
  131. package/dist/native-pinned-write.d.ts.map +1 -1
  132. package/dist/native-pinned-write.js +2 -1
  133. package/dist/opened-realpath.d.ts.map +1 -1
  134. package/dist/opened-realpath.js +5 -4
  135. package/dist/output.d.ts +2 -0
  136. package/dist/output.d.ts.map +1 -1
  137. package/dist/output.js +2 -0
  138. package/dist/overwrite-file-handle.d.ts +8 -0
  139. package/dist/overwrite-file-handle.d.ts.map +1 -0
  140. package/dist/overwrite-file-handle.js +42 -0
  141. package/dist/path-case.d.ts +7 -0
  142. package/dist/path-case.d.ts.map +1 -0
  143. package/dist/path-case.js +136 -0
  144. package/dist/path-scope-lexical.d.ts +14 -0
  145. package/dist/path-scope-lexical.d.ts.map +1 -0
  146. package/dist/path-scope-lexical.js +27 -0
  147. package/dist/path.d.ts.map +1 -1
  148. package/dist/path.js +22 -3
  149. package/dist/permissions-windows.d.ts +1 -1
  150. package/dist/permissions-windows.d.ts.map +1 -1
  151. package/dist/permissions-windows.js +48 -6
  152. package/dist/pinned-open.d.ts.map +1 -1
  153. package/dist/pinned-open.js +3 -1
  154. package/dist/pinned-write.d.ts +2 -2
  155. package/dist/pinned-write.d.ts.map +1 -1
  156. package/dist/pinned-write.js +3 -1
  157. package/dist/private-temp-workspace.d.ts.map +1 -1
  158. package/dist/private-temp-workspace.js +6 -4
  159. package/dist/realpath.d.ts +4 -0
  160. package/dist/realpath.d.ts.map +1 -0
  161. package/dist/realpath.js +43 -0
  162. package/dist/recursive-mkdir-path.d.ts +3 -0
  163. package/dist/recursive-mkdir-path.d.ts.map +1 -0
  164. package/dist/recursive-mkdir-path.js +8 -0
  165. package/dist/replace-directory.d.ts.map +1 -1
  166. package/dist/replace-directory.js +2 -1
  167. package/dist/replace-file-copy-fallback.d.ts.map +1 -1
  168. package/dist/replace-file-copy-fallback.js +23 -31
  169. package/dist/replace-file-copy-source.d.ts.map +1 -1
  170. package/dist/replace-file-copy-source.js +7 -12
  171. package/dist/replace-file-mode.d.ts +3 -0
  172. package/dist/replace-file-mode.d.ts.map +1 -0
  173. package/dist/replace-file-mode.js +10 -0
  174. package/dist/replace-file.d.ts +1 -0
  175. package/dist/replace-file.d.ts.map +1 -1
  176. package/dist/replace-file.js +14 -8
  177. package/dist/root-boundary.d.ts +25 -0
  178. package/dist/root-boundary.d.ts.map +1 -0
  179. package/dist/root-boundary.js +177 -0
  180. package/dist/root-context.d.ts.map +1 -1
  181. package/dist/root-context.js +40 -13
  182. package/dist/root-create-input.d.ts +10 -0
  183. package/dist/root-create-input.d.ts.map +1 -0
  184. package/dist/root-create-input.js +80 -0
  185. package/dist/root-directory-list.d.ts +3 -1
  186. package/dist/root-directory-list.d.ts.map +1 -1
  187. package/dist/root-directory-list.js +31 -5
  188. package/dist/root-entries.d.ts +11 -0
  189. package/dist/root-entries.d.ts.map +1 -0
  190. package/dist/root-entries.js +61 -0
  191. package/dist/root-errors.d.ts +5 -5
  192. package/dist/root-errors.d.ts.map +1 -1
  193. package/dist/root-errors.js +13 -12
  194. package/dist/root-impl.d.ts +13 -3
  195. package/dist/root-impl.d.ts.map +1 -1
  196. package/dist/root-impl.js +93 -70
  197. package/dist/root-move-preflight.d.ts +8 -0
  198. package/dist/root-move-preflight.d.ts.map +1 -0
  199. package/dist/root-move-preflight.js +16 -0
  200. package/dist/root-options.d.ts +12 -1
  201. package/dist/root-options.d.ts.map +1 -1
  202. package/dist/root-path-existing.d.ts +2 -0
  203. package/dist/root-path-existing.d.ts.map +1 -1
  204. package/dist/root-path-existing.js +14 -5
  205. package/dist/root-path-symlink.d.ts.map +1 -1
  206. package/dist/root-path-symlink.js +3 -2
  207. package/dist/root-path.d.ts +2 -0
  208. package/dist/root-path.d.ts.map +1 -1
  209. package/dist/root-path.js +54 -26
  210. package/dist/root-paths.d.ts +2 -6
  211. package/dist/root-paths.d.ts.map +1 -1
  212. package/dist/root-paths.js +24 -32
  213. package/dist/root-remove.d.ts +5 -0
  214. package/dist/root-remove.d.ts.map +1 -0
  215. package/dist/root-remove.js +286 -0
  216. package/dist/root-symlink-policy.d.ts +2 -1
  217. package/dist/root-symlink-policy.d.ts.map +1 -1
  218. package/dist/root-symlink-policy.js +2 -2
  219. package/dist/root-walk.d.ts.map +1 -1
  220. package/dist/root-walk.js +2 -1
  221. package/dist/root-write-mode.d.ts +2 -0
  222. package/dist/root-write-mode.d.ts.map +1 -1
  223. package/dist/root-write-mode.js +21 -7
  224. package/dist/root-write-verification.d.ts.map +1 -1
  225. package/dist/root-write-verification.js +12 -3
  226. package/dist/root.d.ts +2 -1
  227. package/dist/root.d.ts.map +1 -1
  228. package/dist/safe-path-segment.d.ts.map +1 -1
  229. package/dist/safe-path-segment.js +3 -1
  230. package/dist/secret-file.d.ts.map +1 -1
  231. package/dist/secret-file.js +2 -1
  232. package/dist/secret-read-async.d.ts.map +1 -1
  233. package/dist/secret-read-async.js +2 -1
  234. package/dist/secure-file-windows.d.ts +10 -0
  235. package/dist/secure-file-windows.d.ts.map +1 -0
  236. package/dist/secure-file-windows.js +186 -0
  237. package/dist/secure-file.d.ts.map +1 -1
  238. package/dist/secure-file.js +27 -7
  239. package/dist/secure-temp-dir.d.ts.map +1 -1
  240. package/dist/secure-temp-dir.js +2 -1
  241. package/dist/sibling-staged-file.d.ts +2 -0
  242. package/dist/sibling-staged-file.d.ts.map +1 -1
  243. package/dist/sibling-staged-file.js +49 -8
  244. package/dist/sibling-temp.d.ts +2 -0
  245. package/dist/sibling-temp.d.ts.map +1 -1
  246. package/dist/sibling-temp.js +8 -5
  247. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  248. package/dist/sidecar-lock-acquire.js +16 -5
  249. package/dist/sidecar-lock-policy.d.ts +2 -0
  250. package/dist/sidecar-lock-policy.d.ts.map +1 -1
  251. package/dist/sidecar-lock-policy.js +17 -0
  252. package/dist/sidecar-lock.d.ts.map +1 -1
  253. package/dist/sidecar-lock.js +2 -3
  254. package/dist/staged-directory.d.ts.map +1 -1
  255. package/dist/staged-directory.js +4 -3
  256. package/dist/temp-target.d.ts +14 -12
  257. package/dist/temp-target.d.ts.map +1 -1
  258. package/dist/temp-target.js +15 -7
  259. package/dist/trash.d.ts.map +1 -1
  260. package/dist/trash.js +7 -5
  261. package/dist/unicode-path.d.ts +3 -0
  262. package/dist/unicode-path.d.ts.map +1 -0
  263. package/dist/unicode-path.js +13 -0
  264. package/dist/walk.d.ts.map +1 -1
  265. package/dist/walk.js +14 -12
  266. package/dist/write-file-handle.d.ts +1 -0
  267. package/dist/write-file-handle.d.ts.map +1 -1
  268. package/dist/write-file-handle.js +3 -2
  269. package/docs/advanced.md +7 -1
  270. package/docs/archive.md +31 -5
  271. package/docs/atomic.md +17 -1
  272. package/docs/config.md +1 -0
  273. package/docs/contributing.md +33 -2
  274. package/docs/copy.md +75 -6
  275. package/docs/directory-identity.md +85 -0
  276. package/docs/durability.md +40 -3
  277. package/docs/entries.md +109 -0
  278. package/docs/errors.md +3 -3
  279. package/docs/file-store.md +21 -0
  280. package/docs/filename.md +9 -2
  281. package/docs/guest.md +146 -0
  282. package/docs/in-place-write.md +81 -0
  283. package/docs/index.md +2 -0
  284. package/docs/install-path.md +59 -13
  285. package/docs/install.md +34 -0
  286. package/docs/native-helper.md +14 -5
  287. package/docs/native.md +18 -2
  288. package/docs/output.md +32 -6
  289. package/docs/path-case.md +64 -0
  290. package/docs/path-scope.md +1 -1
  291. package/docs/path.md +1 -1
  292. package/docs/permissions.md +37 -3
  293. package/docs/public-api.md +36 -2
  294. package/docs/root.md +33 -3
  295. package/docs/secure-file.md +17 -14
  296. package/docs/security-model.md +1 -1
  297. package/docs/sidecar-lock.md +12 -3
  298. package/docs/store.md +22 -1
  299. package/docs/temp.md +39 -6
  300. package/docs/types.md +1 -1
  301. package/docs/writing.md +153 -3
  302. package/package.json +14 -8
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 chunks up to 1 MiB, using smaller buffers for smaller source-size hints, 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.
@@ -242,10 +242,47 @@ When the optional binding is active, hashing runs as an async native task and
242
242
  does not occupy the JavaScript event loop with digest updates. With native mode
243
243
  `off`, or in `auto` when no binding loads, the fallback performs asynchronous
244
244
  positioned reads in chunks of up to 256 KiB but updates Node's `Hash` on the JavaScript
245
- thread. Both paths stream constant-size buffers rather than loading the file
246
- into memory. Native mode `require` keeps its usual fail-closed loader semantics.
245
+ thread. Both paths stream bounded buffers rather than loading the file into memory.
246
+ The fallback sizes its scratch buffer to small files and grows it if a stale
247
+ size hint is exceeded, while still probing for actual EOF and byte-limit overflow.
248
+ Native mode `require` keeps its usual fail-closed loader semantics.
249
+
250
+ ### Synchronous hashing
251
+
252
+ `sha256FileSync()` accepts a pathname or a borrowed numeric file descriptor and
253
+ returns the same `{ bytes, digest }` result. It shares `Sha256FileOptions`,
254
+ including the default unlimited byte budget and `too-large` errors for growth
255
+ beyond `maxBytes`. It always reads from offset zero with bounded positional
256
+ `readSync` calls and leaves a borrowed descriptor open at its original position.
257
+ Path inputs use the same regular-file, final-symlink, nonblocking-open, and exact
258
+ bigint admission checks described above, then close their owned descriptor.
259
+ These checks do not provide ancestor confinement or a snapshot of concurrent edits.
247
260
 
248
- If publication fails after this call created the target, it throws an
261
+ ```ts
262
+ import { closeSync, openSync } from "node:fs";
263
+ import { sha256FileSync } from "@openclaw/fs-safe/durability";
264
+
265
+ const fd = openSync(stagedArchive, "r");
266
+ try {
267
+ const hash = sha256FileSync(fd, { maxBytes: manifest.sizeBytes });
268
+ if (hash.bytes !== manifest.sizeBytes || hash.digest !== manifest.sha256) {
269
+ throw new Error("staged backup does not match its manifest");
270
+ }
271
+ } finally {
272
+ closeSync(fd);
273
+ }
274
+ ```
275
+
276
+ The synchronous API uses Node's crypto implementation in every native mode,
277
+ including `require`; it never loads a native binding. It blocks the calling
278
+ thread until hashing finishes or throws. A pre-aborted signal fails before I/O,
279
+ and synchronous signal changes are checked between operations with the original
280
+ reason preserved. Timers and other JavaScript callbacks cannot run while the
281
+ hash is executing; use `sha256File()` when responsive cancellation is needed.
282
+
283
+ ## Publication failure receipts
284
+
285
+ If `publishFileExclusive()` fails after creating the target, it throws an
249
286
  `FsSafeError` with a `details` receipt:
250
287
 
251
288
  ```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. |
@@ -241,6 +256,12 @@ type FileStorePruneOptions = {
241
256
 
242
257
  Symlinks are skipped. The walk is best-effort — failures on individual entries don't abort the whole prune. Compares against `mtimeMs`.
243
258
 
259
+ Pruning rechecks that a selected entry is still a regular file and still expired
260
+ immediately before guarded removal. Fresh replacements and in-place timestamp
261
+ refreshes are preserved; replacements that are themselves expired remain
262
+ eligible. This does not require read permission. The existing best-effort
263
+ external-process race window after dispatch still applies.
264
+
244
265
  ## Difference from `Root`
245
266
 
246
267
  | `FileStore` | `Root` |
package/docs/filename.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Filenames
2
2
 
3
- `sanitizeUntrustedFileName(name, fallback)` reduces a filename string from an untrusted source to one traversal-free path segment. Use it as a thin first pass before storing user-supplied names; pair with [`safeDirName`](install-path.md#safedirname) when you need stricter directory-name handling.
3
+ `sanitizeUntrustedFileName(name, fallback)` reduces a filename string from an untrusted source to one traversal-free path segment. Use it as a thin first pass before storing user-supplied names; use [`safePathSegmentHashedV2`](install-path.md#safepathsegmenthashedv2) when mapping untrusted install IDs to separate directory names.
4
4
 
5
5
  ```ts
6
6
  import { sanitizeUntrustedFileName } from "@openclaw/fs-safe/advanced";
@@ -27,6 +27,13 @@ In order:
27
27
  6. **Suffix Windows reserved basenames.** Compare the part before the first `.` case-insensitively with the Windows device-name set, including `CON`, `PRN`, `AUX`, `NUL`, `CLOCK$`, `CONIN$`, `CONOUT$`, `COM1..9`, `LPT1..9`, and their superscript `¹`, `²`, and `³` variants. Windows-ignored spaces and dots at the end of that basename do not disguise a device name. A match gains `_` before its extension, preserving the original case and extension on every platform.
28
28
  7. **Truncate.** If the cleaned segment is longer than 200 UTF-16 code units, take up to the first 200 without splitting a valid Unicode surrogate pair.
29
29
 
30
+ If truncation itself exposes a reserved-device basename after Windows ignores
31
+ trailing spaces or dots, the result is shortened once more and receives the
32
+ same underscore suffix. A name that reaches the sanitization branch therefore
33
+ remains at most 200 UTF-16 code units and is never a Windows reserved-device
34
+ alias. `fallbackName` is returned verbatim for empty or path-alias input, so
35
+ callers must supply a fallback that already satisfies their filename policy.
36
+
30
37
  That's it. The function stays intentionally small: it removes traversal and
31
38
  the most obvious cross-platform device and character hazards, but it is not a
32
39
  complete portable-filename or uniqueness policy.
@@ -92,5 +99,5 @@ await fs.write(`uploads/${safe}`, body); // fs is a Root() handle; rejects trave
92
99
 
93
100
  ## See also
94
101
 
95
- - [Install path helpers](install-path.md) — `safeDirName`, `safePathSegmentHashed` for directory-segment sanitization.
102
+ - [Install path helpers](install-path.md) — legacy directory-segment sanitizers and `safePathSegmentHashedV2` for untrusted install IDs.
96
103
  - [`root()`](root.md) — the boundary you'll write into after sanitizing.
package/docs/guest.md ADDED
@@ -0,0 +1,146 @@
1
+ # Guest filesystem source
2
+
3
+ `@openclaw/fs-safe/guest` exports Python 3 source for filesystem operations in
4
+ Linux and macOS guests that do not have Node installed. The host imports a
5
+ string and passes it to its existing container, SSH, or process transport.
6
+ Importing this subpath performs no I/O and launches no process.
7
+
8
+ This is the filesystem engine extracted from OpenClaw's sandbox bridge. The
9
+ caller still owns root admission, mount selection, read-only policy, canonical
10
+ path authorization, live authority, argument framing, and process lifetime.
11
+ Use [Root](root.md) for ordinary filesystem operations in the Node process.
12
+ This source artifact is separate from the retired host-side Python worker
13
+ described in [Migrating to 0.5](migrating-to-0.5.md).
14
+
15
+ ## Exports and requirements
16
+
17
+ ```ts
18
+ import {
19
+ GUEST_FILESYSTEM_PYTHON,
20
+ GUEST_FILESYSTEM_CREATE_EXISTS_EXIT_CODE,
21
+ GUEST_FILESYSTEM_READ_NOT_FOUND_EXIT_CODE,
22
+ GUEST_FILESYSTEM_RENAME_NO_REPLACE_PYTHON,
23
+ } from "@openclaw/fs-safe/guest";
24
+ ```
25
+
26
+ `GUEST_FILESYSTEM_PYTHON` is the complete single-invocation program. The two
27
+ exit constants are `17` for an exclusive-create collision and `2` for a
28
+ missing read parent or leaf after the root opens. Other nonzero statuses are
29
+ errors; stderr is diagnostic text, not a structured error protocol.
30
+
31
+ The guest needs Python 3 with `ctypes`, descriptor-relative POSIX operations,
32
+ `O_DIRECTORY`, and `O_NOFOLLOW`. Linux and macOS are the supported guest
33
+ platforms. The guest does not need fs-safe's Node package, native addon, Rust,
34
+ or WASM. Native mode configuration in the host does not configure this program.
35
+
36
+ `GUEST_FILESYSTEM_RENAME_NO_REPLACE_PYTHON` contains just the
37
+ `rename_no_replace(src_parent_fd, src_basename, dst_parent_fd, dst_basename)`
38
+ definition. OpenClaw's workspace bootstrap consumes this same fragment when
39
+ publishing a prepared workspace; exporting it avoids a second source owner.
40
+ Its embedding program must import `ctypes`, `errno`, `os`, and `sys`, admit
41
+ the descriptors and single-component names, and own cleanup and syncing.
42
+ The fragment has no argument preflight of its own.
43
+
44
+ On Linux it uses `renameat2(RENAME_NOREPLACE)`; on macOS it uses
45
+ `renameatx_np(RENAME_EXCL)`. If Linux lacks that function or reports it as
46
+ unsupported, it falls back to `link(..., follow_symlinks=False)`, leaving the
47
+ source entry for caller cleanup. That fallback supports files, not directory
48
+ publication. Existing destinations are never replaced by this fragment.
49
+
50
+ ## Invocation protocol
51
+
52
+ Pass the source through `python3 -c` and arguments as literal argv elements.
53
+ Do not concatenate untrusted values into a shell command. Transport adapters
54
+ that require a shell must apply their existing quoting rules to every element.
55
+ The following example uses an already admitted guest root and parent path:
56
+
57
+ ```ts
58
+ import { spawnSync } from "node:child_process";
59
+ import { GUEST_FILESYSTEM_PYTHON } from "@openclaw/fs-safe/guest";
60
+
61
+ const result = spawnSync("python3", [
62
+ "-c", GUEST_FILESYSTEM_PYTHON,
63
+ "write", "/srv/admitted-workspace", "notes", "today.txt", "1",
64
+ ], { input: Buffer.from("hello\n"), timeout: 10_000 });
65
+ if (result.error) throw result.error;
66
+ if (result.status !== 0) throw new Error(result.stderr.toString());
67
+ ```
68
+
69
+ All frames below start at `sys.argv[1]`. `root` is an admitted guest directory;
70
+ `parent` and `directory` are paths relative to that root. An empty relative
71
+ path selects the root itself. Flags are strings: `"1"` enables and `"0"`
72
+ disables. Do not omit required fields.
73
+
74
+ | Operation | Positional frame | Input / output |
75
+ |---|---|---|
76
+ | Read | `read root parent basename [maxBytes]` | Raw bytes on stdout; optional nonnegative integer byte limit. |
77
+ | Write | `write root parent basename mkdir` | Raw stdin; atomically replaces a file entry. |
78
+ | Create | `create root parent basename mkdir` | Raw stdin; exclusively publishes a completed file. |
79
+ | Copy | `copy srcRoot srcParent srcBasename dstRoot dstParent dstBasename mkdir` | Regular-file copy, atomically replaces destination. |
80
+ | Rename | `rename srcRoot srcParent srcBasename dstRoot dstParent dstBasename mkdir` | Rename with cross-device copy/delete fallback. |
81
+ | Remove | `remove root parent basename recursive force` | Removes the leaf, or recursively removes its tree. |
82
+ | Make directories | `mkdirp root directory` | Creates missing relative directory components. |
83
+ | List directory | `readdir root directory` | JSON array of `{ name, isDirectory }`; no sorting guarantee. |
84
+
85
+ The complete program rejects empty, `.`, `..`, slash-containing, and NUL
86
+ basenames before opening roots or creating parents. Both leaf operands of copy
87
+ and rename are checked. Backslashes, colons, quotes, and newlines remain legal
88
+ POSIX basename characters. Relative directory traversal rejects `..`; empty
89
+ and `.` components are skipped. Supply admitted relative paths, not arbitrary
90
+ absolute paths whose spelling happens to pass that component walk.
91
+
92
+ ## Boundary and operation behavior
93
+
94
+ The supplied root spelling is trusted. Opening it does not prove the root is
95
+ the mount or inode previously authorized by the caller. Basename syntax checks
96
+ are not authorization. Callers must admit their roots and paths, preserve
97
+ read-only shadows and live authority, and run the program inside the intended
98
+ OS isolation boundary.
99
+
100
+ Parent traversal and operation bodies use directory descriptors. Reads and
101
+ copies refuse final symlinks, hardlinked files, and nonregular files. Bounded
102
+ reads check both admitted size and consumed bytes; a growing file can produce
103
+ partial stdout before rejection. Consumers must discard read output when the
104
+ exit status indicates failure. Removal unlinks a final symlink without
105
+ following it; rename moves symlink entries. Write and copy replace destination
106
+ entries, including symlinks, without following them. A force removal tolerates
107
+ a missing leaf but does not suppress failure to open its parent.
108
+
109
+ Write preserves an existing regular file's mode and otherwise creates private
110
+ files. Copy preserves the source mode. Exclusive create publishes a mode-0600
111
+ file from a private staging directory. Staging names retain the `.openclaw-*`
112
+ prefixes and use short random suffixes independent of the destination basename,
113
+ so legal names near the filesystem's component limit also work for writes and
114
+ cross-device moves.
115
+
116
+ Cross-device symlink moves create the new link in a private destination-side
117
+ staging directory before atomically replacing the destination. Link creation or
118
+ publication failure preserves the existing destination and source link; ordinary
119
+ failure cleanup removes the staging directory.
120
+
121
+ Cross-device directory moves build a copy manifest and check it during source
122
+ cleanup. Source changes can leave the published destination and some or all
123
+ of the source. Regular-file and symlink move fallbacks unlink the source
124
+ pathname after publication; they do not perform the directory manifest's
125
+ identity checks. Directory cleanup also has check-to-unlink race windows.
126
+ These mechanics do not promise content integrity against same-UID peers.
127
+
128
+ ## Failure, cancellation, and budgets
129
+
130
+ Nonzero exit is not proof that nothing was published. A post-publication
131
+ identity or sync failure, or cross-device source-cleanup failure, can leave a
132
+ destination. The caller owns reconciliation and retry policy; do not assume
133
+ it is safe to remove that destination or replay a mutation blindly.
134
+
135
+ Ordinary Python failures run `finally` cleanup. There is no cancellation frame
136
+ or signal handler. Process death closes descriptors, but forced termination
137
+ can leave staging names. The transport owns termination, waiting for children
138
+ to settle, and any application-specific recovery.
139
+
140
+ Read byte limits are optional. Copy, write, recursive removal, directory
141
+ listing, and cross-device tree moves have no byte, entry, or depth budget in
142
+ this protocol. Recursive traversal and listing collect entries eagerly.
143
+ Use caller-owned isolation and resource limits appropriate to the operation.
144
+ The existing fsync sequence is preserved; this is not a recursive transaction,
145
+ rollback facility, or a stronger durability guarantee than the underlying
146
+ filesystem provides.