@openclaw/fs-safe 0.9.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 (326) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/LICENSE +1 -0
  3. package/README.md +52 -4
  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 -0
  7. package/dist/advanced.d.ts.map +1 -1
  8. package/dist/advanced.js +5 -0
  9. package/dist/archive-crc32.d.ts.map +1 -1
  10. package/dist/archive-crc32.js +6 -1
  11. package/dist/archive-deadline.d.ts.map +1 -1
  12. package/dist/archive-deadline.js +3 -4
  13. package/dist/archive-durability.d.ts +6 -6
  14. package/dist/archive-durability.d.ts.map +1 -1
  15. package/dist/archive-durability.js +1 -1
  16. package/dist/archive-entry.d.ts.map +1 -1
  17. package/dist/archive-entry.js +4 -5
  18. package/dist/archive-gzip-tail.d.ts +3 -0
  19. package/dist/archive-gzip-tail.d.ts.map +1 -1
  20. package/dist/archive-gzip-tail.js +23 -3
  21. package/dist/archive-input.d.ts.map +1 -1
  22. package/dist/archive-input.js +4 -2
  23. package/dist/archive-merge.d.ts +5 -1
  24. package/dist/archive-merge.d.ts.map +1 -1
  25. package/dist/archive-merge.js +16 -13
  26. package/dist/archive-native.d.ts.map +1 -1
  27. package/dist/archive-native.js +7 -6
  28. package/dist/archive-parser.wasm +0 -0
  29. package/dist/archive-read.d.ts.map +1 -1
  30. package/dist/archive-read.js +83 -74
  31. package/dist/archive-staging.d.ts +6 -3
  32. package/dist/archive-staging.d.ts.map +1 -1
  33. package/dist/archive-staging.js +42 -22
  34. package/dist/archive-tar-stream.d.ts +11 -4
  35. package/dist/archive-tar-stream.d.ts.map +1 -1
  36. package/dist/archive-tar-stream.js +23 -10
  37. package/dist/archive-tar-wasm.d.ts.map +1 -1
  38. package/dist/archive-tar-wasm.js +18 -14
  39. package/dist/archive-zip-admission.d.ts +1 -1
  40. package/dist/archive-zip-admission.d.ts.map +1 -1
  41. package/dist/archive-zip-admission.js +48 -12
  42. package/dist/archive-zip-loader.d.ts +6 -0
  43. package/dist/archive-zip-loader.d.ts.map +1 -0
  44. package/dist/archive-zip-loader.js +38 -0
  45. package/dist/archive-zip-names.d.ts.map +1 -1
  46. package/dist/archive-zip-names.js +13 -8
  47. package/dist/archive-zip-preflight.d.ts +2 -3
  48. package/dist/archive-zip-preflight.d.ts.map +1 -1
  49. package/dist/archive-zip-preflight.js +2 -34
  50. package/dist/archive.d.ts.map +1 -1
  51. package/dist/archive.js +12 -10
  52. package/dist/bounded-read.d.ts +12 -0
  53. package/dist/bounded-read.d.ts.map +1 -1
  54. package/dist/bounded-read.js +82 -45
  55. package/dist/clone-metadata.d.ts +19 -0
  56. package/dist/clone-metadata.d.ts.map +1 -0
  57. package/dist/clone-metadata.js +32 -0
  58. package/dist/copy-file-input.d.ts +22 -0
  59. package/dist/copy-file-input.d.ts.map +1 -0
  60. package/dist/copy-file-input.js +69 -0
  61. package/dist/copy-policy.d.ts +3 -0
  62. package/dist/copy-policy.d.ts.map +1 -0
  63. package/dist/copy-policy.js +8 -0
  64. package/dist/copy-publication.d.ts +10 -1
  65. package/dist/copy-publication.d.ts.map +1 -1
  66. package/dist/copy-publication.js +27 -0
  67. package/dist/copy-tree-portable.d.ts +9 -0
  68. package/dist/copy-tree-portable.d.ts.map +1 -0
  69. package/dist/copy-tree-portable.js +222 -0
  70. package/dist/copy.d.ts +16 -0
  71. package/dist/copy.d.ts.map +1 -0
  72. package/dist/copy.js +125 -0
  73. package/dist/directory-durability.d.ts.map +1 -1
  74. package/dist/directory-durability.js +5 -4
  75. package/dist/directory-guard.d.ts +11 -1
  76. package/dist/directory-guard.d.ts.map +1 -1
  77. package/dist/directory-guard.js +53 -11
  78. package/dist/durability.d.ts +1 -1
  79. package/dist/durability.d.ts.map +1 -1
  80. package/dist/durability.js +1 -1
  81. package/dist/error-detail.d.ts.map +1 -1
  82. package/dist/error-detail.js +4 -1
  83. package/dist/file-handle-transfer.d.ts +14 -0
  84. package/dist/file-handle-transfer.d.ts.map +1 -0
  85. package/dist/file-handle-transfer.js +64 -0
  86. package/dist/file-hash.d.ts +9 -2
  87. package/dist/file-hash.d.ts.map +1 -1
  88. package/dist/file-hash.js +135 -39
  89. package/dist/file-lock-sync.d.ts.map +1 -1
  90. package/dist/file-lock-sync.js +8 -4
  91. package/dist/file-store-boundary.d.ts.map +1 -1
  92. package/dist/file-store-boundary.js +7 -5
  93. package/dist/file-store-path.d.ts +3 -0
  94. package/dist/file-store-path.d.ts.map +1 -0
  95. package/dist/file-store-path.js +27 -0
  96. package/dist/file-store-prune.d.ts.map +1 -1
  97. package/dist/file-store-prune.js +6 -4
  98. package/dist/file-store-sync-write.d.ts.map +1 -1
  99. package/dist/file-store-sync-write.js +56 -44
  100. package/dist/file-store.d.ts.map +1 -1
  101. package/dist/file-store.js +2 -18
  102. package/dist/filename.d.ts.map +1 -1
  103. package/dist/filename.js +4 -13
  104. package/dist/guarded-mkdir.d.ts +1 -0
  105. package/dist/guarded-mkdir.d.ts.map +1 -1
  106. package/dist/guarded-mkdir.js +4 -2
  107. package/dist/guarded-mutation.d.ts +2 -0
  108. package/dist/guarded-mutation.d.ts.map +1 -1
  109. package/dist/guarded-mutation.js +8 -4
  110. package/dist/guest-dispatch-python.d.ts +2 -0
  111. package/dist/guest-dispatch-python.d.ts.map +1 -0
  112. package/dist/guest-dispatch-python.js +117 -0
  113. package/dist/guest-native-python.d.ts +4 -0
  114. package/dist/guest-native-python.d.ts.map +1 -0
  115. package/dist/guest-native-python.js +135 -0
  116. package/dist/guest.d.ts +9 -0
  117. package/dist/guest.d.ts.map +1 -0
  118. package/dist/guest.js +413 -0
  119. package/dist/home-dir.d.ts.map +1 -1
  120. package/dist/home-dir.js +9 -7
  121. package/dist/index.d.ts +1 -1
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/install-path.d.ts.map +1 -1
  124. package/dist/install-path.js +5 -8
  125. package/dist/json-durable-queue-directory.js +3 -3
  126. package/dist/json-durable-queue.d.ts.map +1 -1
  127. package/dist/json-durable-queue.js +19 -18
  128. package/dist/json.d.ts.map +1 -1
  129. package/dist/json.js +2 -1
  130. package/dist/local-roots.d.ts.map +1 -1
  131. package/dist/local-roots.js +24 -25
  132. package/dist/move-path-stage.d.ts +8 -0
  133. package/dist/move-path-stage.d.ts.map +1 -0
  134. package/dist/move-path-stage.js +56 -0
  135. package/dist/move-path.d.ts.map +1 -1
  136. package/dist/move-path.js +30 -34
  137. package/dist/mutation-authority.d.ts +9 -0
  138. package/dist/mutation-authority.d.ts.map +1 -0
  139. package/dist/mutation-authority.js +36 -0
  140. package/dist/native-binding.d.ts +29 -1
  141. package/dist/native-binding.d.ts.map +1 -1
  142. package/dist/native-operations.d.ts +1 -1
  143. package/dist/native-operations.d.ts.map +1 -1
  144. package/dist/native-operations.js +11 -5
  145. package/dist/native-pinned-write-windows.d.ts.map +1 -1
  146. package/dist/native-pinned-write-windows.js +19 -7
  147. package/dist/native-pinned-write.d.ts.map +1 -1
  148. package/dist/native-pinned-write.js +3 -1
  149. package/dist/native-staged-file.d.ts +3 -3
  150. package/dist/native-staged-file.d.ts.map +1 -1
  151. package/dist/native-staged-file.js +35 -8
  152. package/dist/opened-realpath.d.ts.map +1 -1
  153. package/dist/opened-realpath.js +5 -4
  154. package/dist/output.d.ts +2 -0
  155. package/dist/output.d.ts.map +1 -1
  156. package/dist/output.js +2 -0
  157. package/dist/overwrite-file-handle.d.ts +8 -0
  158. package/dist/overwrite-file-handle.d.ts.map +1 -0
  159. package/dist/overwrite-file-handle.js +42 -0
  160. package/dist/path-case.d.ts +7 -0
  161. package/dist/path-case.d.ts.map +1 -0
  162. package/dist/path-case.js +136 -0
  163. package/dist/path.d.ts.map +1 -1
  164. package/dist/path.js +5 -2
  165. package/dist/permissions-windows.d.ts +1 -1
  166. package/dist/permissions-windows.d.ts.map +1 -1
  167. package/dist/permissions-windows.js +85 -53
  168. package/dist/pinned-open.d.ts.map +1 -1
  169. package/dist/pinned-open.js +3 -1
  170. package/dist/pinned-operation.js +1 -1
  171. package/dist/pinned-write.d.ts +7 -3
  172. package/dist/pinned-write.d.ts.map +1 -1
  173. package/dist/pinned-write.js +51 -30
  174. package/dist/positional-read.d.ts +9 -0
  175. package/dist/positional-read.d.ts.map +1 -0
  176. package/dist/positional-read.js +36 -0
  177. package/dist/private-temp-workspace.d.ts.map +1 -1
  178. package/dist/private-temp-workspace.js +6 -4
  179. package/dist/publish-copy-stage.d.ts +13 -0
  180. package/dist/publish-copy-stage.d.ts.map +1 -0
  181. package/dist/publish-copy-stage.js +47 -0
  182. package/dist/publish-file.d.ts.map +1 -1
  183. package/dist/publish-file.js +6 -5
  184. package/dist/realpath.d.ts +4 -0
  185. package/dist/realpath.d.ts.map +1 -0
  186. package/dist/realpath.js +43 -0
  187. package/dist/recursive-mkdir-path.d.ts +3 -0
  188. package/dist/recursive-mkdir-path.d.ts.map +1 -0
  189. package/dist/recursive-mkdir-path.js +8 -0
  190. package/dist/replace-directory.d.ts.map +1 -1
  191. package/dist/replace-directory.js +2 -1
  192. package/dist/replace-file-copy-fallback.d.ts.map +1 -1
  193. package/dist/replace-file-copy-fallback.js +23 -31
  194. package/dist/replace-file-copy-source.d.ts.map +1 -1
  195. package/dist/replace-file-copy-source.js +7 -12
  196. package/dist/replace-file-mode.d.ts +3 -0
  197. package/dist/replace-file-mode.d.ts.map +1 -0
  198. package/dist/replace-file-mode.js +10 -0
  199. package/dist/replace-file-temp-owner.d.ts.map +1 -1
  200. package/dist/replace-file-temp-owner.js +6 -2
  201. package/dist/replace-file.d.ts +1 -0
  202. package/dist/replace-file.d.ts.map +1 -1
  203. package/dist/replace-file.js +14 -8
  204. package/dist/root-context.d.ts +3 -0
  205. package/dist/root-context.d.ts.map +1 -1
  206. package/dist/root-context.js +53 -28
  207. package/dist/root-create-input.d.ts +10 -0
  208. package/dist/root-create-input.d.ts.map +1 -0
  209. package/dist/root-create-input.js +80 -0
  210. package/dist/root-directory-list.d.ts +26 -0
  211. package/dist/root-directory-list.d.ts.map +1 -0
  212. package/dist/root-directory-list.js +219 -0
  213. package/dist/root-entries.d.ts +11 -0
  214. package/dist/root-entries.d.ts.map +1 -0
  215. package/dist/root-entries.js +61 -0
  216. package/dist/root-errors.d.ts +6 -5
  217. package/dist/root-errors.d.ts.map +1 -1
  218. package/dist/root-errors.js +16 -12
  219. package/dist/root-file.d.ts +2 -0
  220. package/dist/root-file.d.ts.map +1 -1
  221. package/dist/root-file.js +12 -4
  222. package/dist/root-impl.d.ts +17 -51
  223. package/dist/root-impl.d.ts.map +1 -1
  224. package/dist/root-impl.js +358 -234
  225. package/dist/root-options.d.ts +77 -0
  226. package/dist/root-options.d.ts.map +1 -0
  227. package/dist/root-options.js +18 -0
  228. package/dist/root-path-existing.d.ts +5 -0
  229. package/dist/root-path-existing.d.ts.map +1 -1
  230. package/dist/root-path-existing.js +59 -3
  231. package/dist/root-path-symlink.d.ts.map +1 -1
  232. package/dist/root-path-symlink.js +3 -2
  233. package/dist/root-path.d.ts +1 -0
  234. package/dist/root-path.d.ts.map +1 -1
  235. package/dist/root-path.js +74 -162
  236. package/dist/root-paths.d.ts.map +1 -1
  237. package/dist/root-paths.js +13 -9
  238. package/dist/root-remove.d.ts +5 -0
  239. package/dist/root-remove.d.ts.map +1 -0
  240. package/dist/root-remove.js +286 -0
  241. package/dist/root-symlink-policy.d.ts +14 -0
  242. package/dist/root-symlink-policy.d.ts.map +1 -0
  243. package/dist/root-symlink-policy.js +34 -0
  244. package/dist/root-walk.d.ts +5 -4
  245. package/dist/root-walk.d.ts.map +1 -1
  246. package/dist/root-walk.js +99 -50
  247. package/dist/root-write-mode.d.ts.map +1 -1
  248. package/dist/root-write-mode.js +3 -5
  249. package/dist/root.d.ts +4 -1
  250. package/dist/root.d.ts.map +1 -1
  251. package/dist/secret-file.d.ts.map +1 -1
  252. package/dist/secret-file.js +2 -1
  253. package/dist/secret-read-async.d.ts.map +1 -1
  254. package/dist/secret-read-async.js +2 -1
  255. package/dist/secure-file.d.ts.map +1 -1
  256. package/dist/secure-file.js +25 -8
  257. package/dist/secure-temp-dir.d.ts.map +1 -1
  258. package/dist/secure-temp-dir.js +2 -1
  259. package/dist/sibling-staged-file.d.ts +1 -0
  260. package/dist/sibling-staged-file.d.ts.map +1 -1
  261. package/dist/sibling-staged-file.js +42 -8
  262. package/dist/sibling-temp.d.ts +2 -0
  263. package/dist/sibling-temp.d.ts.map +1 -1
  264. package/dist/sibling-temp.js +6 -4
  265. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  266. package/dist/sidecar-lock-acquire.js +17 -5
  267. package/dist/sidecar-lock-policy.d.ts +2 -0
  268. package/dist/sidecar-lock-policy.d.ts.map +1 -1
  269. package/dist/sidecar-lock-policy.js +20 -1
  270. package/dist/staged-directory.d.ts.map +1 -1
  271. package/dist/staged-directory.js +4 -3
  272. package/dist/temp-cleanup.d.ts.map +1 -1
  273. package/dist/temp-cleanup.js +3 -2
  274. package/dist/temp-target.d.ts +14 -12
  275. package/dist/temp-target.d.ts.map +1 -1
  276. package/dist/temp-target.js +12 -6
  277. package/dist/timing.d.ts +1 -0
  278. package/dist/timing.d.ts.map +1 -1
  279. package/dist/timing.js +25 -6
  280. package/dist/trash.d.ts.map +1 -1
  281. package/dist/trash.js +9 -7
  282. package/dist/unicode-path.d.ts +3 -0
  283. package/dist/unicode-path.d.ts.map +1 -0
  284. package/dist/unicode-path.js +13 -0
  285. package/dist/walk.d.ts.map +1 -1
  286. package/dist/walk.js +9 -28
  287. package/dist/windows-owner.d.ts +9 -12
  288. package/dist/windows-owner.d.ts.map +1 -1
  289. package/dist/windows-owner.js +24 -58
  290. package/dist/write-file-handle.d.ts +7 -0
  291. package/dist/write-file-handle.d.ts.map +1 -0
  292. package/dist/write-file-handle.js +26 -0
  293. package/docs/advanced.md +22 -4
  294. package/docs/archive.md +44 -9
  295. package/docs/atomic.md +24 -1
  296. package/docs/config.md +1 -0
  297. package/docs/contributing.md +36 -1
  298. package/docs/copy.md +155 -0
  299. package/docs/directory-identity.md +85 -0
  300. package/docs/durability.md +53 -3
  301. package/docs/entries.md +109 -0
  302. package/docs/errors.md +4 -4
  303. package/docs/file-store.md +15 -0
  304. package/docs/guest.md +141 -0
  305. package/docs/in-place-write.md +81 -0
  306. package/docs/index.md +2 -0
  307. package/docs/install.md +31 -0
  308. package/docs/local-roots.md +8 -1
  309. package/docs/native-helper.md +10 -3
  310. package/docs/native.md +18 -1
  311. package/docs/output.md +32 -6
  312. package/docs/path-case.md +64 -0
  313. package/docs/path-scope.md +1 -1
  314. package/docs/permissions.md +29 -10
  315. package/docs/positional-read.md +63 -0
  316. package/docs/public-api.md +31 -2
  317. package/docs/root.md +196 -6
  318. package/docs/secure-file.md +2 -0
  319. package/docs/security-model.md +14 -0
  320. package/docs/sidecar-lock.md +12 -3
  321. package/docs/temp.md +35 -6
  322. package/docs/timing.md +2 -0
  323. package/docs/types.md +19 -12
  324. package/docs/walk.md +54 -5
  325. package/docs/writing.md +173 -5
  326. package/package.json +19 -8
package/docs/root.md CHANGED
@@ -18,6 +18,7 @@ const fs = await root("/srv/workspace", {
18
18
  function root(rootDir: string, defaults?: RootDefaults): Promise<Root>;
19
19
 
20
20
  type RootDefaults = {
21
+ assertBeforeMutation?: () => void; // synchronous caller authority check at mutation dispatch
21
22
  durable?: boolean; // fsync write/create/writeJson/createJson/append/copyIn; default true
22
23
  hardlinks?: "reject" | "allow"; // refuse files with nlink > 1 on read; defaults to "reject"
23
24
  denyMutations?: DenyMutationPolicy; // absolute paths/prefixes mutation methods may not change
@@ -26,7 +27,8 @@ type RootDefaults = {
26
27
  mode?: number; // file mode applied to new writes; per-call override available
27
28
  nonBlockingRead?: boolean; // compatibility hint; safe opens are already nonblocking where supported
28
29
  renameIdentity?: "strict" | "verify-content-with-lock"; // default "strict"
29
- symlinks?: "reject" | "follow-within-root"; // policy when a path component is a symlink
30
+ symlinks?: "reject" | "follow-within-root" | "follow-parents-within-root"; // read policy
31
+ mutationSymlinks?: "reject" | "follow-parents-within-root"; // opt-in mutation policy
30
32
  };
31
33
 
32
34
  type DenyMutationPolicy = {
@@ -37,7 +39,9 @@ type DenyMutationPolicy = {
37
39
 
38
40
  `root()` resolves the directory through the real filesystem. A symlinked input becomes the canonical path; a non-existent root throws `FsSafeError` with code `not-found`, and malformed or non-directory roots throw `invalid-path`.
39
41
 
40
- `defaults` apply to every method on the returned handle. Per-call options on individual methods override the defaults for that call only, except `denyMutations`: root and per-call deny entries are merged so a call cannot clear a root-level deny.
42
+ The root directory is pinned with exact bigint device/inode identities. A changed root rejects subsequent operations; an unknown Windows identity that remains unverifiable after bounded reinspection rejects construction with `path-mismatch`.
43
+
44
+ `defaults` apply to every method on the returned handle. Per-call options on individual methods override the defaults for that call only, except `denyMutations` and `assertBeforeMutation`: deny entries are merged, and the root assertion runs before the per-call assertion. A call cannot clear either root-level restriction.
41
45
 
42
46
  Every `maxBytes` value must be a non-negative safe integer or positive `Infinity`. Zero is an active zero-byte cap; `Infinity` explicitly disables the cap. An omitted or explicitly `undefined` per-call value preserves the configured Root default instead of clearing it.
43
47
 
@@ -60,7 +64,12 @@ fs.walk(rel, options) // root-bounded AsyncIterable<{ relativePath, kin
60
64
 
61
65
  `walk()` is the incremental, root-bounded recursive scan. It supports entry and
62
66
  depth budgets, cancellation, and `symlinkPolicy: "skip" |
63
- "follow-within-root"`. Budget exhaustion yields a `"truncated"` marker by
67
+ "follow-within-root"`. With an entry budget, sorted walks prepare small metadata
68
+ batches within the remaining budget; unbounded sorted walks reuse the full
69
+ directory snapshot.
70
+ The default `order: "sorted"` enumerates and sorts each directory's names;
71
+ `order: "filesystem"` streams names in filesystem order for bounded work in
72
+ wide directories. Budget exhaustion yields a `"truncated"` marker by
64
73
  default or throws `FsSafeError("too-large")` with `limitBehavior: "throw"`.
65
74
  Use `entryFilter(entry)` to return `"include"`, `"skip"`, or
66
75
  `"skip-subtree"`. `"skip"` omits the current entry but still descends into a
@@ -104,13 +113,25 @@ fs.append(rel, data, options?) // append text/buffer; syncs before clo
104
113
  fs.copyIn(rel, sourceAbsPath, options?) // copy from outside the root, atomically, with size cap
105
114
  fs.openWritable(rel, options?) // FileHandle for streaming writes; supports await using
106
115
  fs.move(from, to, options?) // rename within the root; defaults to no clobber
107
- fs.remove(rel, options?) // unlink file or rmdir empty directory
116
+ fs.remove(rel, options?) // unlink file, rmdir, or bounded recursive removal
108
117
  fs.mkdir(rel, options?) // mkdir -p (creates missing parents)
109
118
  fs.ensureRoot(options?) // accepts "" / "." as the root itself
110
119
  ```
111
120
 
112
121
  `write`, `create`, `append`, `writeJson`, and `createJson` accept `mode?: number`; use `0o600` for credentials and other private state. `writeJson` also accepts the same options as `JSON.stringify` plus `trailingNewline?: boolean` (defaults `true` so the file ends in `\n`).
113
122
 
123
+ `create` also accepts `AsyncIterable<Uint8Array>` with `RootCreateStreamOptions`:
124
+ the same path, authority, mode, and durability options, plus `maxBytes` and
125
+ `signal`, without `encoding` or `renameIdentity`. It consumes one chunk at a
126
+ time and publishes the completed file exclusively. The byte cap inherits an
127
+ explicit `Root.defaults.maxBytes`; without either cap, consumption is unlimited.
128
+ See [streamed creation](writing.md#streamed-creation) for cancellation,
129
+ cleanup, and filesystem requirements.
130
+
131
+ `append` accepts `prependNewlineIfNeeded: true` to separate text from existing
132
+ content when neither side supplies a newline. String data uses its `encoding`
133
+ for the newline check, including UTF-16LE; Buffer data uses a single LF byte.
134
+
114
135
  These five methods and `copyIn` also accept `durable?: boolean`: the per-call value overrides
115
136
  `Root.defaults.durable`, which defaults to `true` when omitted. An explicitly
116
137
  `undefined` per-call value preserves the root default. `durable: false` keeps
@@ -119,7 +140,76 @@ and parent-directory fsync calls. Use it only for reconstructible data: a crash
119
140
  may lose the write or leave the previous file. See [Writing](writing.md#write-options)
120
141
  for platform details.
121
142
 
122
- `copyIn` is a one-shot ingest from a trusted absolute source path: it streams the source through the boundary, atomically renames into the root, and respects `maxBytes`.
143
+ `copyIn` accepts a `RootCopySource`: a trusted absolute source path or a file
144
+ within another Root. The guarded form supplies `root` with only its `open` and
145
+ `stat` read capabilities, plus `relativePath`:
146
+
147
+ ```ts
148
+ const source = await root("/srv/templates");
149
+ const destination = await root("/srv/workspace");
150
+ await destination.copyIn("config/settings.json", {
151
+ root: source,
152
+ relativePath: "config/settings.json",
153
+ }, {
154
+ overwrite: false,
155
+ clone: "auto",
156
+ mode: 0o600,
157
+ signal: AbortSignal.timeout(30_000),
158
+ });
159
+ ```
160
+
161
+ The source Root applies its read policies, including confinement and symlink
162
+ handling. `sourceHardlinks` overrides its hardlink policy only when supplied;
163
+ otherwise the source Root default is retained. The admitted source
164
+ descriptor stays open through copying and source-identity verification; copying
165
+ does not consume its current file position. Both forms enforce `maxBytes` while
166
+ reading, including when a file grows after admission, and use bounded buffers.
167
+ Copies have independent file data; changing either file cannot change the other.
168
+ Set `preserveSourceMode: true` to select the mode from the admitted source
169
+ descriptor. An explicit numeric `mode`, including `Root.defaults.mode`, takes
170
+ precedence. By default, copying retains the existing destination-mode rules.
171
+ The operation verifies source identity, not a coherent snapshot of concurrent
172
+ in-place edits. Keep the source unchanged when snapshot consistency is required.
173
+
174
+ `overwrite` defaults to `true`, preserving the existing replacement behavior.
175
+ With `overwrite: false`, an existing destination produces `already-exists` and
176
+ is never altered. Copying prepares a private sibling file before publishing its
177
+ completed contents. Native mode uses no-replace rename. The guarded JavaScript
178
+ fallback links the completed stage and removes its temporary name in the same
179
+ JavaScript turn; the filesystem must support hardlinks. Other processes can
180
+ briefly observe both names. The source is never hardlinked to the destination.
181
+
182
+ `clone` chooses the file-data transfer strategy through `CopyCloneMode`, shared
183
+ with [`copyTree`](copy.md#api). File copies default to `"never"`; tree copies
184
+ default to `"auto"`:
185
+
186
+ | Value | Behavior |
187
+ | --- | --- |
188
+ | `never` | Copy regular file bytes using reads and writes, without explicit cloning or copy offload. |
189
+ | `auto` | Try native file cloning, then copy offload or ordinary byte copying when cloning is unavailable. |
190
+ | `always` | Require native cloning; fail when the binding or filesystem cannot provide it. |
191
+
192
+ Native file cloning supports APFS and supported Linux filesystems. Windows
193
+ currently uses byte copying for `never` and `auto`; `always` fails. Clone choice
194
+ does not change modes, durability, root confinement, or source and publication
195
+ identity checks. The shared strategy does not replace Root's guarded regular-file
196
+ contract with `copyTree`'s caller-owned immutable-tree and metadata contract.
197
+
198
+ An already aborted `signal` prevents I/O. Cancellation during copying waits for
199
+ admitted reads and native work to settle, then cleans only the owned unpublished
200
+ stage. The final authority check runs before publication. Once publication has
201
+ occurred, later cancellation or verification failure preserves the destination.
202
+ The synchronous optional `onDestinationPublished` callback receives a frozen
203
+ `RootCopyPublicationReceipt` containing `{ path, dev, ino }`, with exact bigint identity immediately after
204
+ publication, before later checks can fail. Callback errors also preserve the
205
+ published file. This receipt records an outcome; it does not authorize removing
206
+ a file that another actor may have edited. Application recovery and cooperative
207
+ locking remain caller-owned.
208
+
209
+ Existing `copyIn` callers must account for completed destinations retained after
210
+ a post-publication source-verification failure, even without the new options.
211
+ Recovery must inspect current destination state rather than assume a rejected
212
+ copy left no file.
123
213
 
124
214
  Root operations that choose a new destination reject a leading Windows
125
215
  drive-relative spelling such as `C:name` on every platform. This applies to
@@ -131,8 +221,71 @@ basename first.
131
221
 
132
222
  `openWritable` opens a writable file with options `mode?: number` and `writeMode?: "replace" | "append" | "update"`. `replace` truncates existing files and is the default; `update` keeps existing contents. Use it for streaming output. Prefer `await using` for cleanup.
133
223
 
224
+ `remove` leaves non-empty directories unchanged unless `recursive: true` is
225
+ provided. Recursive removal defaults to streaming entries in filesystem order;
226
+ `order: "sorted"` processes each directory's children lexicographically. The
227
+ `maxEntries` (100,000 by default) and `maxDepth` (64 by default) budgets accept
228
+ explicit `Infinity` when the caller needs unlimited traversal. It never
229
+ follows discovered symlinks; an explicit `mutationSymlinks` policy rejects them,
230
+ while the omitted policy unlinks them. `force: true` ignores missing targets,
231
+ and `signal` stops further work after admitted I/O and resource cleanup settle.
232
+ Removal is not transactional: a budget, cancellation, policy, or identity
233
+ failure can leave a partially removed tree. See [removal](writing.md)
234
+ for the full counting and failure contract.
235
+
236
+ ### Live mutation authority
237
+
238
+ All mutation methods accept `assertBeforeMutation?: () => void`. Use it when a
239
+ lease, operation owner, or cancellation state can expire while filesystem
240
+ preparation is awaiting I/O:
241
+
242
+ ```ts
243
+ const controller = new AbortController();
244
+ await fs.write("config.json", "{}\n", {
245
+ assertBeforeMutation: () => controller.signal.throwIfAborted(),
246
+ });
247
+ ```
248
+
249
+ The callback runs synchronously after awaited preparation, immediately before
250
+ each Root-owned mutation is dispatched: parent creation, file creation and
251
+ content writes (including private staging and streamed chunks), publication,
252
+ truncation, append, move, and removal. Buffered writes use bounded chunks and
253
+ recheck before every partial-write submission; file removal submits a direct
254
+ unlink request. Native calls that perform multiple filesystem steps are one
255
+ dispatch. No asynchronous wait separates the check
256
+ from that dispatch. A thrown value rejects the operation unchanged; an async
257
+ or thenable-returning callback rejects with `TypeError` before that mutation.
258
+ Synchronous return values are ignored. Callbacks can run multiple times and
259
+ must inspect current authority each time.
260
+
261
+ Already dispatched I/O cannot be revoked. Identity-checked cleanup, final
262
+ permissions, and durability finish under the existing operation owner even
263
+ after authority expires. Sidecar lock acquisition, recovery, and release for
264
+ `renameIdentity: "verify-content-with-lock"` are lock bookkeeping outside this
265
+ callback; content mutations still recheck after the lock is acquired. An
266
+ operation may leave already-created parent directories when a later check
267
+ rejects. A no-op such as `ensureRoot()` on the existing root does not require a
268
+ callback invocation. This is a dispatch fence, not a filesystem transaction or
269
+ a replacement for root confinement.
270
+
271
+ If cleanup also fails or cannot prove ownership of an entry, the existing
272
+ structured cleanup error takes precedence and retains the authority refusal
273
+ as its cause.
274
+
275
+ For `openWritable()`, the callback covers the library's parent creation,
276
+ exclusive creation, and truncation. The returned raw `FileHandle` belongs to
277
+ the caller, which must check authority before its own later writes.
278
+
134
279
  All mutation methods accept `denyMutations?: { paths?: string[]; prefixes?: string[] }`. Entries must be absolute paths. `paths` blocks those exact paths; `prefixes` blocks those paths and their descendants. fs-safe preserves path strings exactly and canonicalizes through existing ancestors before comparing, so a symlinked ancestor to a denied location is still denied. Denied mutations throw `FsSafeError` with code `denied-path`. Use this for caller-specific sensitive paths, not as a replacement for the root boundary, symlink, or hardlink checks.
135
280
 
281
+ All mutation methods also accept `mutationSymlinks`. `"reject"` rejects symlink
282
+ components; `"follow-parents-within-root"` resolves contained parent directory
283
+ aliases but rejects the final component if it is a symlink, including a dangling
284
+ link. Missing parent directories can still be created through a contained alias.
285
+ `move()` applies the policy to both source and destination. An omitted value
286
+ preserves existing behavior, including `remove()` unlinking a final symlink.
287
+ The read-only `symlinks` default does not change mutation behavior.
288
+
136
289
  ### Inspection (advisory)
137
290
 
138
291
  ```ts
@@ -140,14 +293,22 @@ fs.exists(rel) // boolean
140
293
  fs.stat(rel) // PathStat
141
294
  fs.list(rel) // string[]
142
295
  fs.list(rel, { withFileTypes }) // DirEntry[]
296
+ fs.entries(rel, options?) // nonrecursive AsyncIterable<DirEntry>, including symlinks
143
297
  fs.resolve(rel) // absolute path inside the root, after canonicalization
144
298
  ```
145
299
 
146
300
  These do not pin a later operation. They are safe to expose to UIs and decision points; for the actual read or write, use the verb methods so the operation pins identity at the point of use.
147
301
 
302
+ `entries()` streams immediate children in filesystem order by default. It
303
+ supports cancellation, a physical-entry limit that throws on overflow, and
304
+ bounded sorted-name collection. It reports child symlinks without following
305
+ them; its `symlinks` option applies only to the selected directory path.
306
+ See [Directory entries](entries.md) for ordering, identity, and partial-result
307
+ semantics.
308
+
148
309
  `resolve()` is the exception to the existing-object rule: because it selects a
149
310
  location for later use, it rejects a leading drive-relative spelling. Reads,
150
- `stat`, `exists`, `list`, `walk`, `remove`, and the source argument of `move`
311
+ `stat`, `exists`, `list`, `entries`, `walk`, `remove`, and the source argument of `move`
151
312
  accept an existing POSIX filename such as `c:notes.txt`. For `move`, only the
152
313
  new destination name is subject to the portable guard.
153
314
 
@@ -218,6 +379,35 @@ await fs.readText("config.toml");
218
379
  await fs.readText("links/current.log", { symlinks: "follow-within-root" });
219
380
  ```
220
381
 
382
+ With `follow-within-root`, parent components after a symlink are applied to the
383
+ symlink's resolved target. Reads use that checked canonical path, including
384
+ after home expansion and through `readAbsolute` and `reader`; the default policy still rejects a symlink
385
+ even when a later `..` would hide it in a purely lexical normalization.
386
+
387
+ Use `follow-parents-within-root` when directory aliases are allowed but a final
388
+ file symlink should fail. Set each policy at the root to share that contract
389
+ between reads and mutations:
390
+
391
+ ```ts
392
+ const workspace = await root("/srv/workspace", {
393
+ symlinks: "follow-parents-within-root",
394
+ mutationSymlinks: "follow-parents-within-root",
395
+ });
396
+ await workspace.readText("directory-alias/notes.txt");
397
+ await workspace.write("directory-alias/notes.txt", "updated\n");
398
+ ```
399
+
400
+ The library uses the resolved parent for the operation and checks the final
401
+ component again before publication or removal. These checks preserve the existing
402
+ [platform containment guarantees](security-model.md#symlinks-write-side);
403
+ they do not make check-and-rename atomic against another process. Callers do not
404
+ need a separate `realpath()` or final `lstat()` preflight.
405
+
406
+ For methods that accept absolute paths, the same final-component rule applies
407
+ when a path enters the root through an alias outside its lexical spelling. A directory alias may
408
+ lead to a regular file inside the root; an absolute final file or directory
409
+ symlink is rejected before its canonical target replaces the original path.
410
+
221
411
  Text helpers default to UTF-8. Pass `encoding` per call to `readText`, `readJson`, `write`, `create`, or `append` when you need another encoding.
222
412
 
223
413
  ## Common patterns
@@ -21,6 +21,7 @@ The helper:
21
21
  - rejects every non-regular preview and, by default, symlink paths
22
22
  - opens POSIX paths no-follow and nonblocking before reading, then verifies the opened fd still matches the path and realpath; a FIFO swap cannot block before `timeoutMs` owns the byte read
23
23
  - optionally requires the real path to live under one of `trust.trustedDirs`
24
+ - rejects hardlink aliases using descriptor, pathname, and realpath link counts, then rechecks the descriptor after reading before returning bytes
24
25
  - rejects hard-to-verify or unsafe permissions unless `permissions.allowInsecure` is set
25
26
  - rejects files owned by another POSIX uid
26
27
  - enforces `maxBytes` before and after reading
@@ -73,6 +74,7 @@ type SecureFileReadOptions = {
73
74
  | `not-found` | The path could not be stat'd before open. |
74
75
  | `not-file` | The opened target is not a regular file. |
75
76
  | `symlink` | The path is a symlink and `trust.allowSymlink` is false. |
77
+ | `hardlink` | The descriptor, pathname, or realpath has more than one link. |
76
78
  | `path-mismatch` | The path or realpath changed between open and verification, or filesystem identity could not be verified after bounded re-inspection. |
77
79
  | `outside-workspace` | `realPath` is outside `trust.trustedDirs`. |
78
80
  | `permission-unverified` | Required mode/ACL checks could not be completed. |
@@ -53,6 +53,11 @@ Every path is resolved against the canonicalized real path of the root, then che
53
53
 
54
54
  Per-call `symlinks: "follow-within-root"` allows symlinks whose final target is still inside the root. The default is `"reject"`.
55
55
 
56
+ `symlinks: "follow-parents-within-root"` allows contained parent directory aliases
57
+ while rejecting a final symlink, including dangling links. Reads open the checked
58
+ canonical parent plus the final basename with the usual no-follow and identity
59
+ checks, so callers do not need their own parent canonicalization.
60
+
56
61
  Guarded root reads compare lossless bigint identities from before open, the opened
57
62
  descriptor, the input path, and the canonical target; numeric public `Stats`
58
63
  receipts are not used as identity evidence. Unknown Windows device/inode values
@@ -70,6 +75,15 @@ directory descriptors. Replacement uses descriptor-relative rename just like
70
75
  no-replace publication, so replacing the parent pathname does not divert the
71
76
  mutation.
72
77
 
78
+ The opt-in `mutationSymlinks` policy applies independently of read policy.
79
+ `"reject"` rejects symlink components; `"follow-parents-within-root"` resolves
80
+ contained directory aliases and rejects final symlinks. Publication checks the
81
+ final component again after awaited staging and parent fences, immediately before
82
+ the rename or exclusive open. These are best-effort symlink checks, not an atomic
83
+ expected-entry/CAS replacement: a concurrent process can still replace the final
84
+ entry between its check and rename. Existing parent containment guarantees remain
85
+ as described below. Omitting `mutationSymlinks` preserves existing mutation behavior.
86
+
73
87
  The JavaScript fallback used by `off`, by `auto` when no binding loads, and by
74
88
  the explicit `renameIdentity: "verify-content-with-lock"` compatibility policy
75
89
  cannot provide that guarantee because Node exposes no `mkdirat` or `renameat`.
@@ -2,6 +2,8 @@
2
2
 
3
3
  `acquireFileLock()` and `withFileLock()` provide a cross-process file lock with retry and process-exit cleanup. The lock is implemented as a sidecar file (e.g. `state.json` ↔ `state.json.lock`) — only one acquirer can create the sidecar with `O_CREAT | O_EXCL` at a time.
4
4
 
5
+ JavaScript raw-sidecar creation passes mode `0o600` to that exclusive open. On POSIX, the process umask may further restrict the new file but cannot add group or other access. No pathname `chmod` fallback is used.
6
+
5
7
  ```ts
6
8
  import { acquireFileLock } from "@openclaw/fs-safe/file-lock";
7
9
 
@@ -61,7 +63,7 @@ function withFileLockSync<T, TPayload>(targetPath: string, options: FileLockSync
61
63
  type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
62
64
  managerKey?: string; // optional in-process manager namespace
63
65
  lockPath?: string; // override; defaults to `${targetPath}.lock`
64
- staleMs?: number; // default 30_000
66
+ staleMs?: number; // non-negative or Infinity; default 30_000
65
67
  timeoutMs?: number; // overall acquire deadline; default unbounded
66
68
  retry?: FileLockRetryOptions;
67
69
  staleRecovery?: "fail-closed" | "remove-if-unchanged"; // default "fail-closed"
@@ -86,7 +88,7 @@ type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
86
88
  lockRoot?: Root;
87
89
  retainOnExit?: boolean; // keep the sidecar across process exit (default false)
88
90
  onCompromised?: (info: { lockPath: string; normalizedTargetPath: string }) => void;
89
- compromiseCheckIntervalMs?: number;
91
+ compromiseCheckIntervalMs?: number; // 0/omitted disables; otherwise 1..2_147_483_647
90
92
  };
91
93
 
92
94
  type FileLockRetryOptions = {
@@ -245,7 +247,14 @@ type FileLockHandle = {
245
247
  captured at acquisition. Set `compromiseCheckIntervalMs` together with
246
248
  `onCompromised` for a cheap periodic check; the callback fires once after the
247
249
  sidecar no longer matches or after a verification I/O failure. This is
248
- detection, not revocation of work already in progress.
250
+ detection, not revocation of work already in progress. Asynchronous checks are
251
+ serialized, so a slow verification never overlaps the next timer tick.
252
+
253
+ The compromise-check interval is validated before payload evaluation or
254
+ filesystem acquisition. Omit it or pass `0` to disable monitoring; enabled
255
+ intervals must be finite and between 1 and 2,147,483,647 milliseconds. Values
256
+ outside that range are rejected instead of being clamped by Node.js to an
257
+ unexpectedly tight polling loop.
249
258
 
250
259
  ## Synchronous locks
251
260
 
package/docs/temp.md CHANGED
@@ -262,8 +262,8 @@ const result = await writeSiblingTempFile<string>({
262
262
  // result.filePath, result.result (returned by writeTemp)
263
263
  ```
264
264
 
265
- `writeSiblingTempFile` chooses a random, initially absent sibling name in `dir`
266
- and calls `writeTemp()`. After the callback succeeds, it validates the produced
265
+ By default, `writeSiblingTempFile` chooses a random, initially absent sibling
266
+ name in `dir` and calls `writeTemp()`. After the callback succeeds, it validates the produced
267
267
  regular file before taking ownership: symlinks, directories, other non-regular
268
268
  files, hardlinks, and changes between the pre-open pathname, opened descriptor,
269
269
  and current pathname are rejected. The callback must finish and close its
@@ -292,12 +292,40 @@ Omitting either option or passing `false` skips that sync, never the identity
292
292
  checks. Parent synchronization can be unsupported or fail without rejecting
293
293
  the write, so success is not a strict crash-durability receipt.
294
294
 
295
- Cleanup only unlinks an admitted file while the parent, pathname identity, and
296
- single-link regular-file checks still agree. Observed substitutes are preserved,
295
+ Without producer isolation, cleanup only unlinks an admitted file while the
296
+ parent, pathname identity, and single-link regular-file checks still agree.
297
+ Observed substitutes are preserved,
297
298
  including during process-exit cleanup. Operational cleanup failures retain an
298
299
  identity-bound exit retry. If the callback throws or admission fails, no file
299
300
  has been adopted: even a regular partial file is left for caller-directed
300
- recovery. The helper never recursively removes a sibling temp.
301
+ recovery. The helper never recursively removes a sibling temp file path.
302
+
303
+ Set `producerIsolation: "private-directory"` in `WriteSiblingTempFileOptions`
304
+ when the producer can leave partial output before throwing. The callback then
305
+ receives an initially absent file path inside a private child workspace under
306
+ `dir`, on the same filesystem as the final target. fs-safe captures directory
307
+ cleanup ownership before invoking the callback. A callback exception triggers
308
+ owned workspace cleanup, including partial output, subject to directory
309
+ identity checks and I/O failures. The callback must still finish and close its
310
+ writer before returning.
311
+
312
+ After the callback succeeds, `Root.move` checks source aliases and moves the
313
+ output to the ordinary sibling path before file admission. An escaping symlink
314
+ can fail with `path-alias` at this step. Rejected output still inside the owned
315
+ workspace follows its cleanup contract. Once output moves to the sibling path,
316
+ failures before file adoption retain it for caller-directed recovery, as above.
317
+ File admission, requested modes, sync options, and final rename keep their
318
+ existing contracts; `resolveFinalPath(result)` still names a direct child of `dir`.
319
+
320
+ The isolated path retains exact bigint identities for both the parent and the
321
+ workspace and rechecks them before moving output to the sibling path. An
322
+ observed replacement is rejected. Cleanup uses the existing
323
+ [`withTempFile` ownership contract](#withtempfile), backed by [`tempFile`](#tempfile):
324
+ moving or replacing the parent or workspace can leave the original or
325
+ replacement paths in place. This option does not promise cleanup through a
326
+ retained directory after a rename, stronger permissions, or additional crash
327
+ durability. Omitting it preserves the direct sibling callback path and
328
+ unadmitted partial-file retention.
301
329
 
302
330
  On POSIX, admission uses no-follow and nonblocking open flags, so a FIFO swap
303
331
  does not block the helper. Windows retains Node's guarded pathname-open behavior
@@ -345,7 +373,7 @@ If `replaceFileAtomic` does what you need, prefer that. Use
345
373
  the final destination still needs root-boundary checks.
346
374
  Its private workspace uses the same identity-aware directory cleanup as
347
375
  `tempFile()`: moving and replacing the workspace preserves the replacement.
348
- The callback staging component is capped at 255 bytes under NFC and NFD by
376
+ The callback staging component is capped at 255 bytes as written and under NFC and NFD by
349
377
  shortening only an overlong embedded destination tail, while preserving an
350
378
  extension when possible. Short callback paths and the final target stay
351
379
  unchanged. This workspace owns its contents, unlike the unadmitted sibling
@@ -440,6 +468,7 @@ import fs from "node:fs/promises";
440
468
 
441
469
  const r = await writeSiblingTempFile({
442
470
  dir: "/srv/cache",
471
+ producerIsolation: "private-directory",
443
472
  writeTemp: async (tempPath) => {
444
473
  const handle = await fs.open(tempPath, "w");
445
474
  try {
package/docs/timing.md CHANGED
@@ -22,6 +22,8 @@ function withTimeout<T>(
22
22
 
23
23
  If `timeoutMs` is `0`, negative, `Infinity`, or `NaN`, the helper is a no-op and simply awaits the original promise.
24
24
 
25
+ Finite delays above Node's single-timer limit (2,147,483,647 ms, about 24.9 days) are scheduled in bounded intervals without expiring early. The timer is still cleared if the wrapped promise settles first.
26
+
25
27
  ## Examples
26
28
 
27
29
  ### Simple ceiling
package/docs/types.md CHANGED
@@ -45,7 +45,7 @@ type DirEntry = PathStat & {
45
45
  };
46
46
  ```
47
47
 
48
- Returned by `Root.list(rel, { withFileTypes: true })`. Includes every
48
+ Returned by `Root.list(rel, { withFileTypes: true })` and [`Root.entries()`](entries.md). Includes every
49
49
  `PathStat` field plus the entry's `name`.
50
50
 
51
51
  ## `BasePathOptions`
@@ -99,6 +99,7 @@ type ReadResult = {
99
99
  type RenameIdentityPolicy = "strict" | "verify-content-with-lock";
100
100
 
101
101
  type RootDefaults = {
102
+ assertBeforeMutation?: () => void;
102
103
  denyMutations?: DenyMutationPolicy;
103
104
  durable?: boolean; // default true for write/create/writeJson/createJson/append
104
105
  hardlinks?: "reject" | "allow";
@@ -107,7 +108,8 @@ type RootDefaults = {
107
108
  mode?: number;
108
109
  nonBlockingRead?: boolean;
109
110
  renameIdentity?: RenameIdentityPolicy;
110
- symlinks?: "reject" | "follow-within-root";
111
+ symlinks?: "reject" | "follow-within-root" | "follow-parents-within-root";
112
+ mutationSymlinks?: MutationSymlinkPolicy;
111
113
  };
112
114
 
113
115
  type DenyMutationPolicy = {
@@ -121,20 +123,20 @@ type RootOptions = {
121
123
  };
122
124
  ```
123
125
 
124
- `RootDefaults` is what `root(rootDir, defaults)` accepts. See [`root()`](root.md) for the per-method options that override these. `denyMutations` is the exception: root and per-call deny entries are merged.
126
+ `RootDefaults` is what `root(rootDir, defaults)` accepts. See [`root()`](root.md) for the per-method options that override these. `denyMutations` and `assertBeforeMutation` are exceptions: deny entries are merged, and the root authority assertion runs before the per-call assertion.
125
127
 
126
128
  ## `RootReadOptions` / `RootWriteOptions` / `RootCopyOptions`
127
129
 
128
130
  ```ts
129
131
  type RootReadOptions = Pick<RootDefaults, "hardlinks" | "maxBytes" | "nonBlockingRead" | "symlinks">;
130
- type RootWriteOptions = Pick<RootDefaults, "denyMutations" | "durable" | "mkdir" | "mode" | "renameIdentity"> & {
132
+ type RootWriteOptions = Pick<RootDefaults, "assertBeforeMutation" | "denyMutations" | "durable" | "mkdir" | "mode" | "renameIdentity" | "mutationSymlinks"> & {
131
133
  encoding?: BufferEncoding;
132
134
  overwrite?: boolean;
133
135
  };
134
- type RootCopyOptions = Pick<RootDefaults, "denyMutations" | "maxBytes" | "mkdir" | "mode"> & {
136
+ type RootCopyOptions = Pick<RootDefaults, "assertBeforeMutation" | "denyMutations" | "durable" | "maxBytes" | "mkdir" | "mode" | "mutationSymlinks"> & {
135
137
  sourceHardlinks?: "reject" | "allow";
136
138
  };
137
- type RootOpenWritableOptions = Pick<RootDefaults, "denyMutations" | "mkdir" | "mode"> & {
139
+ type RootOpenWritableOptions = Pick<RootDefaults, "assertBeforeMutation" | "denyMutations" | "mkdir" | "mode" | "mutationSymlinks"> & {
138
140
  writeMode?: "replace" | "append" | "update";
139
141
  };
140
142
  type RootWriteJsonOptions = RootWriteOptions & {
@@ -145,23 +147,28 @@ type RootWriteJsonOptions = RootWriteOptions & {
145
147
  type RootAppendOptions = RootWriteOptions & {
146
148
  prependNewlineIfNeeded?: boolean;
147
149
  };
148
- type RootMoveOptions = Pick<RootDefaults, "denyMutations"> & {
150
+ type RootMoveOptions = Pick<RootDefaults, "assertBeforeMutation" | "denyMutations" | "mutationSymlinks"> & {
149
151
  overwrite?: boolean;
150
152
  };
151
- type RootRemoveOptions = Pick<RootDefaults, "denyMutations">;
152
- type RootMkdirOptions = Pick<RootDefaults, "denyMutations">;
153
+ type RootRemoveOptions = Pick<RootDefaults, "assertBeforeMutation" | "denyMutations" | "mutationSymlinks">;
154
+ type RootMkdirOptions = Pick<RootDefaults, "assertBeforeMutation" | "denyMutations" | "mutationSymlinks">;
153
155
  ```
154
156
 
155
157
  Per-method option shapes. Each picks the `RootDefaults` keys that apply, plus method-specific extras.
156
158
 
157
- ## `SymlinkPolicy` / `HardlinkPolicy`
159
+ ## `SymlinkPolicy` / `MutationSymlinkPolicy` / `HardlinkPolicy`
158
160
 
159
161
  ```ts
160
- type SymlinkPolicy = "reject" | "follow-within-root";
162
+ type SymlinkPolicy = "reject" | "follow-within-root" | "follow-parents-within-root";
163
+ type MutationSymlinkPolicy = "reject" | "follow-parents-within-root";
161
164
  type HardlinkPolicy = "reject" | "allow";
162
165
  ```
163
166
 
164
- The two policy unions you'll see throughout. `"reject"` is conservative; `"follow-within-root"` allows symlinks whose final target is still inside the root; `"allow"` (hardlinks only) is permissive. Defaults for both symlinks and hardlinks are `"reject"`; switch hardlinks to `"allow"` only when you intentionally accept hardlink aliases.
167
+ `"reject"` is conservative; `"follow-within-root"` allows symlinks whose final target is still inside the root; `"allow"` (hardlinks only) is permissive. Defaults for read symlinks and hardlinks are `"reject"`; switch hardlinks to `"allow"` only when you intentionally accept hardlink aliases.
168
+
169
+ `"follow-parents-within-root"` allows contained parent directory aliases while
170
+ rejecting final symlinks. Mutation policy is opt-in and independent of read
171
+ policy; omission preserves each mutation method's existing behavior.
165
172
 
166
173
  ## `FsSafeErrorCode` / `FsSafeErrorCategory`
167
174
 
package/docs/walk.md CHANGED
@@ -72,10 +72,57 @@ Unreadable directories are skipped rather than throwing, but every skipped direc
72
72
  `Root.walk(rel, options)` is the root-bounded counterpart to these standalone
73
73
  inventory helpers. It yields `{ relativePath, kind, size }` incrementally and
74
74
  accepts `maxDepth`, `maxEntries`, `symlinkPolicy: "skip" |
75
- "follow-within-root"`, and an `AbortSignal`. The default budget behavior yields
75
+ "follow-within-root"`, `order: "sorted" | "filesystem"`, and an `AbortSignal`. The default budget behavior yields
76
76
  one `kind: "truncated"` marker and ends; pass `limitBehavior: "throw"` for a
77
77
  typed `FsSafeError("too-large")` instead.
78
78
 
79
+ For followed symlinks, both `kind` and `size` describe the resolved target.
80
+
81
+ The default `order: "sorted"` visits each directory's names in lexicographic
82
+ order before descending depth first. It reads and sorts all names in each
83
+ visited directory. With `maxEntries`, it prepares small metadata batches capped
84
+ by the remaining global entry budget. Every batch stops at the first directory
85
+ or symlink, so recursive descent cannot spend a budget already used by later
86
+ siblings. An early `break` may leave metadata from the current batch unused;
87
+ the total still stays within `maxEntries`. Filtering requires metadata and
88
+ consumes the entry budget, including entries skipped by the filter.
89
+
90
+ Without `maxEntries`, sorted walks reuse a full directory metadata snapshot
91
+ from the `Root.list()` owner. This preserves the existing fast complete-scan
92
+ behavior and its snapshot semantics: changes made after a directory is listed
93
+ do not alter its already-captured entries. Supply an entry budget or use
94
+ filesystem order when metadata work must remain incremental. Sorted entries
95
+ describe the observations captured in their directory snapshot or batch.
96
+
97
+ Use `order: "filesystem"` when a wide directory must not be fully enumerated:
98
+
99
+ ```ts
100
+ for await (const entry of capability.walk("", {
101
+ order: "filesystem",
102
+ maxEntries: 128,
103
+ symlinkPolicy: "skip",
104
+ })) {
105
+ consume(entry);
106
+ }
107
+ ```
108
+
109
+ This order follows the filesystem's directory stream and is not deterministic.
110
+ It reads one entry at a time, including one name of lookahead to distinguish an
111
+ exactly exhausted budget from truncation. The lookahead does not request full
112
+ entry metadata from fs-safe, and an early `break` does not prefetch later child
113
+ metadata. If a filesystem does not supply directory-entry
114
+ types, Node may classify that one extra entry with a synchronous `lstat`.
115
+ Handles close on completion, truncation, cancellation, errors, or an
116
+ early `break`. Both orders keep the same depth-first traversal, entry filtering,
117
+ and truncation rules. Cancellation is checked between entries, with event-loop
118
+ handoffs between budgeted sorted batches. Root and directory checks and admitted
119
+ child metadata reads are synchronous; no mode can interrupt a filesystem
120
+ syscall already in progress or the sorted mode's name sorting.
121
+
122
+ If a thrown walk failure and directory close both fail, disposal throws a
123
+ `SuppressedError` with the close failure in `error` and the original failure in
124
+ `suppressed`, preserving both causes.
125
+
79
126
  `entryFilter` is evaluated for each resolved file, directory, or other entry:
80
127
 
81
128
  ```ts
@@ -109,10 +156,12 @@ Every examined directory entry consumes `maxEntries` before filtering, so
109
156
  `"truncated"` markers describe already-reached state and do not authorize
110
157
  further descent.
111
158
 
112
- The pure-Node path validates every directory canonically inside the root,
113
- revalidates each listing through the normal `Root.list()` boundary, and tracks
114
- canonical directories to stop symlink cycles. It does not hold a descriptor
115
- for the entire tree, so it is not a process sandbox against a hostile peer that
159
+ The pure-Node path validates every directory through the Root boundary, pins
160
+ its exact identity, and rechecks it and the Root identity around each metadata
161
+ batch or individual filesystem-order observation. Sorted batches contain no
162
+ await or caller code between their before/after checks. It tracks canonical
163
+ directories to stop symlink cycles.
164
+ Neither mode holds a descriptor for every path component, so it is not a process sandbox against a hostile peer that
116
165
  can continuously swap and restore directories. Each individual lookup retains
117
166
  the documented Node `Root` boundary checks.
118
167