@openclaw/fs-safe 0.16.0 → 0.17.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 (180) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +8 -1
  3. package/dist/absolute-path.d.ts.map +1 -1
  4. package/dist/absolute-path.js +2 -8
  5. package/dist/advanced.d.ts +2 -0
  6. package/dist/advanced.d.ts.map +1 -1
  7. package/dist/advanced.js +2 -0
  8. package/dist/archive-deadline.d.ts.map +1 -1
  9. package/dist/archive-deadline.js +22 -23
  10. package/dist/archive-merge.d.ts +1 -0
  11. package/dist/archive-merge.d.ts.map +1 -1
  12. package/dist/archive-merge.js +4 -4
  13. package/dist/archive-native.d.ts +1 -0
  14. package/dist/archive-native.d.ts.map +1 -1
  15. package/dist/archive-native.js +1 -0
  16. package/dist/archive-options.d.ts +2 -0
  17. package/dist/archive-options.d.ts.map +1 -1
  18. package/dist/archive-parser.wasm +0 -0
  19. package/dist/archive-zip-count.d.ts.map +1 -1
  20. package/dist/archive-zip-count.js +21 -1
  21. package/dist/archive-zip-directory.d.ts.map +1 -1
  22. package/dist/archive-zip-directory.js +23 -1
  23. package/dist/archive-zip-loader.d.ts +2 -0
  24. package/dist/archive-zip-loader.d.ts.map +1 -1
  25. package/dist/archive-zip-loader.js +7 -0
  26. package/dist/archive-zip-names.d.ts.map +1 -1
  27. package/dist/archive-zip-names.js +7 -2
  28. package/dist/archive.d.ts.map +1 -1
  29. package/dist/archive.js +9 -3
  30. package/dist/byte-view.d.ts +3 -0
  31. package/dist/byte-view.d.ts.map +1 -0
  32. package/dist/byte-view.js +13 -0
  33. package/dist/creation-darwin.d.ts +0 -1
  34. package/dist/creation-darwin.d.ts.map +1 -1
  35. package/dist/creation-darwin.js +0 -9
  36. package/dist/directory-durability.d.ts +6 -6
  37. package/dist/directory-durability.d.ts.map +1 -1
  38. package/dist/directory-receipt.d.ts +2 -2
  39. package/dist/directory-receipt.d.ts.map +1 -1
  40. package/dist/directory-receipt.js +15 -19
  41. package/dist/file-cleanup.d.ts +1 -0
  42. package/dist/file-cleanup.d.ts.map +1 -1
  43. package/dist/file-cleanup.js +7 -4
  44. package/dist/file-contents.d.ts +6 -0
  45. package/dist/file-contents.d.ts.map +1 -0
  46. package/dist/file-contents.js +40 -0
  47. package/dist/file-hash.d.ts.map +1 -1
  48. package/dist/file-hash.js +16 -4
  49. package/dist/file-lock-sync-root-acquire.d.ts.map +1 -1
  50. package/dist/file-lock-sync-root-acquire.js +3 -0
  51. package/dist/file-lock-sync-root-held.d.ts +1 -2
  52. package/dist/file-lock-sync-root-held.d.ts.map +1 -1
  53. package/dist/file-lock-sync-root-held.js +7 -5
  54. package/dist/file-lock-sync-stale-admission.d.ts.map +1 -1
  55. package/dist/file-lock-sync-stale-admission.js +3 -0
  56. package/dist/file-lock-sync.d.ts.map +1 -1
  57. package/dist/file-lock-sync.js +8 -11
  58. package/dist/file-store.js +3 -3
  59. package/dist/guarded-mkdir.d.ts.map +1 -1
  60. package/dist/guarded-mkdir.js +6 -27
  61. package/dist/install-path.d.ts.map +1 -1
  62. package/dist/install-path.js +2 -5
  63. package/dist/json-durable-queue-ownership.d.ts.map +1 -1
  64. package/dist/json-durable-queue-ownership.js +2 -6
  65. package/dist/json-durable-queue-paths.d.ts.map +1 -1
  66. package/dist/json-durable-queue-paths.js +2 -24
  67. package/dist/json-durable-queue.d.ts.map +1 -1
  68. package/dist/json-durable-queue.js +10 -9
  69. package/dist/json.d.ts.map +1 -1
  70. package/dist/json.js +32 -75
  71. package/dist/local-roots.d.ts.map +1 -1
  72. package/dist/local-roots.js +19 -21
  73. package/dist/move-path-cleanup.d.ts +5 -19
  74. package/dist/move-path-cleanup.d.ts.map +1 -1
  75. package/dist/move-path-cleanup.js +57 -21
  76. package/dist/move-path.d.ts.map +1 -1
  77. package/dist/move-path.js +62 -39
  78. package/dist/native-staged-file.d.ts +2 -1
  79. package/dist/native-staged-file.d.ts.map +1 -1
  80. package/dist/native-staged-file.js +8 -5
  81. package/dist/native.js +2 -2
  82. package/dist/opened-realpath.d.ts.map +1 -1
  83. package/dist/opened-realpath.js +11 -2
  84. package/dist/path.d.ts.map +1 -1
  85. package/dist/path.js +2 -1
  86. package/dist/permissions.d.ts.map +1 -1
  87. package/dist/permissions.js +3 -17
  88. package/dist/pinned-write-input.d.ts.map +1 -1
  89. package/dist/pinned-write-input.js +11 -1
  90. package/dist/pinned-write-mode.d.ts +3 -3
  91. package/dist/pinned-write-mode.d.ts.map +1 -1
  92. package/dist/pinned-write-mode.js +15 -8
  93. package/dist/pinned-write-staged.d.ts.map +1 -1
  94. package/dist/pinned-write-staged.js +10 -11
  95. package/dist/pinned-write.d.ts.map +1 -1
  96. package/dist/pinned-write.js +17 -13
  97. package/dist/publish-file.d.ts +2 -2
  98. package/dist/publish-file.d.ts.map +1 -1
  99. package/dist/publish-file.js +56 -96
  100. package/dist/regular-file.d.ts.map +1 -1
  101. package/dist/regular-file.js +35 -44
  102. package/dist/replace-file-copy-fallback.d.ts.map +1 -1
  103. package/dist/replace-file-copy-fallback.js +28 -26
  104. package/dist/replace-file-copy-source.d.ts.map +1 -1
  105. package/dist/replace-file-copy-source.js +13 -22
  106. package/dist/replace-file-descriptor.d.ts.map +1 -1
  107. package/dist/replace-file-descriptor.js +10 -16
  108. package/dist/replace-file-temp-owner.js +6 -6
  109. package/dist/replace-file.d.ts.map +1 -1
  110. package/dist/replace-file.js +9 -13
  111. package/dist/root-directory-list.d.ts.map +1 -1
  112. package/dist/root-directory-list.js +20 -3
  113. package/dist/root-file-final-admission.d.ts +1 -1
  114. package/dist/root-file-final-admission.d.ts.map +1 -1
  115. package/dist/root-file-final-admission.js +5 -2
  116. package/dist/root-file.d.ts.map +1 -1
  117. package/dist/root-file.js +3 -2
  118. package/dist/root-impl.d.ts.map +1 -1
  119. package/dist/root-impl.js +50 -20
  120. package/dist/root-move-noreplace.d.ts +2 -0
  121. package/dist/root-move-noreplace.d.ts.map +1 -1
  122. package/dist/root-move-noreplace.js +2 -2
  123. package/dist/root-read-admission.d.ts.map +1 -1
  124. package/dist/root-read-admission.js +7 -2
  125. package/dist/root-remove.d.ts.map +1 -1
  126. package/dist/root-remove.js +15 -1
  127. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  128. package/dist/sidecar-lock-acquire.js +4 -6
  129. package/dist/sidecar-lock-handle.d.ts +3 -0
  130. package/dist/sidecar-lock-handle.d.ts.map +1 -1
  131. package/dist/sidecar-lock-handle.js +6 -0
  132. package/dist/sidecar-lock-reclaim.d.ts +1 -1
  133. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  134. package/dist/sidecar-lock-reclaim.js +11 -8
  135. package/dist/sidecar-lock.d.ts.map +1 -1
  136. package/dist/sidecar-lock.js +3 -5
  137. package/dist/staged-directory.d.ts +2 -2
  138. package/dist/staged-directory.d.ts.map +1 -1
  139. package/dist/strict-file-identity.d.ts +1 -1
  140. package/dist/strict-file-identity.d.ts.map +1 -1
  141. package/dist/strict-file-identity.js +9 -9
  142. package/dist/symlink-parents.d.ts.map +1 -1
  143. package/dist/symlink-parents.js +2 -27
  144. package/dist/temp-workspace-owner.js +4 -4
  145. package/dist/unicode-path.d.ts.map +1 -1
  146. package/dist/unicode-path.js +3 -0
  147. package/dist/walk.d.ts.map +1 -1
  148. package/dist/walk.js +4 -2
  149. package/dist/write-file-handle.d.ts +7 -0
  150. package/dist/write-file-handle.d.ts.map +1 -1
  151. package/dist/write-file-handle.js +23 -0
  152. package/dist/write-open-flags.d.ts.map +1 -1
  153. package/dist/write-open-flags.js +1 -8
  154. package/dist/write-queue.d.ts.map +1 -1
  155. package/dist/write-queue.js +1 -4
  156. package/docs/advanced.md +68 -1
  157. package/docs/archive.md +41 -2
  158. package/docs/atomic.md +29 -5
  159. package/docs/contributing.md +4 -0
  160. package/docs/creation.md +8 -4
  161. package/docs/durability.md +35 -0
  162. package/docs/file-contents.md +68 -0
  163. package/docs/json.md +5 -4
  164. package/docs/local-roots.md +2 -0
  165. package/docs/mutation-policy-proof.md +5 -3
  166. package/docs/native.md +9 -8
  167. package/docs/path.md +4 -4
  168. package/docs/public-api.md +5 -0
  169. package/docs/quickstart.md +1 -1
  170. package/docs/reading.md +2 -2
  171. package/docs/regular-file.md +3 -0
  172. package/docs/root.md +9 -0
  173. package/docs/sidecar-lock.md +9 -1
  174. package/docs/staged-file.md +4 -3
  175. package/docs/store.md +3 -1
  176. package/docs/temp.md +4 -1
  177. package/docs/types.md +18 -2
  178. package/docs/walk.md +7 -0
  179. package/docs/writing.md +9 -2
  180. package/package.json +8 -8
package/docs/advanced.md CHANGED
@@ -19,7 +19,7 @@ import {
19
19
 
20
20
  ## What lives here
21
21
 
22
- The exports group into a handful of themes. Each documented helper has its own page; everything else is reference-only and tracked here.
22
+ The exports group into a handful of themes. Documented helpers link to their contract below or a dedicated page; everything else is reference-only and tracked here.
23
23
 
24
24
  ### Path scopes and root paths
25
25
 
@@ -71,7 +71,9 @@ Operational filesystem failures such as permissions or I/O errors are rethrown.
71
71
  | `readFileDescriptorBounded`, `readFileDescriptorBoundedSync`, `readFileHandleBounded` | – | Incremental whole-file reads for already-open descriptors/handles. They consume at most `maxBytes + 1`, do not close the input, and throw `FsSafeError("too-large")` on overflow. |
72
72
  | `createDirectory`, `createDirectorySync`, `createFileSync` | [Exclusive leaf creation](creation.md) | Create one exclusive entry under an existing trusted parent, optionally with private permissions; file creation returns an owned disposable descriptor. |
73
73
  | `readFileWindowFully`, `readFileWindowFullySync`, `ReadFileWindowOptions` | [positional-read.md](positional-read.md) | Fill a caller-owned buffer at an explicit file position, completing short reads and returning the EOF count without moving or closing the descriptor. |
74
+ | `writeFileWindowFully`, `WriteFileWindowOptions` | [Borrowed-handle writes](#borrowed-handle-writes) | Write all supplied bytes at an explicit position or the current cursor, completing short writes with cancellation and per-write authority checks. |
74
75
  | `copyFileHandle`, `copyFileDescriptorSync`, `CopyFileHandleOptions` | [copy.md](copy.md#borrowed-filehandle-transfers) | Copy caller-owned regular files through async handles or sync descriptors from position zero with byte limits and synchronous callbacks; preserves cursors and leaves publication and cleanup to the caller. |
76
+ | `sameFileContentsSync`, `SameFileContentsOptions` | [Exact file comparison](file-contents.md) | Compare borrowed regular-file descriptors byte for byte through EOF with bounded memory and an optional per-file byte limit, preserving both cursors and lifetimes. |
75
77
  | `overwriteFileHandle`, `OverwriteFileHandleOptions` | [in-place-write.md](in-place-write.md) | Overwrite a borrowed read/write handle with prefix-only preparation and best-effort rollback; preserves its inode, cursor, and caller-owned lifetime. |
76
78
  | `openRootFile`, `openRootFileSync`, `canUseRootFileOpen`, `matchRootFileOpenFailure`, related types | – | Low-level root-bounded open; rejects every symlink component by default, with `symlinks: "follow-parents-within-root"` for contained parent aliases or `"follow-within-root"` for final links too. |
77
79
  | `appendRegularFile`, `appendRegularFileSync`, `readRegularFile`, `readRegularFileSync`, `statRegularFile`, `statRegularFileSync`, `resolveRegularFileAppendFlags`, `AppendRegularFileOptions`, `RegularFileStatResult` | [regular-file.md](regular-file.md) | Type-checked regular-file I/O. |
@@ -151,6 +153,71 @@ component is followed by another segment, both helpers throw
151
153
  `FsSafeError("not-file")` before the platform can expose that state as POSIX
152
154
  `ENOTDIR` or Windows `ENOENT`.
153
155
 
156
+ #### Borrowed-handle writes
157
+
158
+ Use `writeFileWindowFully()` when you already own a writable file handle and
159
+ need to complete a byte-window write, including positive short writes.
160
+
161
+ ```ts
162
+ import { root } from "@openclaw/fs-safe";
163
+ import { writeFileWindowFully } from "@openclaw/fs-safe/advanced";
164
+
165
+ const workspace = await root("/srv/workspace");
166
+ await using opened = await workspace.openWritable("record.bin", { writeMode: "update" });
167
+ await writeFileWindowFully(opened.handle, Buffer.from([1, 2, 3]), 16);
168
+ ```
169
+
170
+ ```ts
171
+ type WriteFileWindowOptions = {
172
+ signal?: AbortSignal;
173
+ assertBeforeMutation?: () => void;
174
+ };
175
+
176
+ function writeFileWindowFully(
177
+ handle: import("node:fs/promises").FileHandle,
178
+ bytes: Uint8Array,
179
+ position: number | null,
180
+ options?: WriteFileWindowOptions,
181
+ ): Promise<void>;
182
+ ```
183
+
184
+ A numeric `position` writes at that offset without moving the handle's cursor.
185
+ It and the exclusive window end (`position + bytes.byteLength`) must be
186
+ non-negative safe integers; invalid ranges throw `RangeError` before mutation.
187
+ Bounds come from the intrinsic byte view, ignoring shadowed metadata properties.
188
+ Pass `null` to write at and advance the current cursor. Each syscall writes at
189
+ most 512 KiB. A write that makes no progress throws
190
+ `FsSafeError("helper-failed")`; filesystem errors propagate unchanged.
191
+ Empty input still validates the range and checks cancellation, but performs no
192
+ I/O and does not call `assertBeforeMutation`.
193
+
194
+ The caller must supply a writable regular-file handle, opened **without append
195
+ mode** for numeric positions. Some operating systems ignore positioned-write
196
+ offsets on append handles, and this helper does not inspect file type or open
197
+ flags. Opening, path admission, identity checks, and closing remain the caller's
198
+ responsibility. Keep the handle open and the borrowed bytes unchanged, attached,
199
+ and accessible until the promise settles; avoid concurrent I/O when it can change
200
+ the intended contents or shared cursor. The helper does not acquire a lock.
201
+
202
+ `assertBeforeMutation` runs synchronously immediately before every write,
203
+ including short-write retries. A thrown value propagates unchanged; a Promise or
204
+ thenable return rejects with `TypeError` before that write. The callback must not
205
+ modify the payload or handle. It does not run as a final completion check; the
206
+ caller owns any authority check before later publication or other mutations.
207
+
208
+ `signal` is checked at admission, before and after each authority callback, and
209
+ after each pending write settles. Cancellation waits for an in-flight write and
210
+ then rejects with the signal's reason without starting another syscall. If that
211
+ write fails, its filesystem error or zero-progress failure takes precedence over cancellation. Already
212
+ written bytes remain changed; there is no rollback or hidden write after the
213
+ promise settles.
214
+
215
+ The helper neither truncates an existing suffix nor changes permissions,
216
+ synchronizes, or closes the handle. Callers retain those responsibilities and
217
+ any wider transaction policy. For complete replacement with best-effort
218
+ rollback, use [`overwriteFileHandle()`](in-place-write.md); for root-bounded
219
+ atomic replacement, use [`Root.write()`](writing.md).
220
+
154
221
  ### Local roots and file URLs
155
222
 
156
223
  | Export | Page | Notes |
package/docs/archive.md CHANGED
@@ -11,6 +11,14 @@ These TAR routes work with all optional dependencies omitted and need no
11
11
  runtime interpreter, download, install script, or consumer compiler toolchain.
12
12
  ZIP fallback still requires optional `jszip`.
13
13
 
14
+ The shared TAR parser reuses the already-validated owned path for ordinary
15
+ members. Original header names and USTAR prefixes still undergo validation
16
+ even when PAX or GNU metadata supplies an override; effective override paths
17
+ retain their separate checks. Empty USTAR prefixes retain field decoding and
18
+ padding checks; path validation applies to nonempty prefixes. Joining an
19
+ admitted prefix and name with a separator preserves their checked components,
20
+ so the parser does not repeat the same component validation on the joined path.
21
+
14
22
  `auto` prefers an available native binding; a native operation failure is
15
23
  terminal and never retries through WASM. `require` rejects a missing binding
16
24
  with `FsSafeError("helper-unavailable")`, including for zstd/bzip2 suffix
@@ -31,6 +39,7 @@ await extractArchive({
31
39
  timeoutMs: 15_000, // hard budget; active destination mutation is joined
32
40
  stripComponents: 0, // tar-style strip-leading-dirs
33
41
  entryModes: "clamp", // default; use "preserve" for archive rwx bits
42
+ entryUmask: 0, // default; remove these bits from final modes
34
43
  entryFilter: ({ path, kind, size }) => "extract",
35
44
  onFiltered: "reject-archive", // default; opt into "skip-entry" explicitly
36
45
  limits: {
@@ -50,7 +59,7 @@ await extractArchive({
50
59
  type ExtractArchiveOptions = {
51
60
  archivePath: string; // absolute path to the archive
52
61
  destDir: string; // absolute destination directory; must already exist
53
- timeoutMs: number; // positive wall-clock budget; <= 0/non-finite disables it
62
+ timeoutMs: number; // positive elapsed-time budget; <= 0/non-finite disables it
54
63
  durable?: boolean; // false; opt into syncing published files and directories before completion
55
64
  kind?: ArchiveKind; // "zip" | "tar" | "tar-zstd" | "tar-bzip2"
56
65
  stripComponents?: number; // strip N leading dirs from entry paths
@@ -58,6 +67,7 @@ type ExtractArchiveOptions = {
58
67
  limits?: ArchiveExtractLimits;
59
68
  logger?: ArchiveLogger; // { info?, warn? }
60
69
  entryModes?: "clamp" | "preserve";
70
+ entryUmask?: number; // integer 0..0o777; defaults to 0
61
71
  entryFilter?: (entry: { path: string; kind: ArchiveEntryKind; size: number }) =>
62
72
  "extract" | "skip";
63
73
  onFiltered?: "reject-archive" | "skip-entry";
@@ -71,6 +81,12 @@ once, deepest first, and finally the destination directory. All work stays insid
71
81
  the extraction deadline; active syncs are joined before rejection. File sync
72
82
  failures use the same error surface as `Root.copyIn()`; directory I/O failures
73
83
  also reject, with the existing platform limitations on directory flushing.
84
+
85
+ Deadline checks use a monotonic clock, including before queued mutations start
86
+ and before reporting success. Synchronous caller code can delay the timer, but
87
+ cannot permit the next operation after the budget expires. This does not
88
+ interrupt a callback halfway through execution or replace its own thrown error;
89
+ active destination mutations are still joined before timeout rejection.
74
90
  Files whose final mode prevents reading, including `0o000` and write-only files,
75
91
  sync once through the copy's retained descriptor during publication. Permissions
76
92
  are never widened to reopen them. Directory modes are finalized after the file
@@ -103,6 +119,15 @@ including a mode containing only stripped special bits, stays zero under
103
119
  directories; ZIP UNIX creator records with zero attributes are explicit zero,
104
120
  while non-UNIX ZIP records use the absent-metadata defaults.
105
121
 
122
+ `entryUmask` removes permission bits after the selected mode policy: final modes
123
+ are the policy result `& ~entryUmask`. It applies to files, explicit directories,
124
+ and implicit parent directories, including existing destination directories.
125
+ The destination root and private staging modes are unchanged. The default `0`
126
+ preserves existing behavior; invalid masks reject before extraction begins.
127
+ fs-safe neither reads nor changes the process umask. Pass
128
+ `entryUmask: process.umask()` explicitly when that is the caller's policy.
129
+ Windows retains the POSIX-mode limitations described below.
130
+
106
131
  TAR mode fields containing only NUL/ASCII-space padding use absent defaults.
107
132
  Both backends recognize GNU binary modes, including signed values,
108
133
  within JavaScript's safe-integer range before masking permission bits.
@@ -116,7 +141,7 @@ stay `0o600` and directories `0o700` until publication. Files receive their fina
116
141
  mode through the guarded copy's owned writer descriptor. Directories are pinned
117
142
  before descending and finalized after their children, including empty and
118
143
  restrictive directories. Explicit accepted directory modes win regardless of
119
- archive order; implicit parents receive `0o755`. Existing destination directories
144
+ archive order; implicit parents receive `0o755 & ~entryUmask`. Existing destination directories
120
145
  also receive the requested final mode. They are never temporarily widened to
121
146
  allow child writes; insufficient write/search access still rejects.
122
147
 
@@ -154,6 +179,13 @@ between native and JavaScript paths rather than reimplementing it in Rust.
154
179
 
155
180
  ZIP extraction and bounded reads admit every physical central-directory record and its referenced local header before either decoder can normalize or collapse names. Raw names and valid Unicode Path names must pass traversal checks before stripping, filtering, or selecting a requested member; duplicate or colliding names reject with `entry-path`, even in unrelated or skipped members. Materially conflicting local/central or Unicode interpretations, malformed critical metadata, and ambiguous framing reject with `ArchiveFormatError`. Harmless internal separator and dot-component equivalence is allowed only after validation; every raw and Unicode interpretation must also agree on whether its name ends in `/` or `\`. Ordinary legacy filename decoding remains backend-selected. Native ZIP extraction groups nearby metadata reads into at most two 4 KiB read-ahead buffers per admission pass; larger records retain separately bounded reads. Buffered record views remain stable across eviction, and cached work periodically yields for deadline checks.
156
181
 
182
+ ZIP end-record admission searches the bounded comment window for signatures
183
+ while retaining complete comment-length and ambiguity checks. Dense signature
184
+ sequences fall back to the bounded byte scan.
185
+ The separate `readZipCentralDirectoryEntryCount(buffer)` hint uses bounded
186
+ reverse searches for comments and retains its latest-valid-record selection;
187
+ it does not replace strict archive admission.
188
+
157
189
  ZIP admission establishes each entry's kind before callbacks: a high-word UNIX
158
190
  symlink type takes precedence regardless of creator, followed by the DOS directory
159
191
  bit, the exact UNIX directory type, or a terminal slash/backslash. Native manifests
@@ -185,6 +217,10 @@ decoded validation. Unicode Path admission is shared only when both the raw name
185
217
  and the complete Unicode fields match; different fields still verify their own
186
218
  CRC and interpretation. Shared backing memory is checked independently. Decoded
187
219
  name validation is not reused across entries or archives.
220
+ UTF-8-flagged ASCII names in nonshared backing memory reuse their raw-path
221
+ validation, and an identical decoded spelling reuses its canonical key. Shared
222
+ name bytes still undergo independent decoding and validation; Unicode Path
223
+ fields retain their own CRC and interpretation checks.
188
224
 
189
225
  `stripComponents` removes leading nonempty, non-`.` path components after
190
226
  normalizing separators. For example, `./pkg/hello.txt` with
@@ -202,6 +238,9 @@ directory paths. For example, `./pkg//state\cache/value` is presented as
202
238
  `pkg/state/cache/value`, even with `stripComponents: 1`. Case and Unicode
203
239
  spelling are preserved. Local PAX `path`, GNU long-name, and supported ZIP
204
240
  Unicode Path names use the same canonicalization.
241
+ Callbacks follow physical archive order, including ZIP names that look like
242
+ integer object keys. The public ZIP loader's `files` object retains ordinary
243
+ JavaScript object enumeration and mutation behavior.
205
244
 
206
245
  Raw paths undergo traversal, absolute/drive-path, and NUL validation **before**
207
246
  canonicalization; normalization cannot turn an unsafe path into an accepted
package/docs/atomic.md CHANGED
@@ -16,7 +16,7 @@ import {
16
16
 
17
17
  Write `content` to a sibling temp file in the destination directory, apply the parent-directory and final file modes through verified descriptors, optionally `fsync` the file descriptor, optionally `fsync` the parent directory after rename, then atomically rename over the destination. No permission change follows a caller-supplied pathname.
18
18
 
19
- On POSIX, the parent is opened with no-follow and directory-only flags, checked against its pre-open identity, and mode-adjusted through that descriptor. A replacement symlink is rejected rather than followed. If the directory cannot be opened for descriptor access, the operation fails closed instead of retrying by pathname. Windows does not enforce POSIX directory modes and Node cannot consistently open directory descriptors there, so `dirMode` is passed only to `mkdir`; no pathname `chmod` fallback is attempted.
19
+ On POSIX, the parent is opened with no-follow and directory-only flags, checked against its exact pre-open device/inode identity, and mode-adjusted through that descriptor. A replacement symlink is rejected rather than followed. If the directory cannot be opened for descriptor access, the operation fails closed instead of retrying by pathname. Windows does not enforce POSIX directory modes and Node cannot consistently open directory descriptors there, so `dirMode` is passed only to `mkdir`; no pathname `chmod` fallback is attempted.
20
20
 
21
21
  Async replacements to the same destination are serialized inside the current process, so two overlapping `replaceFileAtomic()` calls do not interleave their temp-write/rename phases. Use a sidecar lock when multiple processes may write the same target.
22
22
 
@@ -92,10 +92,13 @@ await replaceFileAtomic({
92
92
 
93
93
  If `beforeRename` throws, the rename is skipped and the owned temp file is removed — the destination is unchanged. Cleanup unlinks only the exact admitted single-link file; a substitute observed at the temp name is preserved and removed from cleanup authority. The same identity is rechecked before every rename retry, when entering copy fallback, and at the final name after rename. A post-rename verification failure reports the race without rolling back or deleting the published name.
94
94
 
95
- JavaScript permits `beforeRename` callbacks to throw any value, including
95
+ JavaScript permits `beforeRename` callbacks and filesystem adapters to throw any value, including
96
96
  `undefined`, `null`, `false`, signed zero, `0n`, an empty string, and `NaN`.
97
- Once such an operation failure reaches temp-owner settlement, atomic replacement
98
- preserves that value when cleanup and close succeed. With
97
+ Atomic replacement preserves such operational failures when cleanup and close
98
+ succeed, including rename and post-rename verification failures. Rename retry
99
+ and copy-fallback classification reads the error code once without coercion;
100
+ missing or unreadable codes preserve the original failure. A rejected call has
101
+ no success receipt even if an adapter committed its rename before throwing. With
99
102
  `throwOnCleanupError: true`, an additional owned-temp cleanup failure keeps the
100
103
  existing cleanup wrapper whose `cause` is the original thrown value. A later
101
104
  descriptor-close failure is reported in an `AggregateError`, in operation/cleanup
@@ -150,6 +153,14 @@ must not have aliases. The policy reads `nlink` from a pinned destination
150
153
  descriptor, not pathname metadata, before rename and rechecks it in the copy
151
154
  fallback.
152
155
 
156
+ Source and pinned destination admission compare exact bigint device/inode
157
+ observations, so distinct identities that round to the same JavaScript number
158
+ cannot authorize a copy. Unknown Windows identities get one bounded reinspection
159
+ of the same descriptor or path; incomplete or inconsistent observations fail
160
+ closed without reopening. Injected filesystem adapters must honor the
161
+ `{ bigint: true }` stat option. Source admission reuses that exact pair instead
162
+ of immediately repeating it with numeric metadata.
163
+
153
164
  The default `copyFallbackRestore: "none"` preserves the existing fallback
154
165
  contract: a failed copy can leave a partial destination. For state files where
155
166
  preserving the old bytes is more important, choose `"restore-original"` and set
@@ -349,7 +360,10 @@ the preflight cap fails with `FsSafeError("too-large")`.
349
360
  If another writer changes source entries during the fallback, the staged copy
350
361
  throws `ESTALE` before commit when possible. If the destination has already
351
362
  been committed, cleanup still preserves the changed source entries and throws
352
- `ESTALE`. Directory manifests retain an exact bigint device/inode receipt from
363
+ `ESTALE`. Copied file and symlink manifests retain exact bigint identities and
364
+ nanosecond timestamps, so rounded file IDs cannot authorize copying or removal
365
+ of a different entry. Hardlink groups also use exact identities. Directory
366
+ manifests retain an exact bigint device/inode receipt from
353
367
  copy admission. Each directory is rechecked after traversal, and the source root
354
368
  is checked again before publication. Cleanup checks the same receipt before
355
369
  removing children, then invokes mutation authority and rechecks the receipt and
@@ -366,6 +380,11 @@ unlink is verified through a remaining manifested alias and its exact resulting
366
380
  identity becomes the next cleanup receipt. This accounts for the operation's
367
381
  own link-count and ctime changes without suppressing unexpected external
368
382
  mutations.
383
+ On Windows, opening a regular source may advance its ctime while all other
384
+ fingerprint fields match. That exception applies only to opening; post-copy
385
+ verification and cleanup retain their full fingerprint checks.
386
+ Copied aliases share each verified open-time update. Changes observed between
387
+ copies still reject instead of being mistaken for an owned open transition.
369
388
 
370
389
  ### Mutation authority and publication receipts
371
390
 
@@ -404,6 +423,11 @@ thenable, or any other value fails with a `TypeError`; rejected asynchronous
404
423
  results are consumed. Perform asynchronous policy checks before calling the
405
424
  helper and use the authority callback to recheck the current owner at each
406
425
  mutation boundary. All callbacks are captured before the first await.
426
+ Copied source leaves are checked again immediately after authority returns and
427
+ before unlink is submitted. Supplying any of the three callbacks also
428
+ retains the original source-parent route and renews copied-directory ancestry
429
+ before cleanup. Substituted entries are preserved; pathname checks and unlink
430
+ remain a best-effort sequence, not atomic.
407
431
 
408
432
  `onDestinationPublished` runs exactly once after a successful rename resolves,
409
433
  before awaited post-rename directory checks or source cleanup. It receives a
@@ -157,6 +157,10 @@ pnpm archive:producer-smoke ./consumer require
157
157
  This uses a child bound to canonical cwd/device/inode running `/usr/bin/tar -czf - .`
158
158
  with unchanged stdout, and npm tar, on synthetic Unicode/newline/long-name files,
159
159
  then the installed package API for exact payload hashes and bounded reads.
160
+ The consumer must be separate from the source checkout; package resolution must
161
+ stay within its own `node_modules`, including pnpm's local `.pnpm` layout.
162
+ Workspace self-resolution, upward resolution, and external package links reject
163
+ before package imports or archive fixture creation.
160
164
  It also rejects a valid PAX override attached to an invalid raw UTF-8 field.
161
165
  The `require` command must resolve the freshly packed native binding; the
162
166
  `off` command uses the installed WASM asset. No live user files are read.
package/docs/creation.md CHANGED
@@ -42,12 +42,16 @@ POSIX creation requests `0700` for directories and `0600` for files by default;
42
42
  the umask may restrict those permissions further. Existing directory privacy
43
43
  checks never broaden permissions.
44
44
 
45
- Native private file creation checks the retained descriptor's actual owner and
45
+ Private POSIX `Root.create()` and `createJson()` writes check the retained descriptor's actual owner and
46
46
  permissions before writing payload bytes, after producer and authority callbacks,
47
- and at publication. A successful `chmod` is insufficient: filesystems that do not
47
+ and at publication, including JavaScript fallback writes. Payload writes require
48
+ the temporary `0600` mode even when the requested final mode differs.
49
+ Private ownership, permissions and ACLs are checked before preparing that mode,
50
+ including after authority callbacks; valid restrictive initial modes remain supported.
51
+ A successful `chmod` is insufficient: filesystems that do not
48
52
  enforce owner-only permissions reject before payload writes. The requested final
49
53
  mode is verified too; a failure after publication preserves the completed file
50
- and reports its published outcome.
54
+ and staged creation reports its published outcome.
51
55
 
52
56
  On macOS (Darwin), private creation also requires an ACL-free result. The native
53
57
  helper must provide `inspectDarwinAcl`; native `off`, a missing helper, or an
@@ -113,7 +117,7 @@ thenables reject before mutation. Parent and file identity checks are repeated
113
117
  after the callback. Final permission checks, descriptor settlement and cleanup
114
118
  retain the operation's cleanup ownership after publication.
115
119
 
116
- Failure does not always mean the final path is absent. Private-file errors
120
+ Failure does not always mean the final path is absent. Staged private-file errors
117
121
  after publication or ambiguous publication preserve the destination and carry
118
122
  `details.publication.status` (`published` or `indeterminate`), the target path and
119
123
  staging cleanup outcome. Cleanup and close failures retain the original error
@@ -89,6 +89,29 @@ or reconstructed numeric identity is accepted only when both components are
89
89
  safe integers and, on Windows, nonzero. Rounded or unknown caller identities
90
90
  fail with `path-mismatch` rather than authorizing a different directory.
91
91
 
92
+ Caller-supplied receipts may use `DirectoryReceipt<BigIntStats>` with the result
93
+ of `lstat(path, { bigint: true })`. `pinDirectory()`, `syncDirectory()`,
94
+ `syncDirectorySync()`, `publishFileExclusive()`'s `parentReceipt`, and
95
+ `stageFileInDirectory()` accept both numeric and bigint receipt inputs.
96
+ `DirectoryReceipt` without a type argument and all returned durability receipts
97
+ still expose numeric `Stats`, including working type predicates and Date
98
+ properties. Bigint metadata is projected from the supplied observation, retaining
99
+ fractional timestamps and the private exact device/inode identity.
100
+
101
+ ```ts
102
+ import { lstatSync, realpathSync, type BigIntStats } from "node:fs";
103
+ import { syncDirectorySync, type DirectoryReceipt } from "@openclaw/fs-safe/durability";
104
+
105
+ const directoryPath = "/srv/backups/sqlite";
106
+ const receipt: DirectoryReceipt<BigIntStats> = {
107
+ path: directoryPath,
108
+ realPath: realpathSync(directoryPath),
109
+ identity: lstatSync(directoryPath, { bigint: true }),
110
+ };
111
+ // Keep this receipt across the application's publication operation.
112
+ const outcome = syncDirectorySync(receipt);
113
+ ```
114
+
92
115
  Call `close()` in `finally`. Closing is idempotent; using a closed pin fails.
93
116
 
94
117
  These checks intentionally reject a moved or replaced pathname. For one file's
@@ -276,6 +299,11 @@ pathname without reopening the file and repeat the symlink and file-type checks.
276
299
  POSIX opens are nonblocking, so a raced FIFO or device is rejected after
277
300
  descriptor inspection rather than waiting for a writer.
278
301
 
302
+ A pathname hash reports failure to close its owned descriptor after successful
303
+ hashing. If hashing, admission, or cancellation already failed, that original
304
+ failure remains primary even when close also fails. This also applies to
305
+ `sha256FileSync()`; borrowed handles and descriptors remain caller-owned.
306
+
279
307
  When the optional binding is active, hashing runs as an async native task and
280
308
  does not occupy the JavaScript event loop with digest updates. With native mode
281
309
  `off`, or in `auto` when no binding loads, the fallback performs asynchronous
@@ -347,6 +375,13 @@ this receipt instead of inferring ownership from path existence. The original
347
375
  failure remains available as `cause`. Failures before target creation retain
348
376
  their existing error shape and do not claim a cleanup result.
349
377
 
378
+ Source and target identities are checked again after successful or unsupported
379
+ directory synchronization, while their descriptors remain owned. A late
380
+ verification failure retains its strategy's verification phase and is not a
381
+ directory-sync failure. Completed copied targets stay pinned during conditional
382
+ cleanup; substituted entries remain untouched. Returned numeric metadata comes
383
+ from the retained target descriptor and grants no continuing pathname authority.
384
+
350
385
  ### Directory-sync failure policy
351
386
 
352
387
  `onSyncFailure` applies only after target creation and content/identity fencing
@@ -0,0 +1,68 @@
1
+ # Exact file comparison
2
+
3
+ `sameFileContentsSync()` compares the bytes of two already-open regular files,
4
+ starting at offset zero. It uses bounded buffers and completes positive short
5
+ reads independently on each descriptor. A `true` result requires matching bytes
6
+ and observed EOF on both inputs; a difference can return `false` immediately.
7
+
8
+ ```ts
9
+ import fs from "node:fs";
10
+ import { sameFileContentsSync } from "@openclaw/fs-safe/advanced";
11
+
12
+ const source = fs.openSync("/trusted/source.sqlite", "r");
13
+ try {
14
+ const copy = fs.openSync("/trusted/copy.sqlite", "r");
15
+ try {
16
+ console.log(sameFileContentsSync(source, copy, { maxBytes: 256 * 1024 * 1024 }));
17
+ } finally {
18
+ fs.closeSync(copy);
19
+ }
20
+ } finally {
21
+ fs.closeSync(source);
22
+ }
23
+ ```
24
+
25
+ ## Signature
26
+
27
+ ```ts
28
+ type SameFileContentsOptions = { maxBytes?: number };
29
+
30
+ function sameFileContentsSync(
31
+ leftFd: number,
32
+ rightFd: number,
33
+ options?: SameFileContentsOptions,
34
+ ): boolean;
35
+ ```
36
+
37
+ Both descriptors must be open regular files. Nonregular inputs throw
38
+ `FsSafeError("not-file")`; underlying filesystem errors propagate unchanged.
39
+ Passing the same descriptor twice is allowed, but does not bypass validation,
40
+ the byte limit, or reads. File sizes are checked against the limit, but are not
41
+ used as proof that contents match or that EOF has been reached.
42
+
43
+ ## Bounds and ownership
44
+
45
+ `maxBytes` is a limit for each file, not their combined size. It accepts a
46
+ non-negative safe integer or `Infinity`; omission imposes no caller-selected
47
+ limit. Invalid limits throw `RangeError` before filesystem work. Comparisons
48
+ cannot exceed `Number.MAX_SAFE_INTEGER` bytes because positions must remain
49
+ exactly representable.
50
+
51
+ A reported file size above the limit throws `FsSafeError("too-large")` before
52
+ reading. At the limit, the comparison reads at most one additional byte from
53
+ each input to prove EOF; any observed overflow throws the same error. A
54
+ matching prefix is never reported as complete equality. An early mismatch
55
+ does not scan the remaining bytes or promise to detect later errors or growth.
56
+ A zero-byte limit admits two empty files. Memory use is at most two 1 MiB
57
+ payload buffers, regardless of file size.
58
+
59
+ The operation neither changes the descriptors' current offsets nor closes
60
+ them, including on failure. It performs no writes, hashing, pathname lookup,
61
+ or identity comparison. Callers retain path admission, hardlink policy,
62
+ descriptor lifetime, and any before/after mutation-fingerprint checks. A
63
+ comparison is not a snapshot of concurrently modified files; applications
64
+ requiring stable contents must retain their existing coordination and checks.
65
+
66
+ Use [`readFileWindowFullySync()`](positional-read.md) for a selected byte
67
+ window and [bounded descriptor reads](advanced.md#files-and-identity) when the
68
+ caller needs the file contents in memory.
package/docs/json.md CHANGED
@@ -146,10 +146,11 @@ where lower latency matters more than crash-durability.
146
146
 
147
147
  Synchronous variant. It pretty-prints with two spaces, appends a newline,
148
148
  creates parents at `0o700`, writes at `0o600`, and synchronizes the parent
149
- directory best-effort. File-mode tightening carries the staged bigint identity
150
- through rename and applies `fchmod` only when the reopened descriptor and current
151
- pathname still name that same single-link regular file; a swap is preserved and
152
- skips this best-effort step. It has no options bag. On `EPERM`/`EEXIST`, its legacy
149
+ directory best-effort. A retained staging descriptor carries the exact bigint
150
+ identity through rename and file-mode tightening. Publication and cleanup never
151
+ adopt a substituted temporary file: a changed identity, type or link count rejects
152
+ the write and leaves the replacement untouched. A swap detected after publication
153
+ also rejects without deleting or changing the replacement. It has no options bag. On `EPERM`/`EEXIST`, its legacy
153
154
  compatibility path removes the existing destination and retries the staged-file
154
155
  rename, so that fallback is temporarily non-atomic while retaining the staged
155
156
  file's `0600` mode; use the async `writeJson()`/`replaceFileAtomic()` surfaces
@@ -83,6 +83,8 @@ An existing non-directory component cannot be traversed further, including by
83
83
  The asynchronous helper opens the candidate through the matched [`Root`](root.md),
84
84
  so no-follow, identity, hardlink, device-path, and byte-limit checks happen at
85
85
  the read itself.
86
+ Link policies are captured once before root initialization and reused for every
87
+ candidate, so replacing options while a read is pending cannot weaken admission.
86
88
 
87
89
  ```ts
88
90
  type ReadLocalFileFromRootsOptions = LocalRootsInputOptions & {
@@ -44,9 +44,11 @@ new placeholder, and before publishing to an existing symlink-selected destinati
44
44
  They verify alias binding, destination preservation, observed placeholder/stage
45
45
  states, and cleanup before fixture teardown. The default native-off and explicit
46
46
  `verify-content-with-lock` native-require configurations both select the existing
47
- Windows JS buffer writer. The compatibility route is expected not to load the
48
- addon; the receipt does not mislabel this as native publication or evidence that
49
- content-verification fallback or lock contention was exercised.
47
+ Windows JS buffer writer. In native-require mode, the compatibility route loads
48
+ the addon to publish its retained sidecar lock through `Root.create`; the payload
49
+ writer remains JS. The receipt does not mislabel this as native payload
50
+ publication or evidence that content-verification fallback or lock contention
51
+ was exercised.
50
52
 
51
53
  ## Bounds and interpretation
52
54
 
package/docs/native.md CHANGED
@@ -76,14 +76,14 @@ whether a path, archive entry, mode, owner, or cleanup policy is acceptable.
76
76
  or add-on CRT descriptor namespace.
77
77
 
78
78
  The internal macOS `inspectDarwinAcl(fd)` capability reports `absent`, `empty`,
79
- or `present` for the opened object's extended ACL. It synchronously owns a
80
- close-on-exec duplicate for inspection, leaves the caller's descriptor and file
81
- position alone, and never reopens a pathname. Darwin's `acl_get_entry` returns
79
+ or `present` for the opened object's extended ACL. It synchronously borrows the
80
+ caller's descriptor, preserving its file position and POSIX record locks, and
81
+ never reopens a pathname. Keep the descriptor open until inspection returns.
82
+ Darwin's `acl_get_entry` returns
82
83
  zero for an entry; end-of-list is accepted only for the first entry of a valid,
83
84
  privately owned empty ACL. Unsupported, malformed, and failed inspection is not
84
- reported as absence. These facts do not classify individual ACE permissions,
85
- prove volume ownership enforcement, or add ACL enforcement to private writers
86
- and secure readers outside the clone path.
85
+ reported as absence. These facts do not classify individual ACE permissions or
86
+ prove volume ownership enforcement; each caller applies its own security policy.
87
87
 
88
88
  ## Archives
89
89
 
@@ -267,8 +267,9 @@ infer native loading from timing.
267
267
  ## Loader security
268
268
 
269
269
  Importing fs-safe never executes a child process. Linux libc selection uses
270
- the Node process report, conventional musl library filenames, and the ELF
271
- `PT_INTERP` field of `process.execPath`. If all probes are inconclusive, the
270
+ the Node process report, then the ELF `PT_INTERP` field of `process.execPath`,
271
+ then conventional musl library filenames. An installed compatibility loader
272
+ does not override the running executable's interpreter. If all probes are inconclusive, the
272
273
  loader conservatively attempts the glibc package and lets normal module loading
273
274
  fail into `auto` fallback. The loader requires only the package selected from
274
275
  the detected target; it never probes unrelated packages, downloads code, or
package/docs/path.md CHANGED
@@ -44,7 +44,7 @@ opened or mutated.
44
44
 
45
45
  ### `isPathInsideWithRealpath(rootDir, target, opts?)`
46
46
 
47
- Synchronous. Same as `isPathInside`, but resolves both inputs through `realpath` first. Use this when you want the canonical answer and either input might be a symlink.
47
+ Synchronous. First requires lexical containment with `isPathInside`, then resolves both inputs through `realpath` and checks containment again. A lexically outside path is rejected even if its resolved target is inside the root.
48
48
 
49
49
  ```ts
50
50
  isPathInsideWithRealpath("/srv/uploads", "/srv/symlink-to-elsewhere"); // false
@@ -121,7 +121,7 @@ The check is intentionally not a normal consumer policy knob. Safe read APIs rej
121
121
 
122
122
  ### `isNotFoundPathError(err)`
123
123
 
124
- `true` if the error is a `NodeJS.ErrnoException` with code `ENOENT` (file or directory missing).
124
+ `true` if the error has code `ENOENT` (file or directory missing) or `ENOTDIR` (a path component is not a directory).
125
125
 
126
126
  ```ts
127
127
  try {
@@ -207,8 +207,8 @@ import {
207
207
  } from "@openclaw/fs-safe/advanced";
208
208
  ```
209
209
 
210
- - `assertNoPathAliasEscape({ rootRealPath, candidatePath, policy })` — async. Asserts the candidate's resolved real path is inside the root. Configurable via `PATH_ALIAS_POLICIES` (which currently ships only the default `"strict"` policy).
211
- - `assertNoHardlinkedFinalPath({ filePath })` — async. Throws if the file at `filePath` has `nlink > 1`.
210
+ - `assertNoPathAliasEscape({ absolutePath, rootPath, boundaryLabel, policy? })` — async. Applies root path resolution and final hardlink checks. `policy` defaults to `PATH_ALIAS_POLICIES.strict`; `PATH_ALIAS_POLICIES.unlinkTarget` permits final symlink and hardlink aliases for unlink operations.
211
+ - `assertNoHardlinkedFinalPath({ filePath, root, boundaryLabel, allowFinalHardlinkForUnlink? })` — async. Rejects a regular file with `nlink > 1`; missing paths and nonregular resolved entries are ignored. Setting `allowFinalHardlinkForUnlink: true` skips this check for unlink operations.
212
212
 
213
213
  Use these when writing a custom helper that wants the same guards `root()` uses but with different surrounding logic.
214
214
 
@@ -20,6 +20,9 @@ The root subpath also exports `openLocalFileSafely`, `readLocalFileSafely`, and
20
20
  `resolveOpenedFileRealPathForHandle` for trusted absolute-file composition.
21
21
  They do not create a root boundary around arbitrary caller input; prefer
22
22
  `root()` for untrusted paths.
23
+ The handle resolver verifies exact descriptor and pathname identities, with one
24
+ bounded retry for unknown Windows observations. It borrows the handle without
25
+ reading, reopening, closing it, or changing its cursor.
23
26
 
24
27
  The error helpers are `categorizeFsSafeError` and `FsSafeErrorDetails`. The
25
28
  deprecated native-configuration bridge retains the `FsSafePythonConfig` type.
@@ -135,6 +138,8 @@ The durability surface also exports the synchronous strict
135
138
  `Sha256FileSyncInput`, `Sha256FileOptions`, and `Sha256FileResult`.
136
139
  `sha256FileSync()` provides synchronous pathname or borrowed-descriptor hashing
137
140
  with the same byte-budget and digest-result contracts as `sha256File()`.
141
+ `DirectoryReceipt<T>` accepts `Stats` or `BigIntStats` input metadata; its default
142
+ type argument and returned durability receipts remain numeric `Stats`.
138
143
 
139
144
  ## Archives
140
145
 
@@ -60,7 +60,7 @@ await fs.move("notes/today.txt", "notes/archive/today.txt", { overwrite: true })
60
60
  await fs.remove("notes/archive/today.txt");
61
61
  ```
62
62
 
63
- `move()` defaults to no clobber. That mode requires the native helper so a concurrent target cannot be replaced between an absence check and the rename; without it, the call fails with `helper-unavailable`. Pass `{ overwrite: true }` when replacing the target is intentional. `remove()` works on files and empty directories. For non-empty directories, list and remove children first or use [`replaceDirectoryAtomic`](atomic.md#replacedirectoryatomic).
63
+ `move()` defaults to no clobber. That mode requires the native helper so a concurrent target cannot be replaced between an absence check and the rename; without it, the call fails with `helper-unavailable`. Pass `{ overwrite: true }` when replacing the target is intentional. `remove()` removes files and empty directories by default. To remove a non-empty directory, pass `{ recursive: true }`; use `maxEntries`, `maxDepth`, and `signal` to bound the work. See [`root()`](root.md) for removal ordering, limits, and partial-removal semantics.
64
64
 
65
65
  ## 5. Inspect
66
66
 
package/docs/reading.md CHANGED
@@ -28,7 +28,7 @@ Regardless of shape, every read goes through the same boundary checks:
28
28
  4. Reject `..` traversal and absolute spellings when they resolve outside the root. In-root absolute spellings remain accepted; `readAbsolute` makes that intent explicit.
29
29
  5. Open with `O_NOFOLLOW` where available. Any remaining symlink in the path triggers `symlink` unless the call's `symlinks` policy is `follow-within-root`.
30
30
  6. Compare the pre-open bigint path identity with the open fd, then perform one best-effort final admission: check the captured root identity, compare the policy-aware pathname with the fd, freshly canonicalize and re-admit that target inside the captured root, compare its exact bigint identity without following a final symlink with the fd, and check the root again. Both final pathname observations run even when their spellings match. An observed swap triggers `path-mismatch` or `outside-workspace`; a missing final path triggers `not-found`.
31
- 7. If `hardlinks: "reject"`, refuse files with `nlink > 1` (`hardlink`).
31
+ 7. If `hardlinks: "reject"`, refuse files with `nlink > 1` (`hardlink`), including links introduced before either fresh final pathname observation. Root-file helpers apply the same final check when `rejectHardlinks` is enabled; directory admission is unaffected.
32
32
  8. If `maxBytes` is set, refuse reads larger than the cap (`too-large`).
33
33
 
34
34
  The final fence closes a rejected descriptor before any Root read consumes bytes or
@@ -100,7 +100,7 @@ type RootReadOptions = {
100
100
  hardlinks?: "reject" | "allow"; // override defaults.hardlinks
101
101
  maxBytes?: number; // refuse reads larger than this many bytes
102
102
  nonBlockingRead?: boolean; // compatibility hint; safe opens are already nonblocking where supported
103
- symlinks?: "reject" | "follow-within-root"; // override defaults.symlinks
103
+ symlinks?: "reject" | "follow-within-root" | "follow-parents-within-root"; // override defaults.symlinks
104
104
  };
105
105
  ```
106
106
 
@@ -110,6 +110,9 @@ descriptor, and current pathname identities remain exact bigints through the
110
110
  append boundary; rounded-equal replacements and persistent unknown Windows
111
111
  identities reject before chmod or writing bytes. With
112
112
  `rejectSymlinkParents: true`, it also rejects symlinked ancestor directories.
113
+ Supported option values are captured once before filesystem work, so replacing
114
+ the content, encoding, mode or cap cannot change an in-flight append. Byte-array
115
+ contents remain caller-owned; leave them unchanged until the append completes.
113
116
 
114
117
  On POSIX, `O_NONBLOCK` prevents a no-reader FIFO substituted before open from
115
118
  stalling admission. A confirmed non-regular target is refused before chmod or