@openclaw/fs-safe 0.8.6 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +17 -0
  3. package/dist/advanced.d.ts +1 -0
  4. package/dist/advanced.d.ts.map +1 -1
  5. package/dist/advanced.js +1 -0
  6. package/dist/archive-crc32.d.ts.map +1 -1
  7. package/dist/archive-crc32.js +6 -1
  8. package/dist/archive-deadline.d.ts.map +1 -1
  9. package/dist/archive-deadline.js +3 -4
  10. package/dist/archive-durability.d.ts +24 -0
  11. package/dist/archive-durability.d.ts.map +1 -0
  12. package/dist/archive-durability.js +180 -0
  13. package/dist/archive-gzip-tail.d.ts +2 -0
  14. package/dist/archive-gzip-tail.d.ts.map +1 -1
  15. package/dist/archive-gzip-tail.js +22 -4
  16. package/dist/archive-input.js +4 -4
  17. package/dist/archive-kind.d.ts.map +1 -1
  18. package/dist/archive-kind.js +3 -2
  19. package/dist/archive-merge.d.ts +1 -0
  20. package/dist/archive-merge.d.ts.map +1 -1
  21. package/dist/archive-merge.js +42 -9
  22. package/dist/archive-native.d.ts +1 -0
  23. package/dist/archive-native.d.ts.map +1 -1
  24. package/dist/archive-native.js +4 -2
  25. package/dist/archive-options.d.ts +2 -0
  26. package/dist/archive-options.d.ts.map +1 -1
  27. package/dist/archive-parser.wasm +0 -0
  28. package/dist/archive-read.d.ts.map +1 -1
  29. package/dist/archive-read.js +72 -70
  30. package/dist/archive-staging.d.ts +1 -1
  31. package/dist/archive-staging.d.ts.map +1 -1
  32. package/dist/archive-staging.js +24 -19
  33. package/dist/archive-tar-stream.d.ts +11 -4
  34. package/dist/archive-tar-stream.d.ts.map +1 -1
  35. package/dist/archive-tar-stream.js +20 -8
  36. package/dist/archive-tar-wasm.d.ts.map +1 -1
  37. package/dist/archive-tar-wasm.js +2 -1
  38. package/dist/archive.d.ts.map +1 -1
  39. package/dist/archive.js +9 -4
  40. package/dist/bounded-read.d.ts +7 -0
  41. package/dist/bounded-read.d.ts.map +1 -1
  42. package/dist/bounded-read.js +73 -43
  43. package/dist/clone-metadata.d.ts +19 -0
  44. package/dist/clone-metadata.d.ts.map +1 -0
  45. package/dist/clone-metadata.js +32 -0
  46. package/dist/copy-file-input.d.ts +23 -0
  47. package/dist/copy-file-input.d.ts.map +1 -0
  48. package/dist/copy-file-input.js +91 -0
  49. package/dist/copy-policy.d.ts +3 -0
  50. package/dist/copy-policy.d.ts.map +1 -0
  51. package/dist/copy-policy.js +8 -0
  52. package/dist/copy-publication.d.ts +15 -0
  53. package/dist/copy-publication.d.ts.map +1 -0
  54. package/dist/copy-publication.js +30 -0
  55. package/dist/copy-tree-portable.d.ts +9 -0
  56. package/dist/copy-tree-portable.d.ts.map +1 -0
  57. package/dist/copy-tree-portable.js +191 -0
  58. package/dist/copy.d.ts +16 -0
  59. package/dist/copy.d.ts.map +1 -0
  60. package/dist/copy.js +123 -0
  61. package/dist/directory-mode-node.d.ts.map +1 -1
  62. package/dist/directory-mode-node.js +4 -4
  63. package/dist/durability.d.ts +1 -1
  64. package/dist/durability.d.ts.map +1 -1
  65. package/dist/error-detail.d.ts.map +1 -1
  66. package/dist/error-detail.js +4 -1
  67. package/dist/file-hash.d.ts +6 -2
  68. package/dist/file-hash.d.ts.map +1 -1
  69. package/dist/file-hash.js +49 -12
  70. package/dist/file-store-sync-write.d.ts +1 -0
  71. package/dist/file-store-sync-write.d.ts.map +1 -1
  72. package/dist/file-store-sync-write.js +9 -6
  73. package/dist/file-store.d.ts +4 -0
  74. package/dist/file-store.d.ts.map +1 -1
  75. package/dist/file-store.js +10 -1
  76. package/dist/filename.d.ts.map +1 -1
  77. package/dist/filename.js +2 -12
  78. package/dist/fs.d.ts.map +1 -1
  79. package/dist/fs.js +1 -2
  80. package/dist/guarded-mkdir.d.ts +1 -0
  81. package/dist/guarded-mkdir.d.ts.map +1 -1
  82. package/dist/guarded-mkdir.js +1 -0
  83. package/dist/guarded-mutation.d.ts +2 -0
  84. package/dist/guarded-mutation.d.ts.map +1 -1
  85. package/dist/guarded-mutation.js +9 -5
  86. package/dist/home-dir.d.ts.map +1 -1
  87. package/dist/home-dir.js +9 -7
  88. package/dist/index.d.ts +1 -1
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/install-path.d.ts.map +1 -1
  91. package/dist/install-path.js +9 -13
  92. package/dist/json-document-store.d.ts +3 -0
  93. package/dist/json-document-store.d.ts.map +1 -1
  94. package/dist/json-document-store.js +1 -0
  95. package/dist/json-durable-queue-directory.js +3 -3
  96. package/dist/json-durable-queue-ownership.js +3 -2
  97. package/dist/json-durable-queue.d.ts.map +1 -1
  98. package/dist/json-durable-queue.js +31 -27
  99. package/dist/local-roots.d.ts.map +1 -1
  100. package/dist/local-roots.js +22 -24
  101. package/dist/move-path-cleanup.d.ts.map +1 -1
  102. package/dist/move-path-cleanup.js +4 -3
  103. package/dist/move-path-stage.d.ts +8 -0
  104. package/dist/move-path-stage.d.ts.map +1 -0
  105. package/dist/move-path-stage.js +55 -0
  106. package/dist/move-path.d.ts.map +1 -1
  107. package/dist/move-path.js +55 -47
  108. package/dist/mutation-authority.d.ts +8 -0
  109. package/dist/mutation-authority.d.ts.map +1 -0
  110. package/dist/mutation-authority.js +36 -0
  111. package/dist/native-binding.d.ts +24 -1
  112. package/dist/native-binding.d.ts.map +1 -1
  113. package/dist/native-operations.d.ts +1 -1
  114. package/dist/native-operations.d.ts.map +1 -1
  115. package/dist/native-operations.js +11 -5
  116. package/dist/native-pinned-write-windows.d.ts.map +1 -1
  117. package/dist/native-pinned-write-windows.js +18 -7
  118. package/dist/native-pinned-write.d.ts.map +1 -1
  119. package/dist/native-pinned-write.js +1 -0
  120. package/dist/native-staged-file.d.ts +3 -3
  121. package/dist/native-staged-file.d.ts.map +1 -1
  122. package/dist/native-staged-file.js +35 -8
  123. package/dist/opened-file-failure.d.ts +1 -1
  124. package/dist/opened-file-failure.d.ts.map +1 -1
  125. package/dist/opened-file-failure.js +3 -2
  126. package/dist/path.d.ts.map +1 -1
  127. package/dist/path.js +3 -1
  128. package/dist/permissions-windows.d.ts.map +1 -1
  129. package/dist/permissions-windows.js +38 -48
  130. package/dist/permissions.js +3 -3
  131. package/dist/pinned-operation.js +1 -1
  132. package/dist/pinned-write.d.ts +5 -1
  133. package/dist/pinned-write.d.ts.map +1 -1
  134. package/dist/pinned-write.js +50 -30
  135. package/dist/positional-read.d.ts +9 -0
  136. package/dist/positional-read.d.ts.map +1 -0
  137. package/dist/positional-read.js +36 -0
  138. package/dist/private-temp-workspace.d.ts.map +1 -1
  139. package/dist/private-temp-workspace.js +9 -3
  140. package/dist/publish-copy-stage.d.ts +13 -0
  141. package/dist/publish-copy-stage.d.ts.map +1 -0
  142. package/dist/publish-copy-stage.js +47 -0
  143. package/dist/publish-file.d.ts.map +1 -1
  144. package/dist/publish-file.js +26 -25
  145. package/dist/replace-file-copy-fallback.d.ts.map +1 -1
  146. package/dist/replace-file-copy-fallback.js +30 -17
  147. package/dist/replace-file-copy-source.d.ts.map +1 -1
  148. package/dist/replace-file-copy-source.js +10 -5
  149. package/dist/replace-file-temp-owner.d.ts.map +1 -1
  150. package/dist/replace-file-temp-owner.js +6 -2
  151. package/dist/replace-file.d.ts.map +1 -1
  152. package/dist/replace-file.js +8 -3
  153. package/dist/root-context.d.ts +3 -0
  154. package/dist/root-context.d.ts.map +1 -1
  155. package/dist/root-context.js +51 -26
  156. package/dist/root-directory-list.d.ts +24 -0
  157. package/dist/root-directory-list.d.ts.map +1 -0
  158. package/dist/root-directory-list.js +201 -0
  159. package/dist/root-errors.d.ts +1 -0
  160. package/dist/root-errors.d.ts.map +1 -1
  161. package/dist/root-errors.js +3 -0
  162. package/dist/root-file.d.ts +2 -0
  163. package/dist/root-file.d.ts.map +1 -1
  164. package/dist/root-file.js +12 -4
  165. package/dist/root-impl.d.ts +6 -50
  166. package/dist/root-impl.d.ts.map +1 -1
  167. package/dist/root-impl.js +342 -208
  168. package/dist/root-options.d.ts +66 -0
  169. package/dist/root-options.d.ts.map +1 -0
  170. package/dist/root-options.js +18 -0
  171. package/dist/root-path-existing.d.ts +5 -0
  172. package/dist/root-path-existing.d.ts.map +1 -1
  173. package/dist/root-path-existing.js +56 -1
  174. package/dist/root-path.d.ts +1 -0
  175. package/dist/root-path.d.ts.map +1 -1
  176. package/dist/root-path.js +74 -162
  177. package/dist/root-symlink-policy.d.ts +13 -0
  178. package/dist/root-symlink-policy.d.ts.map +1 -0
  179. package/dist/root-symlink-policy.js +34 -0
  180. package/dist/root-walk.d.ts +5 -4
  181. package/dist/root-walk.d.ts.map +1 -1
  182. package/dist/root-walk.js +99 -50
  183. package/dist/root-write-mode.d.ts.map +1 -1
  184. package/dist/root-write-mode.js +1 -4
  185. package/dist/root.d.ts +3 -1
  186. package/dist/root.d.ts.map +1 -1
  187. package/dist/secret-file.d.ts +1 -0
  188. package/dist/secret-file.d.ts.map +1 -1
  189. package/dist/secret-file.js +1 -0
  190. package/dist/secret-read-async.js +5 -5
  191. package/dist/secure-file.d.ts +1 -1
  192. package/dist/secure-file.d.ts.map +1 -1
  193. package/dist/secure-file.js +23 -13
  194. package/dist/sibling-staged-file.js +7 -7
  195. package/dist/sibling-temp.d.ts.map +1 -1
  196. package/dist/sibling-temp.js +16 -7
  197. package/dist/sidecar-lock-acquire.d.ts +1 -1
  198. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  199. package/dist/sidecar-lock-acquire.js +6 -4
  200. package/dist/sidecar-lock-policy.d.ts.map +1 -1
  201. package/dist/sidecar-lock-policy.js +5 -3
  202. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  203. package/dist/sidecar-lock-reclaim.js +16 -4
  204. package/dist/sidecar-lock.d.ts.map +1 -1
  205. package/dist/sidecar-lock.js +2 -1
  206. package/dist/temp-cleanup.d.ts.map +1 -1
  207. package/dist/temp-cleanup.js +3 -2
  208. package/dist/temp-target.d.ts.map +1 -1
  209. package/dist/temp-target.js +9 -4
  210. package/dist/timing.d.ts +1 -0
  211. package/dist/timing.d.ts.map +1 -1
  212. package/dist/timing.js +25 -6
  213. package/dist/trash.js +2 -2
  214. package/dist/walk.d.ts.map +1 -1
  215. package/dist/walk.js +7 -27
  216. package/dist/windows-owner.d.ts +9 -12
  217. package/dist/windows-owner.d.ts.map +1 -1
  218. package/dist/windows-owner.js +24 -58
  219. package/dist/write-file-handle.d.ts +6 -0
  220. package/dist/write-file-handle.d.ts.map +1 -0
  221. package/dist/write-file-handle.js +25 -0
  222. package/dist/write-open-flags.d.ts.map +1 -1
  223. package/dist/write-open-flags.js +1 -2
  224. package/docs/advanced.md +18 -4
  225. package/docs/archive.md +57 -9
  226. package/docs/atomic.md +7 -0
  227. package/docs/contributing.md +7 -0
  228. package/docs/copy.md +86 -0
  229. package/docs/durability.md +17 -2
  230. package/docs/errors.md +1 -1
  231. package/docs/file-store.md +48 -3
  232. package/docs/json-store.md +10 -0
  233. package/docs/local-roots.md +8 -1
  234. package/docs/native.md +14 -1
  235. package/docs/permissions.md +20 -10
  236. package/docs/positional-read.md +63 -0
  237. package/docs/reading.md +6 -0
  238. package/docs/root.md +168 -6
  239. package/docs/secret-file.md +6 -0
  240. package/docs/security-model.md +14 -0
  241. package/docs/sidecar-lock.md +2 -0
  242. package/docs/timing.md +2 -0
  243. package/docs/types.md +18 -11
  244. package/docs/walk.md +54 -5
  245. package/docs/writing.md +25 -5
  246. package/package.json +16 -11
package/docs/archive.md CHANGED
@@ -43,6 +43,7 @@ type ExtractArchiveOptions = {
43
43
  archivePath: string; // absolute path to the archive
44
44
  destDir: string; // absolute destination directory; must already exist
45
45
  timeoutMs: number; // positive wall-clock budget; <= 0/non-finite disables it
46
+ durable?: boolean; // false; opt into syncing published files and directories before completion
46
47
  kind?: ArchiveKind; // "zip" | "tar" | "tar-zstd" | "tar-bzip2"
47
48
  stripComponents?: number; // strip N leading dirs from entry paths
48
49
  tarGzip?: boolean; // when archive is .tar.gz/.tgz
@@ -55,6 +56,36 @@ type ExtractArchiveOptions = {
55
56
  };
56
57
  ```
57
58
 
59
+ `durable` defaults to `false`. Private staging never fsyncs, and publication copies
60
+ defer opted-in durability until the complete merge succeeds. With `durable: true`, the final pass syncs each
61
+ published file once (at most eight concurrently), then each published directory
62
+ once, deepest first, and finally the destination directory. All work stays inside
63
+ the extraction deadline; active syncs are joined before rejection. File sync
64
+ failures use the same error surface as `Root.copyIn()`; directory I/O failures
65
+ also reject, with the existing platform limitations on directory flushing.
66
+ Files whose final mode prevents reading, including `0o000` and write-only files,
67
+ sync once through the copy's retained descriptor during publication. Permissions
68
+ are never widened to reopen them. Directory modes are finalized after the file
69
+ pass, with a descriptor pinned before chmod for syncing restrictive directories.
70
+ Existing inaccessible directories are never widened; an existing search-only
71
+ directory must become readable in its final mode if no readable sync descriptor
72
+ can be acquired before chmod.
73
+
74
+ The default suits extractions into temporary or reconstructible locations.
75
+ It skips all file and directory syncs while preserving atomic file publication,
76
+ mode enforcement, identity checks, and containment checks. Successful `durable: true`
77
+ extraction syncs file contents and directory entries before returning; failures
78
+ can leave a partially published tree as described below.
79
+
80
+ For a crash-safe install workflow, extract with the default into a scratch
81
+ directory, then apply the caller's durability policy: sync the staged files and
82
+ directories before publishing with [`replaceDirectoryAtomic`](atomic.md#replacedirectoryatomic),
83
+ and sync the affected parent directories afterward. The directory swap alone
84
+ does not sync the staged tree. Alternatively, pass `durable: true` when extracted
85
+ files must be on stable storage before the extraction call returns, subject to
86
+ the platform's flushing guarantees. A plain fsync on macOS does not flush the
87
+ drive cache.
88
+
58
89
  `entryModes` defaults to `"clamp"`: directories become `0o755`; files become
59
90
  `0o644`, or `0o755` when the archived owner-execute bit is set. `"preserve"`
60
91
  keeps archived read/write/execute bits. Both policies strip setuid, setgid, and
@@ -194,18 +225,22 @@ If `kind` is omitted, the helper calls `resolveArchiveKind(archivePath)` and thr
194
225
  The destination merge is nontransactional: each file is published atomically,
195
226
  but completed files and directories can remain when a later copy, post-copy
196
227
  check, mode application, or deadline fails. This also applies to
197
- `mergeExtractedTreeIntoDestination()`. Guarded `Root.copyIn()` owns cleanup for
198
- its operation; the archive merge does not unlink the current destination name
199
- on error because it has no publication receipt proving ownership. A failure
200
- before publication preserves a pre-existing file, and rejection does not grant
201
- authority to delete a substituted file or alias. Failed extraction does not
228
+ `mergeExtractedTreeIntoDestination()`. Guarded `Root.copyIn()` owns unpublished
229
+ stage cleanup and preserves completed publications. The archive merge never
230
+ acquires rollback authority over the current destination name. Replacing a
231
+ source path after publication rejects the merge with `path-mismatch` while
232
+ preserving the admitted bytes already published, or any later destination edit.
233
+ A failure before publication preserves a pre-existing file, and rejection does
234
+ not grant authority to delete a substituted file or alias. Failed extraction does not
202
235
  restore overwritten contents. Active destination mutations and their guarded
203
236
  cleanup still finish before rejection; no later destination mutation begins.
204
- New directories whose postorder finalization was never reached can retain their
237
+ New directories whose finalization was never reached can retain their
205
238
  private working mode after failure. Failure cleanup closes retained descriptors;
206
- it does not run a final chmod sweep or roll back the archive. The public merge
239
+ it does not run a cleanup chmod sweep or roll back the archive. The public merge
207
240
  helper still derives modes from its external source tree and must be able to
208
241
  read that source; it never chmods an unreadable external source to admit it.
242
+ That helper retains per-copy durability and immediate postorder directory-mode
243
+ finalization; the deferred pass described above belongs to `extractArchive()`.
209
244
 
210
245
  ### Limits
211
246
 
@@ -522,8 +557,8 @@ await extractArchive({
522
557
  ## `readArchiveEntry`
523
558
 
524
559
  `readArchiveEntry(archivePath, entryPath, { maxBytes, kind? })` reads one
525
- regular-file entry into a bounded `Buffer` without extracting a tree. It pins
526
- and privately stages the archive input, rejects link, directory, and duplicate
560
+ regular-file entry into a bounded `Buffer` without extracting a tree. It reads
561
+ the input through an identity-checked descriptor, rejects link, directory, and duplicate
527
562
  entries, verifies ZIP CRC and declared size,
528
563
  and throws `ArchiveLimitError` if the requested entry's output exceeds
529
564
  `maxBytes`. ZIP output within that cap must match the declared uncompressed
@@ -538,6 +573,19 @@ limits. It does not apply payload budgets to unrequested members. ZIP
538
573
  inputs retain the archive subpath's 256 MiB compressed-input ceiling.
539
574
  With a native binding it uses the same Rust decoders as extraction, including
540
575
  zstd and bzip2 TAR. Without native it retains the JS ZIP/TAR/gzip implementation.
576
+ Archive member reads retain their private in-memory input without a disk
577
+ snapshot. The native ZIP reader retains the private allocation and parsed directory across worker-thread
578
+ inspection and reading without an extra archive-byte copy.
579
+ Decompression still allocates its bounded output; Node receives that native
580
+ allocation without another copy where external buffers are supported.
581
+ Native TAR retains the fully admitted member offsets alongside the same input
582
+ allocation. Plain TAR copies only the selected payload range after full archive
583
+ validation. Gzip, zstd, and bzip2 replay bounded decompression and still validate
584
+ all framing, trailers, and physical padding before returning. The JavaScript
585
+ TAR/gzip fallback streams views of the private input into the shared WASM parser
586
+ for admission and replay; WASM transport and selected output still require copies.
587
+ Returned buffers own their bytes, so changing a result cannot modify an archive
588
+ reader or retain an unrelated part of the input through its backing ArrayBuffer.
541
589
 
542
590
  Requested paths and effective member names use extraction's canonical pre-strip
543
591
  identity: backslashes become `/`, and repeated separators and `.` components
package/docs/atomic.md CHANGED
@@ -207,6 +207,13 @@ directory mode. Staged file modes are applied through their still-open handles.
207
207
  If descriptor-bound mode application fails, the staged path is removed and the
208
208
  move fails before publication. A transient staged-path cleanup failure retains
209
209
  an identity-bound process-exit cleanup retry.
210
+ The staging entry's initially admitted identity is checked before publication
211
+ and cleanup; a later substituted entry is preserved. Regular files are admitted
212
+ through their new descriptor. Node provides no creation descriptor for directories
213
+ or symlinks, so their first identity comes from an immediate pathname observation.
214
+ Replacement before that observation remains a best-effort detection gap: use a
215
+ parent protected from concurrent untrusted mutation or OS isolation. A copy write
216
+ that makes no progress rejects instead of looping indefinitely.
210
217
  On POSIX, staged directory modes are applied through no-follow directory
211
218
  descriptors; on Windows, Node cannot portably open those descriptors and no
212
219
  pathname `chmod` fallback is attempted, so directory modes remain subject to
@@ -68,6 +68,13 @@ pnpm check
68
68
  This runs the filesystem boundary checks, build, tests, and package
69
69
  tarball/import validation.
70
70
 
71
+ ### Method benchmarks
72
+
73
+ `pnpm benchmark:methods` measures the callable library surface against synthetic
74
+ fixtures and fails on uncovered exports or returned methods. See the
75
+ [benchmark guide](https://github.com/openclaw/fs-safe/tree/main/benchmarks) for native/fallback runs, per-call
76
+ timings, exclusions, and comparison methodology. Run `pnpm build` first.
77
+
71
78
  ### Real TAR producers
72
79
 
73
80
  After installing the freshly packed root (and optionally its freshly built host
package/docs/copy.md ADDED
@@ -0,0 +1,86 @@
1
+ # Directory copying and cloning
2
+
3
+ `@openclaw/fs-safe/copy` materializes independent, caller-owned directory trees. `copyTree` prefers native copy-on-write operations by default, can require cloning, or can copy regular file bytes without cloning or copy offload.
4
+
5
+ ```ts
6
+ import { copyTree, createCloneSource, probeTreeClone } from "@openclaw/fs-safe/copy";
7
+
8
+ const parent = "/srv/worktrees";
9
+ const backend = probeTreeClone(parent);
10
+ if (backend) {
11
+ const template = `${parent}/template`;
12
+ await createCloneSource(template);
13
+ // Populate this caller-owned template, then keep its contents unchanged.
14
+ await copyTree(template, `${parent}/checkout`, {
15
+ clone: "always",
16
+ signal: AbortSignal.timeout(60_000),
17
+ });
18
+ }
19
+ ```
20
+
21
+ ## Filesystems
22
+
23
+ | Backend | Operation | Source preparation |
24
+ | ------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
25
+ | `apfs` | Native bulk clone, then directory timestamp repair | `createCloneSource` creates an empty directory. |
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
+ | `refs` | Native directory traversal with parallel file block clones | `createCloneSource` creates an empty directory on ReFS, including Dev Drive volumes. |
28
+ | `xfs` | Native directory traversal with parallel file reflinks | `createCloneSource` creates an empty directory. The XFS volume must support reflinks. |
29
+
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
+
32
+ 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
+
34
+ ### APFS permissions
35
+
36
+ APFS directory cloning does not guarantee descendant ACL preservation. With the `CLONE_ACL` flag used here, live macOS testing preserved the source root's ACL but dropped an explicit ACL on a source descendant. Destination ACL inheritance was also omitted below the cloned root. `probeTreeClone` checks filesystem support only; neither it nor `copyTree` checks whether these ACL semantics meet the caller's permission policy. A successful clone is not proof of source ACL preservation or normal file-creation inheritance throughout the tree.
37
+
38
+ Callers that require source ACL preservation or destination ACL inheritance must use a creation path that preserves their permission policy. For example, a private Git template cache can prohibit custom descendant ACLs and decline cloning when the destination parent has inheritable ACL entries, the template root carries ACLs, or ACL inspection fails; it must also account for policy changes during cloning. Checking only the source root cannot establish that an arbitrary tree has no descendant ACLs. This library does not inspect or repair ACLs after a clone.
39
+
40
+ Apple [strongly discourages general directory cloning](https://github.com/apple-oss-distributions/xnu/blob/f6217f891ac0bb64f3d375211650a4c1ff8ca1ea/bsd/man/man2/clonefile.2). The [XNU directory-clone authorizer notes unfinished descendant ACL inheritance](https://github.com/apple-oss-distributions/xnu/blob/f6217f891ac0bb64f3d375211650a4c1ff8ca1ea/bsd/vfs/vfs_subr.c#L8879); this is one verified limitation, not Apple's stated complete rationale. The bulk operation remains useful for controlled, immutable templates whose callers accept its metadata semantics.
41
+
42
+ ## API
43
+
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"`.
45
+
46
+ `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
+
48
+ `createCloneSource(destination, { signal? })` creates an empty cloneable source. Its parent must already exist and the destination must be absent.
49
+
50
+ `copyTree(source, destination, { clone?, signal?, concurrency? })` copies a directory into an absent destination. Existing destinations are never merged or overwritten. The destination must be outside the source tree.
51
+
52
+ | `clone` policy | Behavior |
53
+ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
54
+ | `"auto"` (default) | Prefer native cloning; copy bytes when the binding or filesystem capability is unavailable, or cloning cannot cross the filesystem boundary. |
55
+ | `"always"` | Require native cloning. Unsupported operations fail without a byte-copy fallback. |
56
+ | `"never"` | Copy regular file bytes using reads and writes. No native cloning or copy-offload calls. Works without a native binding. |
57
+
58
+ 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
+
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.
61
+
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.
63
+
64
+ 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
+
66
+ 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
+
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.
69
+
70
+ `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
+
72
+ ## Ownership and cancellation
73
+
74
+ 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.
75
+
76
+ An already aborted signal prevents dispatch. In-flight cancellation stops cancellable traversal and waits for admitted native writes to finish before rejecting. APFS and Btrfs bulk operations cannot be interrupted once dispatched. An aborted or failed call can therefore leave a destination, including a complete bulk clone. It remains caller-owned; after settlement, the caller decides whether to retain or remove it. Do not start cleanup by racing the cloning promise against an abort promise.
77
+
78
+ 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
+
80
+ ## Platform tests and benchmarks
81
+
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.
83
+
84
+ 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
+
86
+ 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.
@@ -199,7 +199,7 @@ import { sha256File } from "@openclaw/fs-safe/durability";
199
199
  const snapshot = await open(stagedArchive, "r");
200
200
  try {
201
201
  const before = await snapshot.stat();
202
- const hash = await sha256File(snapshot);
202
+ const hash = await sha256File(snapshot, { maxBytes: before.size });
203
203
  if (hash.bytes !== before.size || hash.digest !== manifest.sha256) {
204
204
  throw new Error("staged backup does not match its manifest");
205
205
  }
@@ -209,6 +209,21 @@ try {
209
209
  ```
210
210
 
211
211
  The result is `{ bytes, digest }`, where `digest` is lowercase hexadecimal.
212
+ The optional `Sha256FileOptions` argument supports `maxBytes` and `signal`.
213
+ `maxBytes` accepts a non-negative safe integer (including zero), or
214
+ `Infinity` for no limit, which is also the default. Oversized files reject with
215
+ `FsSafeError("too-large")`; reads stay bounded to at most `maxBytes + 1` bytes,
216
+ even if the file grows after its initial size check. Hashing never silently
217
+ truncates to the limit.
218
+
219
+ Pass `signal` to cancel. A pre-aborted signal rejects before file I/O or native
220
+ loading. In-flight cancellation is cooperative between reads and rejects with
221
+ the signal's original reason only after the pending read or native task stops.
222
+ Callers may close their handle after awaiting rejection; do not close it while
223
+ the operation is pending. A signal can be shared by successive or concurrent
224
+ hashes. Neither mode provides a snapshot of concurrently modified contents;
225
+ callers requiring stable content must also fence identity and metadata.
226
+
212
227
  The handle overload never closes the caller's descriptor and uses positioned
213
228
  reads, so it does not alter the descriptor's current offset. The path overload
214
229
  rejects symbolic links and non-regular files, compares lossless bigint identities
@@ -226,7 +241,7 @@ descriptor inspection rather than waiting for a writer.
226
241
  When the optional binding is active, hashing runs as an async native task and
227
242
  does not occupy the JavaScript event loop with digest updates. With native mode
228
243
  `off`, or in `auto` when no binding loads, the fallback performs asynchronous
229
- positioned reads in 64 KiB chunks but updates Node's `Hash` on the JavaScript
244
+ positioned reads in chunks of up to 256 KiB but updates Node's `Hash` on the JavaScript
230
245
  thread. Both paths stream constant-size buffers rather than loading the file
231
246
  into memory. Native mode `require` keeps its usual fail-closed loader semantics.
232
247
 
package/docs/errors.md CHANGED
@@ -132,7 +132,7 @@ type FsSafeErrorCode =
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
134
  | `too-large` | A read or bounded walk exceeded its configured budget. | Caller gave a too-permissive file or traversal limit. |
135
- | `unsupported-platform` | Reserved compatibility code for a platform-specific operation. | No current public helper emits this `FsSafeError` code. Platform-specific APIs currently return a typed unsupported result or use `helper-unavailable`; keep the union member when exhaustively switching across supported package versions. |
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`.
138
138
 
@@ -28,9 +28,18 @@ const cache = fileStore({
28
28
  dirMode: 0o700, // mode for parent directories created on demand (default 0o700)
29
29
  maxBytes: 64 * 1024 * 1024, // optional: refuse writes/reads larger than this
30
30
  private: true, // use secret-file atomic writes for private state
31
+ durable: true, // sync file and parent directory (default true)
31
32
  });
32
33
  ```
33
34
 
35
+ | `FileStoreOptions` option | Default | Purpose |
36
+ |---|---|---|
37
+ | `rootDir` | Required | Store directory. |
38
+ | `private` | `false` | Use the secret-file atomic path. |
39
+ | `mode` / `dirMode` | `0o600` / `0o700` | File and parent-directory modes. |
40
+ | `maxBytes` | Unset | Store read/write byte limit. |
41
+ | `durable` | `true` | Sync the written file and its parent directory; see method support below. |
42
+
34
43
  Store and per-call `maxBytes` values must be non-negative safe integers or positive `Infinity`. Zero is an active zero-byte cap; `Infinity` disables the cap. An omitted or explicitly `undefined` per-call value preserves the store-level limit. The same rule applies to buffer writes, streams, copies, async reads, and synchronous reads/writes.
35
44
 
36
45
  Use `private: true` for credentials, auth profiles, tokens, and other private
@@ -101,7 +110,22 @@ therefore does not imply that no filesystem access or serialization occurred.
101
110
 
102
111
  ## Writes
103
112
 
104
- Every write goes through `writeSiblingTempFile` — temp + rename, mode applied to file and parent dir, both `fsync`'d.
113
+ Writes use guarded sibling-temp publication: apply file and directory modes,
114
+ then rename into place. By default, the file and parent directory are synced
115
+ where supported by the platform and writer.
116
+
117
+ `durable: false` keeps the sibling-temp replace/rename behavior but skips the
118
+ temp-file and parent-directory `fsync` calls. Use it only for reconstructible
119
+ metadata where lower latency matters more than crash-durability. Per-call
120
+ `durable` overrides the store option, which defaults to `true`; an omitted or
121
+ `undefined` override preserves the store default. Modes, path confinement,
122
+ and publication identity checks are unchanged.
123
+
124
+ | Method | Durability support |
125
+ |---|---|
126
+ | `write`, `writeText`, `writeJson` (async and sync) | Per-call option overrides store default. |
127
+ | `writeStream`, `copyIn` (either private mode) | Per-call option overrides store default. |
128
+ | JSON `write`, `update`, `updateOr` | JSON handle option overrides file-store default. |
105
129
 
106
130
  ### `write(rel, data, options?)`
107
131
 
@@ -118,7 +142,7 @@ Convenience wrappers over `write`. `writeJson` pretty-prints with a trailing new
118
142
  ### `json<T>(rel, options?)`
119
143
 
120
144
  Returns a typed single-file JSON state helper for a file under this store. It
121
- inherits the store's root, mode, max-size, and private-write policy, then adds
145
+ inherits the store's root, mode, max-size, durability, and private-write policy, then adds
122
146
  `readOr`, `readRequired`, `update`, `updateOr`, and optional sidecar locking:
123
147
 
124
148
  ```ts
@@ -129,6 +153,9 @@ await state.updateOr(defaultState, (current) => ({ ...current, enabled: true }))
129
153
  Use this when one JSON file owns one piece of state. `jsonStore({ filePath })`
130
154
  is the absolute-path convenience wrapper for the same primitive.
131
155
 
156
+ Pass `{ durable: false }` or `{ durable: true }` to `json()` to override the
157
+ parent store's durability for all mutations of that JSON handle.
158
+
132
159
  ### `writeStream(rel, stream, options?)`
133
160
 
134
161
  ```ts
@@ -138,6 +165,9 @@ const path = await cache.writeStream("downloads/blob.bin", Readable.from(remoteF
138
165
 
139
166
  Streams into a sibling temp with a running byte budget. Aborts the source stream with `too-large` if `maxBytes` is exceeded mid-stream — partial writes are cleaned up.
140
167
 
168
+ Streams honor `durable` in both private modes. Non-private streams stage their
169
+ input and forward the resolved durability option to Root `copyIn` for publication.
170
+
141
171
  ### `copyIn(rel, sourcePath, options?)`
142
172
 
143
173
  ```ts
@@ -146,12 +176,16 @@ const path = await cache.copyIn("ingest/upload.bin", "/tmp/upload.bin");
146
176
 
147
177
  One-shot ingest from an absolute source path. Source is checked for symlink/non-regular before copy. Same mode rules as `write`.
148
178
 
179
+ `copyIn` honors per-call and store-level `durable` values in both private modes,
180
+ with the same precedence as `write`.
181
+
149
182
  ### `FileStoreWriteOptions`
150
183
 
151
184
  Per-call overrides for the store-level defaults:
152
185
 
153
186
  ```ts
154
187
  type FileStoreWriteOptions = {
188
+ durable?: boolean; // store default, otherwise true
155
189
  dirMode?: number;
156
190
  mode?: number;
157
191
  maxBytes?: number;
@@ -159,6 +193,17 @@ type FileStoreWriteOptions = {
159
193
  };
160
194
  ```
161
195
 
196
+ | `FileStoreWriteOptions` option | Default |
197
+ |---|---|
198
+ | `durable` | Store option, otherwise `true`. |
199
+ | `dirMode` / `mode` | Store directory/file modes. |
200
+ | `maxBytes` | Store byte limit. |
201
+ | `tempPrefix` | Writer-specific temporary prefix. |
202
+
203
+ The same durability precedence applies to `fileStoreSync().write`,
204
+ `writeText`, and `writeJson`. The synchronous store has no `writeStream` or
205
+ `copyIn` methods.
206
+
162
207
  ## Reads
163
208
 
164
209
  `open`, `read`, `readBytes`, `readText`, and `readJson` delegate to a fresh `Root` with `hardlinks: "reject"` and the store's `maxBytes`. Same return shapes as `Root`.
@@ -254,5 +299,5 @@ await root.move(`pending/${id}`, `done/${id}`);
254
299
 
255
300
  - [`root()`](root.md) — the boundary `FileStore` is built on; reach for it when you need move/list/append.
256
301
  - [JSON store](json-store.md) — the JSON-state-file equivalent of this surface.
257
- - [Atomic writes](atomic.md) — `writeSiblingTempFile` is what every write goes through.
302
+ - [Atomic writes](atomic.md) — lower-level sibling-temp publication helpers.
258
303
  - [Temp workspaces](temp.md) — private scratch directories backed by `FileStore`.
@@ -45,6 +45,7 @@ type JsonStoreOptions<T> = {
45
45
  filePath: string;
46
46
  dirMode?: number; // default 0o700
47
47
  mode?: number; // default 0o600
48
+ durable?: boolean; // default true
48
49
  trailingNewline?: boolean; // default true
49
50
  lock?: boolean | JsonStoreLockOptions; // false / undefined = no lock
50
51
  };
@@ -71,6 +72,15 @@ type JsonStore<T> = {
71
72
  `jsonStore({ filePath })` resolves `rootDir = dirname(filePath)` and calls
72
73
  `fileStore({ rootDir, private: true }).json(basename(filePath), options)`.
73
74
 
75
+ `durable: false` keeps sibling-temp replace/rename behavior but skips the
76
+ temp-file and parent-directory `fsync` calls. Use it only for reconstructible
77
+ metadata where lower latency matters more than crash-durability. The default
78
+ is `true`, subject to platform sync support. This store-level policy applies
79
+ to `write`, `update`, and `updateOr`; these methods have no per-call options.
80
+ For `fileStore(...).json(rel, options)`, `options.durable` overrides the parent
81
+ file store's durability, while omission or `undefined` inherits it. Modes,
82
+ identity checks, mutation serialization, and sidecar locking are unchanged.
83
+
74
84
  The store does **not** validate the parsed value against `T` at runtime — the cast is unchecked. Wrap with a schema (zod/valibot) if the file might be hand-edited or written by another process you don't control.
75
85
 
76
86
  ## `read()`
@@ -71,6 +71,13 @@ component that does not exist: dangling symlinks, descendants of dangling
71
71
  symlinks, and candidates whose existing ancestors cannot be canonicalized are
72
72
  rejected rather than treated as safe missing paths.
73
73
 
74
+ Filesystem path inputs retain symlinks and parent components until boundary
75
+ resolution, including after home expansion. A followed `link/../file` resolves
76
+ the parent of the link's target; default reads reject the link instead of
77
+ normalizing it away. File URLs retain the URL parser's normal path semantics.
78
+ An existing non-directory component cannot be traversed further, including by
79
+ `..`; the helpers reject that input instead of selecting a different file.
80
+
74
81
  ## `readLocalFileFromRoots(options)`
75
82
 
76
83
  The asynchronous helper opens the candidate through the matched [`Root`](root.md),
@@ -82,7 +89,7 @@ type ReadLocalFileFromRootsOptions = LocalRootsInputOptions & {
82
89
  hardlinks?: "reject" | "allow";
83
90
  maxBytes?: number;
84
91
  nonBlockingRead?: boolean;
85
- symlinks?: "reject" | "follow-within-root";
92
+ symlinks?: "reject" | "follow-within-root" | "follow-parents-within-root";
86
93
  };
87
94
 
88
95
  type LocalRootsReadResult = ReadResult & {
package/docs/native.md CHANGED
@@ -59,6 +59,19 @@ whether a path, archive entry, mode, owner, or cleanup policy is acceptable.
59
59
 
60
60
  ## Archives
61
61
 
62
+ Native ZIP and TAR entry reads retain a private, unpooled input Buffer in
63
+ the async reader. ZIP reuses its parsed directory; TAR retains admitted member
64
+ offsets. TypeScript validates and selects the requested member before reading. The internal binding borrows that allocation: it must never be
65
+ mutated or detached while the reader or a read task exists. The public API only
66
+ accepts a pathname and owns this buffer exclusively. An N-API reference keeps
67
+ the bytes alive; a mutex serializes access to the retained ZIP cursor. Plain
68
+ TAR copies only the selected range after complete admission, while gzip, zstd,
69
+ and bzip2 replay bounded decompression and validate the full physical stream.
70
+ Concurrent TAR reads share immutable input and own separate decoder state.
71
+ Each output owns a new vector, which N-API transfers to Node without a second
72
+ payload copy on runtimes supporting external buffers. No entry-read path
73
+ requires temporary-file staging. Extraction retains its private staged input.
74
+
62
75
  Rust streams ZIP and TAR payloads, including gzip, zstd, and bzip2. It first
63
76
  returns a bounded manifest. TypeScript applies the shared path, filter, strip,
64
77
  mode, and byte policies and returns an index-bound extraction plan. Rust then
@@ -165,7 +178,7 @@ remain TypeScript-owned. What changes is the syscall strength or availability:
165
178
  | Zstd/bzip2 TAR | Supported. | Unsupported; typed `helper-unavailable`. |
166
179
  | Publication copy | Clone, Linux `copy_file_range`, async native SHA-256. | Exclusive `wx` byte loop and Node SHA-256 with the same content/identity fences. |
167
180
  | `rename-noreplace` | Atomic platform no-replace rename. | Unsupported; no emulation by check-then-rename. |
168
- | Windows DACL read | Direct `GetSecurityInfo`; the public facts API exposes ordered basic allow/deny ACE SIDs, masks, and decoded flags without trust policy. | Established .NET/`icacls` inspection fallback for coarse permission checks; raw ACE facts are native-only. |
181
+ | Windows DACL read | Direct `GetSecurityInfo`; the public facts API exposes ordered basic allow/deny ACE SIDs, masks, and decoded flags without trust policy. | Structured .NET owner/DACL inspection for coarse permission checks; the public raw ACE facts API remains native-only. |
169
182
  | Windows private directory | Creation-time protected DACL. | Unsupported; no weaker pathname-only substitute. |
170
183
 
171
184
  Use `off` in CI to keep the fallback contract exercised. Use `require` when a
@@ -68,11 +68,18 @@ createIcaclsResetCommand(targetPath, { isDir, env });
68
68
  resolveWindowsUserPrincipal(env);
69
69
  ```
70
70
 
71
- The fallback Windows inspector calls `icacls.exe <path>` using its supported
72
- path-only inspection syntax and classifies principals as trusted, world, or
73
- group. Trusted defaults include the current user, SYSTEM, and Administrators.
74
- Built-in PowerShell, `icacls.exe`, and `whoami.exe` invocations have a fixed
75
- 30-second per-process deadline. A command failure or timeout returns an
71
+ The fallback Windows inspector reads the owner and DACL together through one
72
+ built-in Windows PowerShell/.NET query. It returns canonical SIDs and numeric
73
+ access masks, so Unicode paths and account names do not pass through lossy
74
+ console display text. `inspectWindowsAcl()` uses the same query and returns
75
+ canonical SIDs in its `principal` fields, with normalized rights tokens.
76
+ The advanced options retain `currentUserSid` as an explicit classification
77
+ override and `principalTranslationFailed: true` as an immediate unverified
78
+ result. The optional `principalSids` translation cache is still accepted but
79
+ is no longer needed because the query returns SIDs directly.
80
+ The existing classifier assigns principals to trusted, world, or group;
81
+ trusted defaults include the current user, SYSTEM, and Administrators.
82
+ The built-in query has a fixed 30-second process deadline. A command failure or timeout returns an
76
83
  unverified result (`source: "unknown"`) so callers fail closed. Advanced callers
77
84
  that inject a custom `exec` implementation own that executor's deadline.
78
85
  Failed owner and ACL inspections retain `error` text and an optional
@@ -86,15 +93,18 @@ characters, including a trailing `…` when truncated. Diagnostics do not copy
86
93
  stdout or read target file contents. The separate `errorCause` retains the
87
94
  original exception for restricted local diagnosis; do not serialize or expose
88
95
  it as display text.
89
- The parser is on the advanced surface so tests and CLIs can process captured
90
- `icacls` output without spawning a process.
96
+ The parser and remediation command builders remain on the advanced surface for
97
+ CLIs processing captured `icacls` output or presenting an explicit repair.
98
+ Runtime inspection does not parse that display text. A null DACL reports
99
+ unrestricted access; an empty DACL grants nothing. Inherit-only ACEs do not
100
+ apply to the inspected object, and deny ACEs never subtract coarse grants or
101
+ claim effective-access evaluation. Unsupported ACE layouts remain unverified.
91
102
 
92
103
  When the native binding is available, `inspectPathPermissions()`
93
104
  reads the owner and DACL directly with Windows security APIs. It classifies the
94
105
  current user, LocalSystem, and built-in Administrators as trusted and reports
95
106
  the world/group read/write facts consumed by secure reads. Descriptor forms it
96
- cannot classify equivalently fall back to the established owner/.NET and
97
- `icacls` path; `mode: "off"` exercises that fallback deterministically.
107
+ cannot classify equivalently fall back to the structured .NET query; `mode: "off"` exercises that fallback deterministically.
98
108
 
99
109
  ## Policy-free owner and DACL facts
100
110
 
@@ -164,7 +174,7 @@ This API is Windows-only and native-only; it fails closed with
164
174
  or when the binding is unavailable. POSIX callers should create private
165
175
  directories through their existing trusted-root creation policy rather than a
166
176
  pathname-only compatibility shim. Existing Windows permission inspection still
167
- retains its .NET/`icacls` compatibility fallback.
177
+ retains its structured .NET compatibility fallback.
168
178
 
169
179
  Use `createIcaclsResetCommand()` when you need a structured command and argv pair. Use `formatIcaclsResetCommand()` when you only need a remediation string for a user-facing message.
170
180
 
@@ -0,0 +1,63 @@
1
+ # Positional reads
2
+
3
+ Use `readFileWindowFully()` and `readFileWindowFullySync()` to read a bounded
4
+ window from an already-open file. They fill a caller-owned `Buffer`, completing
5
+ short reads until the buffer is full or the file reaches EOF, and return the
6
+ number of bytes read. They never allocate a payload buffer, close the descriptor,
7
+ or change its current offset.
8
+
9
+ ```ts
10
+ import { root } from "@openclaw/fs-safe";
11
+ import { readFileWindowFully } from "@openclaw/fs-safe/advanced";
12
+
13
+ const workspace = await root("/srv/workspace");
14
+ await using opened = await workspace.open("large.log");
15
+ const buffer = Buffer.allocUnsafe(4096);
16
+ const bytesRead = await readFileWindowFully(opened.handle, buffer, 8192);
17
+ const window = buffer.subarray(0, bytesRead);
18
+ ```
19
+
20
+ ## Signatures
21
+
22
+ ```ts
23
+ type ReadFileWindowOptions = { signal?: AbortSignal };
24
+
25
+ function readFileWindowFully(
26
+ handle: import("node:fs/promises").FileHandle,
27
+ buffer: Buffer,
28
+ position: number,
29
+ options?: ReadFileWindowOptions,
30
+ ): Promise<number>;
31
+
32
+ function readFileWindowFullySync(
33
+ fd: number,
34
+ buffer: Buffer,
35
+ position: number,
36
+ ): number;
37
+ ```
38
+
39
+ `position` and the exclusive window end (`position + buffer.length`) must be
40
+ non-negative safe integers. Invalid ranges throw `RangeError` before reading.
41
+ A zero-length buffer returns zero without I/O. Reading at or beyond EOF also
42
+ returns zero. If EOF occurs within the window, only the returned prefix is
43
+ written; the remaining buffer bytes stay unchanged. Always slice by the returned
44
+ count before using an unsafe-allocated buffer.
45
+
46
+ These helpers use the caller's open descriptor directly. They do not establish
47
+ path containment, file identity, file-type admission, or a snapshot of concurrently
48
+ modified contents. Use [`Root.open()`](root.md#reads) to admit untrusted paths,
49
+ and keep the handle open and the buffer available until the operation settles.
50
+ Underlying I/O errors propagate unchanged.
51
+
52
+ ## Cancellation
53
+
54
+ The async variant accepts `signal`. A pre-aborted signal rejects before I/O.
55
+ In-flight cancellation is checked after the pending read settles and before
56
+ another read starts, preserving the signal's reason. Bytes already read remain
57
+ in the buffer; cancellation does not roll them back. Once the promise settles,
58
+ the caller can reuse the buffer or close its handle without a hidden read still
59
+ running.
60
+
61
+ For a whole-file read that rejects files exceeding a byte limit, use the
62
+ [bounded descriptor readers](advanced.md#files-and-identity) instead. Positional
63
+ reads stop successfully at the requested window and do not probe for extra data.
package/docs/reading.md CHANGED
@@ -14,6 +14,12 @@ const opened = await fs.open("large.log"); // FileHandle for strea
14
14
 
15
15
  Identity and containment checks use brief synchronous metadata calls, like Node's module resolution, while file data is read asynchronously. Canonical paths retain Node's native realpath spelling, including expansion of Windows short paths.
16
16
 
17
+ The same observation rule applies to archive extraction, copy, publication, move,
18
+ directory modes, and supporting lock, queue, and secret-file operations. Opens,
19
+ data transfers, durability syncs, filesystem mutations, and closes retain their
20
+ existing asynchronous behavior. Custom filesystem adapters retain their async
21
+ metadata interface.
22
+
17
23
  Regardless of shape, every read goes through the same boundary checks:
18
24
 
19
25
  1. Resolve the input lexically against the canonical real root.