@openclaw/fs-safe 0.6.0 → 0.7.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 (183) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +7 -0
  3. package/dist/archive-deadline.d.ts +3 -0
  4. package/dist/archive-deadline.d.ts.map +1 -1
  5. package/dist/archive-deadline.js +44 -8
  6. package/dist/archive-entry.d.ts.map +1 -1
  7. package/dist/archive-entry.js +1 -0
  8. package/dist/archive-errors.d.ts +1 -0
  9. package/dist/archive-errors.d.ts.map +1 -1
  10. package/dist/archive-errors.js +3 -0
  11. package/dist/archive-input.d.ts.map +1 -1
  12. package/dist/archive-input.js +26 -16
  13. package/dist/archive-limits.d.ts +11 -3
  14. package/dist/archive-limits.d.ts.map +1 -1
  15. package/dist/archive-limits.js +24 -0
  16. package/dist/archive-native.d.ts +3 -2
  17. package/dist/archive-native.d.ts.map +1 -1
  18. package/dist/archive-native.js +24 -9
  19. package/dist/archive-policy.d.ts +2 -0
  20. package/dist/archive-policy.d.ts.map +1 -1
  21. package/dist/archive-policy.js +9 -1
  22. package/dist/archive-read.d.ts.map +1 -1
  23. package/dist/archive-read.js +69 -41
  24. package/dist/archive-staging.d.ts +3 -0
  25. package/dist/archive-staging.d.ts.map +1 -1
  26. package/dist/archive-staging.js +91 -43
  27. package/dist/archive-tar-admission.d.ts +7 -0
  28. package/dist/archive-tar-admission.d.ts.map +1 -0
  29. package/dist/archive-tar-admission.js +43 -0
  30. package/dist/archive-tar-gnu.d.ts +2 -0
  31. package/dist/archive-tar-gnu.d.ts.map +1 -0
  32. package/dist/archive-tar-gnu.js +20 -0
  33. package/dist/archive-tar-header.d.ts +8 -0
  34. package/dist/archive-tar-header.d.ts.map +1 -0
  35. package/dist/archive-tar-header.js +47 -0
  36. package/dist/archive-tar-meta.d.ts +29 -1
  37. package/dist/archive-tar-meta.d.ts.map +1 -1
  38. package/dist/archive-tar-meta.js +104 -35
  39. package/dist/archive-tar-runtime.d.ts +3 -0
  40. package/dist/archive-tar-runtime.d.ts.map +1 -1
  41. package/dist/archive-tar-runtime.js +3 -0
  42. package/dist/archive-tar.d.ts.map +1 -1
  43. package/dist/archive-tar.js +8 -2
  44. package/dist/archive-zip-names.js +2 -2
  45. package/dist/archive.d.ts.map +1 -1
  46. package/dist/archive.js +38 -26
  47. package/dist/atomic.d.ts +1 -1
  48. package/dist/atomic.d.ts.map +1 -1
  49. package/dist/bounded-read-stream.d.ts.map +1 -1
  50. package/dist/bounded-read-stream.js +2 -4
  51. package/dist/bounded-read.d.ts.map +1 -1
  52. package/dist/bounded-read.js +3 -10
  53. package/dist/byte-budget.d.ts +5 -0
  54. package/dist/byte-budget.d.ts.map +1 -0
  55. package/dist/byte-budget.js +9 -0
  56. package/dist/file-lock-sync.d.ts.map +1 -1
  57. package/dist/file-lock-sync.js +10 -5
  58. package/dist/file-store-boundary.d.ts.map +1 -1
  59. package/dist/file-store-boundary.js +9 -5
  60. package/dist/file-store-limit.d.ts +2 -0
  61. package/dist/file-store-limit.d.ts.map +1 -0
  62. package/dist/file-store-limit.js +8 -0
  63. package/dist/file-store-sync-write.d.ts.map +1 -1
  64. package/dist/file-store-sync-write.js +38 -8
  65. package/dist/file-store.d.ts.map +1 -1
  66. package/dist/file-store.js +26 -26
  67. package/dist/json-durable-queue-directory.d.ts +2 -0
  68. package/dist/json-durable-queue-directory.d.ts.map +1 -0
  69. package/dist/json-durable-queue-directory.js +20 -0
  70. package/dist/json-durable-queue-ownership.d.ts +14 -0
  71. package/dist/json-durable-queue-ownership.d.ts.map +1 -0
  72. package/dist/json-durable-queue-ownership.js +168 -0
  73. package/dist/json-durable-queue-retirement.d.ts +9 -0
  74. package/dist/json-durable-queue-retirement.d.ts.map +1 -0
  75. package/dist/json-durable-queue-retirement.js +126 -0
  76. package/dist/json-durable-queue-transfer-lock.d.ts +2 -0
  77. package/dist/json-durable-queue-transfer-lock.d.ts.map +1 -0
  78. package/dist/json-durable-queue-transfer-lock.js +19 -0
  79. package/dist/json-durable-queue.d.ts +1 -0
  80. package/dist/json-durable-queue.d.ts.map +1 -1
  81. package/dist/json-durable-queue.js +90 -57
  82. package/dist/json.d.ts.map +1 -1
  83. package/dist/json.js +27 -8
  84. package/dist/local-roots.d.ts.map +1 -1
  85. package/dist/local-roots.js +4 -2
  86. package/dist/native-binding.d.ts +12 -3
  87. package/dist/native-binding.d.ts.map +1 -1
  88. package/dist/output-sibling.d.ts.map +1 -1
  89. package/dist/output-sibling.js +11 -110
  90. package/dist/output.d.ts.map +1 -1
  91. package/dist/output.js +4 -2
  92. package/dist/pinned-open.d.ts.map +1 -1
  93. package/dist/pinned-open.js +27 -48
  94. package/dist/pinned-write.d.ts.map +1 -1
  95. package/dist/pinned-write.js +8 -9
  96. package/dist/private-temp-workspace.d.ts +3 -1
  97. package/dist/private-temp-workspace.d.ts.map +1 -1
  98. package/dist/private-temp-workspace.js +81 -56
  99. package/dist/publish-file.d.ts.map +1 -1
  100. package/dist/publish-file.js +2 -4
  101. package/dist/read-opened-file.d.ts.map +1 -1
  102. package/dist/read-opened-file.js +6 -4
  103. package/dist/regular-file.d.ts.map +1 -1
  104. package/dist/regular-file.js +136 -82
  105. package/dist/replace-file-copy-fallback.d.ts +3 -1
  106. package/dist/replace-file-copy-fallback.d.ts.map +1 -1
  107. package/dist/replace-file-copy-fallback.js +21 -36
  108. package/dist/replace-file-copy-source.d.ts +21 -0
  109. package/dist/replace-file-copy-source.d.ts.map +1 -0
  110. package/dist/replace-file-copy-source.js +112 -0
  111. package/dist/replace-file-descriptor.d.ts +13 -3
  112. package/dist/replace-file-descriptor.d.ts.map +1 -1
  113. package/dist/replace-file-descriptor.js +32 -7
  114. package/dist/replace-file-rename-policy.d.ts +7 -0
  115. package/dist/replace-file-rename-policy.d.ts.map +1 -0
  116. package/dist/replace-file-rename-policy.js +30 -0
  117. package/dist/replace-file-temp-owner.d.ts +46 -0
  118. package/dist/replace-file-temp-owner.d.ts.map +1 -0
  119. package/dist/replace-file-temp-owner.js +346 -0
  120. package/dist/replace-file.d.ts +6 -1
  121. package/dist/replace-file.d.ts.map +1 -1
  122. package/dist/replace-file.js +72 -58
  123. package/dist/root-impl.d.ts.map +1 -1
  124. package/dist/root-impl.js +40 -26
  125. package/dist/secret-file.d.ts.map +1 -1
  126. package/dist/secret-file.js +6 -3
  127. package/dist/secret-read-async.d.ts.map +1 -1
  128. package/dist/secret-read-async.js +6 -5
  129. package/dist/secure-file.d.ts.map +1 -1
  130. package/dist/secure-file.js +17 -13
  131. package/dist/sibling-staged-file.d.ts +15 -0
  132. package/dist/sibling-staged-file.d.ts.map +1 -0
  133. package/dist/sibling-staged-file.js +148 -0
  134. package/dist/sibling-temp.d.ts +3 -0
  135. package/dist/sibling-temp.d.ts.map +1 -1
  136. package/dist/sibling-temp.js +30 -74
  137. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  138. package/dist/sidecar-lock-acquire.js +48 -27
  139. package/dist/sidecar-lock-handle.d.ts +6 -2
  140. package/dist/sidecar-lock-handle.d.ts.map +1 -1
  141. package/dist/sidecar-lock-handle.js +17 -3
  142. package/dist/sidecar-lock-policy.d.ts +2 -0
  143. package/dist/sidecar-lock-policy.d.ts.map +1 -1
  144. package/dist/sidecar-lock-policy.js +29 -0
  145. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  146. package/dist/sidecar-lock-reclaim.js +29 -7
  147. package/dist/sidecar-lock.d.ts.map +1 -1
  148. package/dist/sidecar-lock.js +28 -16
  149. package/dist/suppressed-error.d.ts +6 -0
  150. package/dist/suppressed-error.d.ts.map +1 -0
  151. package/dist/suppressed-error.js +15 -0
  152. package/dist/temp-cleanup.d.ts +2 -0
  153. package/dist/temp-cleanup.d.ts.map +1 -1
  154. package/dist/temp-cleanup.js +25 -10
  155. package/dist/temp-workspace-owner.d.ts +23 -0
  156. package/dist/temp-workspace-owner.d.ts.map +1 -0
  157. package/dist/temp-workspace-owner.js +320 -0
  158. package/dist/temp.d.ts +1 -1
  159. package/dist/temp.d.ts.map +1 -1
  160. package/dist/test-hooks.d.ts +5 -0
  161. package/dist/test-hooks.d.ts.map +1 -1
  162. package/docs/advanced.md +8 -1
  163. package/docs/archive.md +194 -26
  164. package/docs/atomic.md +11 -2
  165. package/docs/durability.md +1 -1
  166. package/docs/file-store.md +2 -0
  167. package/docs/install.md +8 -4
  168. package/docs/json.md +8 -4
  169. package/docs/native-helper.md +15 -3
  170. package/docs/native.md +33 -6
  171. package/docs/output.md +10 -0
  172. package/docs/public-api.md +6 -3
  173. package/docs/quickstart.md +1 -1
  174. package/docs/reading.md +1 -1
  175. package/docs/regular-file.md +9 -2
  176. package/docs/root.md +2 -0
  177. package/docs/secret-file.md +5 -1
  178. package/docs/secure-file.md +4 -2
  179. package/docs/security-model.md +18 -0
  180. package/docs/sidecar-lock.md +4 -0
  181. package/docs/store.md +13 -0
  182. package/docs/temp.md +128 -14
  183. package/package.json +10 -10
package/docs/archive.md CHANGED
@@ -23,7 +23,7 @@ await extractArchive({
23
23
  archivePath: "/srv/uploads/plugin.zip",
24
24
  destDir: "/srv/workspace/plugins/plugin",
25
25
  kind: "zip", // optional; resolveArchiveKind() can infer
26
- timeoutMs: 15_000, // hard ceiling for the whole extraction
26
+ timeoutMs: 15_000, // hard budget; active destination mutation is joined
27
27
  stripComponents: 0, // tar-style strip-leading-dirs
28
28
  entryModes: "clamp", // default; use "preserve" for archive rwx bits
29
29
  entryFilter: ({ path, kind, size }) => "extract",
@@ -45,7 +45,7 @@ await extractArchive({
45
45
  type ExtractArchiveOptions = {
46
46
  archivePath: string; // absolute path to the archive
47
47
  destDir: string; // absolute destination directory; must already exist
48
- timeoutMs: number; // positive wall-clock cap; <= 0/non-finite disables it
48
+ timeoutMs: number; // positive wall-clock budget; <= 0/non-finite disables it
49
49
  kind?: ArchiveKind; // "zip" | "tar" | "tar-zstd" | "tar-bzip2"
50
50
  stripComponents?: number; // strip N leading dirs from entry paths
51
51
  tarGzip?: boolean; // when archive is .tar.gz/.tgz
@@ -68,8 +68,8 @@ private staging tree; ZIP applies the same policy to `unixPermissions`.
68
68
  Native extraction is deliberately split into two phases. Rust first reports an
69
69
  entry manifest without creating paths. TypeScript validates paths, applies
70
70
  `stripComponents`, filters, limits, and mode policy, then passes an explicit
71
- accepted-entry plan back to Rust. Rust only performs decompression and the
72
- fd-relative `mkdirBeneath`/exclusive-open writes. This keeps policy identical
71
+ accepted-entry plan back to Rust. Rust owns raw-stream admission, decompression,
72
+ and fd-relative `mkdirBeneath`/exclusive-open writes. This keeps filter policy identical
73
73
  between native and JavaScript paths rather than reimplementing it in Rust.
74
74
 
75
75
  ZIP extraction and bounded reads admit every physical central-directory record and its referenced local header before either decoder can normalize or collapse names. Raw names and valid Unicode Path names must pass traversal checks before stripping, filtering, or selecting a requested member; duplicate or colliding names reject with `entry-path`, even in unrelated or skipped members. Materially conflicting local/central or Unicode interpretations, malformed critical metadata, and ambiguous framing reject with `ArchiveFormatError`. Harmless separator and dot-component equivalence is allowed only after validation. Ordinary legacy filename decoding remains backend-selected.
@@ -82,11 +82,29 @@ toward `maxEntries` and undergo traversal validation. JavaScript TAR extraction
82
82
  passes node-tar this accepted output path with its own stripping disabled, so
83
83
  depth checks, collision checks, writes, and mode application agree.
84
84
 
85
- An `entryFilter` sees the validated effective archive path **before stripping**
86
- (including a local PAX `path` override), entry kind, and declared size.
85
+ An `entryFilter` sees the validated **canonical effective archive path before
86
+ stripping**, entry kind, and declared size. On every JavaScript and native
87
+ ZIP/TAR backend (including gzip and native zstd/bzip2), backslashes become `/`,
88
+ empty and `.` components are removed, and trailing separators are removed from
89
+ directory paths. For example, `./pkg//state\cache/value` is presented as
90
+ `pkg/state/cache/value`, even with `stripComponents: 1`. Case and Unicode
91
+ spelling are preserved. Local PAX `path`, GNU long-name, and supported ZIP
92
+ Unicode Path names use the same canonicalization.
93
+
94
+ Raw paths undergo traversal, absolute/drive-path, and NUL validation **before**
95
+ canonicalization; normalization cannot turn an unsafe path into an accepted
96
+ one. Stripping and output collision checks use this same canonical identity.
97
+ Filters that compare exact strings should use canonical pre-strip paths,
98
+ including directory names without a trailing `/`.
87
99
  Returning `"skip"` rejects the whole archive unless `onFiltered` is
88
- explicitly `"skip-entry"`. Path traversal and archive-wide entry-count checks
89
- still apply to skipped entries.
100
+ explicitly `"skip-entry"`. Runtime values other than `"reject-archive"` and
101
+ `"skip-entry"` reject before extraction starts instead of falling through to
102
+ skip behavior. Path traversal and archive-wide entry-count checks still apply
103
+ to skipped entries.
104
+ `maxEntryBytes` and `maxExtractedBytes` charge only entries accepted after
105
+ stripping and filtering. Skipping a large member does not consume these payload
106
+ budgets. The separate complete-stream decoded limit still applies to all TAR
107
+ content, including skipped or fully stripped members.
90
108
 
91
109
  For example, a fleet restore can omit regenerated cache entries while rejecting
92
110
  any other policy mismatch by default:
@@ -111,14 +129,33 @@ If skipping was not explicitly part of the restore contract, omit
111
129
  `onFiltered`; the first `"skip"` then rejects the complete archive with
112
130
  `ArchiveSecurityError("entry-filtered")`.
113
131
 
114
- Policy rejection is prompt on both implementations. The JavaScript TAR path
115
- owns the file stream and aborts node-tar through a pipeline on filter, path,
116
- link, limit, validation, or timeout failure, which destroys both ends instead
117
- of leaving a paused parser to drain indefinitely. The native path finishes its
118
- bounded manifest read before TypeScript policy evaluation, so a rejected plan
119
- never starts the extraction worker.
120
-
121
- If `kind` is omitted, the helper calls `resolveArchiveKind(archivePath)` and throws if the extension is not recognized. Pass `kind` explicitly when the archive name doesn't carry the type (e.g. content-addressed names). A positive finite `timeoutMs` is a wall-clock budget; zero, negative, `NaN`, and infinity disable the deadline.
132
+ Both TAR implementations finish bounded admission before TypeScript policy
133
+ evaluation, so a rejected plan never starts extraction. The JavaScript path
134
+ owns the extraction file stream and aborts node-tar through a pipeline on
135
+ parser disagreement, validation, or timeout failure, destroying both ends
136
+ instead of leaving a paused parser to drain indefinitely.
137
+
138
+ TAR character devices, block devices, and FIFOs are presented to the filter as
139
+ `kind: "other"`. Accepted entries of these types reject with
140
+ `ArchiveSecurityError("entry-link")`; an explicit `"skip-entry"` filter can omit
141
+ them. GNU typeflag `D` (`GNUDumpDir`) is a directory on both backends, including
142
+ its filter kind, canonical path, and directory creation policy. Its declared
143
+ body size follows the existing TAR strip/filter payload budgets; dump contents
144
+ are not restored as files.
145
+
146
+ Unsupported logical TAR records, including volume headers (`V`), Solaris ACL
147
+ records (`A`), inodes (`I`), continuations (`M`), and unrecognized typeflags,
148
+ still undergo entry counting, raw/effective path validation, stripping, depth
149
+ and output collision checks in physical order. Each remaining record reaches
150
+ `entryFilter` once with its canonical pre-strip path, `kind: "other"`, and
151
+ declared effective size. A filter skip rejects with `"entry-filtered"` unless
152
+ `onFiltered: "skip-entry"` is explicit. Accepted unsupported records are safely
153
+ omitted and do not consume output payload budgets. This applies even when the
154
+ underlying TAR parser suppresses the record. GNU long names describe one such
155
+ record and are then cleared; local PAX on unsupported types and GNU sparse
156
+ `S` records retain their existing fail-closed format policy.
157
+
158
+ If `kind` is omitted, the helper calls `resolveArchiveKind(archivePath)` and throws if the extension is not recognized. Pass `kind` explicitly when the archive name doesn't carry the type (e.g. content-addressed names). Archive inputs must remain regular files from preview through descriptor admission; POSIX opens are no-follow and nonblocking, so a FIFO swap cannot stall before deadline checks resume. A positive finite `timeoutMs` is a wall-clock budget; zero, negative, `NaN`, and infinity disable the deadline. Non-mutating work rejects promptly when the budget expires. If a live destination mutation is already in flight, rejection waits only for that mutation and any rollback to finish; no later destination mutation can begin.
122
159
 
123
160
  ### Limits
124
161
 
@@ -126,8 +163,8 @@ If `kind` is omitted, the helper calls `resolveArchiveKind(archivePath)` and thr
126
163
  type ArchiveExtractLimits = {
127
164
  maxArchiveBytes?: number; // refuse if archivePath stat'd size exceeds this
128
165
  maxEntries?: number; // refuse before extracting if entry count > this
129
- maxExtractedBytes?: number; // refuse mid-stream if total extracted bytes > this
130
- maxEntryBytes?: number; // refuse a single entry larger than this
166
+ maxExtractedBytes?: number; // cap total payload bytes accepted after strip/filter
167
+ maxEntryBytes?: number; // cap one accepted entry after strip/filter
131
168
  maxMetaEntryBytes?: number; // refuse one PAX/GNU metadata body above this
132
169
  maxEntryPathComponents?: number; // bound output path depth after stripComponents
133
170
  };
@@ -142,13 +179,24 @@ A limit violation throws `ArchiveLimitError`. Its constant and string code are:
142
179
  | `ARCHIVE_SIZE_EXCEEDS_LIMIT` | `archive-size-exceeds-limit` |
143
180
  | `ENTRY_COUNT_EXCEEDS_LIMIT` | `archive-entry-count-exceeds-limit` |
144
181
  | `EXTRACTED_SIZE_EXCEEDS_LIMIT` | `archive-extracted-size-exceeds-limit` |
182
+ | `DECODED_SIZE_EXCEEDS_LIMIT` | `archive-decoded-size-exceeds-limit` |
145
183
  | `ENTRY_EXTRACTED_SIZE_EXCEEDS_LIMIT` | `archive-entry-extracted-size-exceeds-limit` |
146
184
  | `META_ENTRY_SIZE_EXCEEDS_LIMIT` | `archive-meta-entry-size-exceeds-limit` |
147
185
  | `ENTRY_PATH_COMPONENTS_EXCEEDS_LIMIT` | `archive-entry-path-components-exceeds-limit` |
148
186
  | `MANIFEST_SIZE_EXCEEDS_LIMIT` | `archive-manifest-size-exceeds-limit` |
149
187
 
150
- `MANIFEST_SIZE_EXCEEDS_LIMIT` is retained in the public compatibility union;
151
- no current public extractor emits it.
188
+ `MANIFEST_SIZE_EXCEEDS_LIMIT` is an active internal TAR admission limit, shared
189
+ by JavaScript and native extraction and bounded reads. Each logical member,
190
+ including ignored, filtered, and fully stripped members, charges
191
+ `64 + 2 * UTF-8 byte length of its effective pre-strip path` before emission or
192
+ retention. PAX/GNU metadata headers do not themselves charge a member cost.
193
+ The allowance is independent of `maxArchiveBytes`: derive a per-member path
194
+ allowance of `max(256, min(maxMetaEntryBytes, max(1, maxEntryPathComponents) * 256))`,
195
+ apply the same 64-byte overhead and doubled path cost, multiply by `maxEntries`,
196
+ and cap the total at 64 MiB using saturating arithmetic. Zero and very large
197
+ public limits remain deterministic. There is no public `maxManifestBytes`
198
+ option; this charged manifest budget supplements the decoded and metadata
199
+ limits rather than bounding the complete process heap.
152
200
 
153
201
  Catch and branch on the code to surface a meaningful response to the caller.
154
202
 
@@ -165,9 +213,87 @@ codes remain `"destination-not-directory"`, `"destination-symlink"`, and
165
213
  - **TOCTOU during merge:** extraction first writes to a private temp dir, then merges into `destDir` using the same boundary checks as `root().write()`. Destination symlink swaps are checked with the selected platform mechanism; non-Linux routes retain the best-effort race window documented in the [security model](security-model.md#containment-guarantees-by-platform).
166
214
  - **Zip bombs:** `maxExtractedBytes` and `maxEntryBytes` apply to *post-decompression* bytes, so highly-compressed payloads hit the cap before they exhaust disk.
167
215
  - **Corrupt ZIP payloads:** streamed output must match both the central-directory CRC and declared uncompressed size before it can leave private staging.
168
- - **Slow-loris archives:** `timeoutMs` is a hard wall-clock budget. Extraction is aborted on overrun.
216
+ - **Corrupt gzip streams:** truncated compressed bodies, missing trailers, and checksum failures reject before extraction publishes files or an entry read returns bytes, on both JavaScript and native backends.
217
+ - **Slow-loris archives:** `timeoutMs` is a hard wall-clock budget for non-mutating work. Extraction is aborted on overrun; if a destination mutation is already in flight, that mutation and rollback are joined before rejection so archive-controlled publication cannot continue afterward.
169
218
  - **Metadata bombs:** a streaming pass-through reader rejects oversized PAX, GNU long-name, and GNU long-link bodies before either TAR implementation buffers them. It understands octal and base-256 fixed sizes and validates bounded local PAX bodies before using their size overrides for member framing. Original archive bytes remain unchanged.
170
219
 
220
+ ### Raw TAR framing
221
+
222
+ Extraction and bounded reads admit the complete decoded TAR stream through the
223
+ raw meter before either backend's TAR parser runs. This applies to plain TAR,
224
+ gzip, and native-supported zstd/bzip2, without changing native-mode availability
225
+ or fallback policy. The existing TypeScript and Rust meters enforce the same
226
+ framing rules before parser normalization:
227
+
228
+ - Every nonzero header must have a valid unsigned octal checksum, delimited
229
+ within its field. Checksum validation precedes metadata allocation and member
230
+ policy. Fixed name, prefix, and linkname fields require strict UTF-8 and NUL
231
+ padding. Raw hardlink (`1`) and symlink (`2`) headers require a nonempty
232
+ linkname; every other type, including PAX/GNU metadata, requires an empty
233
+ linkname. This check precedes metadata handling and member/filter policy.
234
+ - Directory (`5`), hardlink (`1`), and symlink (`2`) raw headers must declare
235
+ zero body bytes, whether or not local PAX metadata is present. Valid zero-size
236
+ links remain subject to the existing link/filter policy.
237
+ - EOF requires two consecutive, complete 512-byte zero blocks at a header
238
+ boundary. A header after just one zero block, a missing/partial EOF marker,
239
+ and any nonzero bytes after EOF reject. Additional zero padding after EOF may
240
+ have any byte length within the decoded ceiling; zero blocks inside a declared
241
+ member body are payload.
242
+ - Headers and padded bodies must be complete. Size fields accept unsigned
243
+ octal with ASCII-space/NUL padding or supported positive base-256 encoding;
244
+ malformed numbers and non-padding bytes after a NUL reject. Raw sizes and
245
+ padded sizes must fit `Number.MAX_SAFE_INTEGER`, even with PAX overrides,
246
+ before member budgets are considered.
247
+
248
+ Framing failures use `ArchiveFormatError("archive-header-invalid")`. PAX `x`
249
+ and GNU long-name/long-link `L`/`K` payloads retain their existing support and
250
+ metadata limits; the zero-body rule is not applied to all non-regular types.
251
+ PAX effective sizes still determine regular-member framing. Admission preserves
252
+ the input bytes, and all entry/path/byte limits and extraction deadlines remain
253
+ in force. Native inspection now completes this admission pass before parsing,
254
+ requiring one additional streaming read/decompression pass.
255
+ JavaScript admission reports an ordered logical-member manifest from the raw
256
+ meter, bounded by entry-count, manifest, and decoded limits. Policy runs once
257
+ over that manifest; extraction checks parser-visible members against the
258
+ accepted decisions before writing. Original member names and USTAR prefixes
259
+ are validated even when overridden, and non-padding bytes after a fixed path
260
+ field's NUL terminator reject rather than hiding an unsafe suffix.
261
+ Both meters enforce the 255-byte component ceiling under NFC and NFD before
262
+ metadata replaces a raw path, including Hangul decomposition expansion.
263
+ Native extraction and entry reads also drain their metered readers through
264
+ physical EOF after parser traversal, before completing directory modes,
265
+ publishing staged files, or returning the requested bytes. Finding the requested
266
+ member or reaching the parser's logical EOF cannot bypass trailing validation.
267
+
268
+ The raw meter enforces `maxEntries` before consuming each logical member's body,
269
+ including members later skipped by filtering or stripping. PAX/GNU metadata
270
+ headers do not count as members; their payloads use `maxMetaEntryBytes`.
271
+ The meter does not receive `maxEntryBytes` or `maxExtractedBytes`: those payload
272
+ budgets apply only after strip/filter acceptance, using declared effective
273
+ sizes and excluding block padding. JavaScript's entry checker and the native
274
+ accepted-plan builder retain this shared policy. Every TAR admission/parser
275
+ pass has a separate absolute decoded ceiling:
276
+ `maxExtractedBytes + maxArchiveBytes`, safely clamped to
277
+ `Number.MAX_SAFE_INTEGER` (768 MiB with defaults). It counts every admitted
278
+ decoded byte: headers, bodies, metadata, all block padding, both EOF blocks,
279
+ and zero padding after EOF. It bounds complete decoding before parser policy,
280
+ including all filtered/stripped content; cumulative metadata and zero tails
281
+ cannot bypass this bound. Exceeding this ceiling throws
282
+ `ArchiveLimitError("archive-decoded-size-exceeds-limit")`.
283
+
284
+ The same TypeScript helper derives the ceiling for JavaScript and every native
285
+ TAR pass. Before selecting a backend, it caps internal metadata/decoded limits at
286
+ `Number.MAX_SAFE_INTEGER` and logical entry counts at `2^32 - 1`. Larger finite
287
+ options such as `Number.MAX_VALUE` remain valid; high-level payload budgets keep
288
+ their large values. The decoded ceiling uses clamped `maxExtractedBytes` and
289
+ archive overhead with safe addition. Ordinary limits, including
290
+ zero and the existing defaulting/rounding rules, retain their behavior.
291
+ There is no new public option. This is an absolute decoded admission
292
+ cap, not a decompression-ratio policy; bounded stream/codec read-ahead remains.
293
+ After this complete preflight, the JavaScript backend disables node-tar's
294
+ independent ratio threshold so it cannot reject data that the native backend
295
+ accepts within the same absolute limits.
296
+
171
297
  ### Bounded local PAX support
172
298
 
173
299
  Extraction and single-entry reads accept one nonempty local POSIX `x` header
@@ -175,8 +301,8 @@ Extraction and single-entry reads accept one nonempty local POSIX `x` header
175
301
  directory, symlink, or hardlink. `path`, `linkpath`, and `size` override that
176
302
  member only. Effective paths still pass traversal validation before stripping,
177
303
  then the output paths pass depth and collision checks. The filter receives the
178
- effective pre-strip path, followed by link policy checks. PAX never permits link
179
- creation. Effective sizes drive framing, filters, and the existing output-byte
304
+ canonical effective pre-strip path, followed by link policy checks. PAX never
305
+ permits link creation. Effective sizes drive framing, filters, and the existing output-byte
180
306
  budgets; `maxEntries` still counts members, not their metadata headers.
181
307
 
182
308
  Records must have exact byte lengths, ASCII keys, a final newline, and no
@@ -206,13 +332,38 @@ Global `g`, old `X`, old GNU `N`, empty/dangling/repeated local headers, mixed
206
332
  PAX/GNU extension chains, unknown keys, charset declarations, ACL extensions,
207
333
  and all sparse extensions (including `GNU.sparse.*`, `SCHILY.filetype`,
208
334
  `SCHILY.realsize`, and `SCHILY.size`) fail closed with
209
- `ArchiveFormatError("archive-header-invalid")`. Standalone GNU long-name `L`
210
- and long-link `K` support is unchanged. GNU sparse extension blocks are still
335
+ `ArchiveFormatError("archive-header-invalid")`. GNU sparse extension blocks are still
211
336
  metered in 512-byte units before rejection, preserving metadata-limit errors
212
337
  for excessive chains. The per-body `maxMetaEntryBytes` limit bounds PAX storage
213
338
  and duplicate-key state; one local header per member prevents local metadata
214
339
  chains without introducing a new limit or changing defaults.
215
340
 
341
+ ### Bounded GNU long names and links
342
+
343
+ Both raw meters buffer GNU long-name `L` and long-link `K` bodies within
344
+ `maxMetaEntryBytes` before either TAR parser runs. A body must contain a nonempty
345
+ UTF-8 name, with either no NUL or exactly one terminal NUL. Embedded NULs,
346
+ additional terminal NULs, bytes after a NUL, and invalid UTF-8 reject with
347
+ `ArchiveFormatError("archive-header-invalid")`. The meters preserve original
348
+ archive bytes, including the optional terminator and block padding.
349
+
350
+ One logical member may have at most one `L` and one `K`, in either order.
351
+ Repeated metadata of either kind, mixed PAX/GNU chains in either direction,
352
+ and GNU metadata without a following member reject with the same format error.
353
+ Pending metadata is cleared only when its described member is admitted;
354
+ metadata records do not count toward `maxEntries`.
355
+
356
+ An `L` name undergoes raw-path validation before parser normalization, stripping,
357
+ or filtering; unsafe paths reject with `ArchiveSecurityError("entry-path")`.
358
+ The validated name remains pending until its described header arrives. An
359
+ effective name ending in `/` or `\` requires raw directory type `5` or `D`;
360
+ other types reject with `ArchiveFormatError` before filtering, preventing the
361
+ parsers from disagreeing about a member's type.
362
+ `K` validates encoding and NUL structure without authorizing link creation.
363
+ Normal link/filter policy still governs the described member. Canonical
364
+ pre-strip filter paths, decoded-stream ceilings, and physical EOF checks apply
365
+ to plain/gzip TAR and native zstd/bzip2 alike.
366
+
216
367
  ## `resolveArchiveKind`
217
368
 
218
369
  ```ts
@@ -259,11 +410,24 @@ await extractArchive({
259
410
  regular-file entry into a bounded `Buffer` without extracting a tree. It pins
260
411
  and privately stages the archive input, rejects link, directory, and duplicate
261
412
  entries, verifies ZIP CRC and declared size,
262
- and throws `ArchiveLimitError` if decompressed bytes exceed `maxBytes`. ZIP
413
+ and throws `ArchiveLimitError` if the requested entry's output exceeds
414
+ `maxBytes`. For TAR, `maxBytes` applies only to that requested entry: a larger
415
+ unrequested member remains valid within the default archive admission limits.
416
+ TAR traversal uses default entry-count, compressed-input, and metadata limits,
417
+ plus the 768 MiB decoded ceiling derived from default extracted/archive byte
418
+ limits. It does not apply payload budgets to unrequested members. ZIP
263
419
  inputs retain the archive subpath's 256 MiB compressed-input ceiling.
264
420
  With a native binding it uses the same Rust decoders as extraction, including
265
421
  zstd and bzip2 TAR. Without native it retains the JS ZIP/TAR/gzip implementation.
266
422
 
423
+ Requested paths and effective member names use extraction's canonical pre-strip
424
+ identity: backslashes become `/`, and repeated separators and `.` components
425
+ are removed after raw-path validation. For example, `./pkg//value` and
426
+ `pkg\value` both address `pkg/value`, including supported GNU/PAX and ZIP
427
+ Unicode Path names. Case and Unicode spelling are preserved. Requests ending
428
+ in `/` or `\` still reject as non-files. Canonical duplicate members reject
429
+ before an unrelated requested entry can be returned.
430
+
267
431
  ```ts
268
432
  const rawManifest = await readArchiveEntry(uploadPath, "package/manifest.json", {
269
433
  maxBytes: 64 * 1024,
@@ -308,6 +472,10 @@ import {
308
472
  - `resolveArchiveOutputPath({ destDir, entryPath })` — combines the entry path with the destination, after validation.
309
473
  - `isWindowsDrivePath(value)` — detects drive-relative segments such as `C:secret` or `nested/C:secret` that should be rejected.
310
474
 
475
+ Validate attacker-controlled paths before calling normalization or stripping
476
+ helpers. After validation, `stripArchivePath(entryPath, 0)` returns the canonical
477
+ pre-strip identity used by extraction filters (or `null` for an empty path).
478
+
311
479
  ## Common patterns
312
480
 
313
481
  ### Extract an upload, surface budget violations
package/docs/atomic.md CHANGED
@@ -48,6 +48,7 @@ type ReplaceFileAtomicOptions = {
48
48
  copyFallbackRestore?: "restore-original" | "none"; // default: "none"
49
49
  maxRestoreBytes?: number; // required with "restore-original"
50
50
  destinationHardlinks?: "reject"; // default unset (no destination nlink policy)
51
+ renameIdentity?: "strict" | "verify-content-with-lock"; // default "strict"
51
52
  syncTempFile?: boolean; // fsync(temp) before rename, or the final file after copy fallback; default false
52
53
  syncParentDir?: boolean; // fsync(parent) after rename, POSIX only; default false
53
54
  throwOnCleanupError?: boolean; // report temp cleanup failure; default false
@@ -58,7 +59,7 @@ type ReplaceFileAtomicOptions = {
58
59
 
59
60
  ### `beforeRename`
60
61
 
61
- Runs after the temp file is fully written and before the rename. Use it to take a backup snapshot, capture the about-to-be-replaced contents, or notify an observer:
62
+ Runs after the temp file is fully written and before the rename. Use it to take a backup snapshot, capture the about-to-be-replaced contents, or notify an observer. The helper retains the staged descriptor and exact bigint identity across the hook; replacing, deleting, hardlinking, or changing the temp entry to a non-regular file is rejected before publication:
62
63
 
63
64
  ```ts
64
65
  await replaceFileAtomic({
@@ -70,7 +71,15 @@ await replaceFileAtomic({
70
71
  });
71
72
  ```
72
73
 
73
- If `beforeRename` throws, the rename is skipped and the temp file is removed — the destination is unchanged.
74
+ 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.
75
+
76
+ Identity checks and pathname rename/unlink remain separate syscalls, not atomic conditional mutations. Use an approved writable parent plus cooperative locking or OS isolation when arbitrary concurrent namespace mutation is in scope.
77
+
78
+ ### FUSE mounts and unstable rename identity
79
+
80
+ Strict source-to-destination identity is the default. Some FUSE mounts assign a different inode to the destination during rename even without concurrency. Set `renameIdentity: "verify-content-with-lock"` to accept that boundary only when the re-opened no-follow destination has the exact requested SHA-256 content under an exclusive hashed sidecar lock in the destination parent. The newly accepted descriptor and identity remain pinned through parent sync and final verification. The synchronous helper provides the same policy with the synchronous lock implementation.
81
+
82
+ This is the same explicit weaker contract available on `Root` writes: cooperating writers are serialized, stale locks fail closed, and mismatched content is rejected after publication without rollback. A same-authority actor that ignores the advisory lock can still substitute another file with identical bytes, so do not use this compatibility policy in directories writable by untrusted same-UID processes.
74
83
 
75
84
  ### `EPERM` and copy fallback
76
85
 
@@ -90,7 +90,7 @@ target fails with `FsSafeError("path-mismatch")`.
90
90
  ## Exclusive file publication
91
91
 
92
92
  `publishFileExclusive()` materializes one file without clobbering an existing
93
- target. It pins the source with `O_NOFOLLOW`, optionally verifies
93
+ target. It pins the source with nonblocking `O_NOFOLLOW`, optionally verifies
94
94
  `expectedSourceIdentity`, tries a hardlink first, then synchronizes the target
95
95
  parent directory.
96
96
 
@@ -31,6 +31,8 @@ const cache = fileStore({
31
31
  });
32
32
  ```
33
33
 
34
+ 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
+
34
36
  Use `private: true` for credentials, auth profiles, tokens, and other private
35
37
  state. Private mode keeps the same `FileStore` shape but routes writes through
36
38
  the secret-file atomic path, refusing symlink parent components and re-asserting
package/docs/install.md CHANGED
@@ -92,9 +92,9 @@ native targets and npm-compatible OS, CPU, and Linux libc filters install only
92
92
  the matching binary. Consumers do not run a native build, download code at
93
93
  runtime, or execute a postinstall step. Omitting optional dependencies keeps
94
94
  non-archive fallback-capable operations working in `auto` or `off`. Native-only
95
- features, including retained-directory staging, atomic `rename-noreplace`,
96
- zstd/bzip2 TAR handling, and Windows private-directory creation, remain
97
- unavailable. Operations needing the binding in `require` mode fail with
95
+ features, including strict owned-tree temp cleanup, retained-directory staging,
96
+ atomic `rename-noreplace`, zstd/bzip2 TAR handling, and Windows private-directory
97
+ creation, remain unavailable. Operations needing the binding in `require` mode fail with
98
98
  `helper-unavailable` when the matching package is absent or incompatible.
99
99
 
100
100
  Upgrading an existing 0.5 consumer? Follow [Migrating to 0.6](migrating-to-0.6.md)
@@ -127,7 +127,11 @@ FS_SAFE_NATIVE_MODE=off # auto | off | require
127
127
  Disabling native loading keeps fallback-capable operations working through Node path
128
128
  operations guarded by lexical and canonical checks plus identity verification.
129
129
  Use `require` when native-backed operations must fail instead of falling back.
130
- The exact boundary is documented in [native helper policy](native-helper.md).
130
+ Temp workspaces retain compatible JavaScript quarantine cleanup in `auto` and
131
+ `off`. Set `cleanupSafety: "require-bounded"` to reject before child creation
132
+ unless native no-replace quarantine and descriptor-bounded tree removal are
133
+ available. See the [temp workspace contract](temp.md#private-temp-workspaces). The exact boundary
134
+ for other operations is documented in [native helper policy](native-helper.md).
131
135
 
132
136
  ## Verify the install
133
137
 
package/docs/json.md CHANGED
@@ -135,10 +135,14 @@ where lower latency matters more than crash-durability.
135
135
 
136
136
  Synchronous variant. It pretty-prints with two spaces, appends a newline,
137
137
  creates parents at `0o700`, writes at `0o600`, and synchronizes the parent
138
- directory best-effort. It has no options bag. On `EPERM`/`EEXIST`, its legacy
139
- compatibility path may replace by copy rather than atomic rename; use the async
140
- `writeJson()`/`replaceFileAtomic()` surfaces when fallback policy must be
141
- explicit.
138
+ directory best-effort. File-mode tightening carries the staged bigint identity
139
+ through rename and applies `fchmod` only when the reopened descriptor and current
140
+ pathname still name that same single-link regular file; a swap is preserved and
141
+ skips this best-effort step. It has no options bag. On `EPERM`/`EEXIST`, its legacy
142
+ compatibility path removes the existing destination and retries the staged-file
143
+ rename, so that fallback is temporarily non-atomic while retaining the staged
144
+ file's `0600` mode; use the async `writeJson()`/`replaceFileAtomic()` surfaces
145
+ when fallback policy must be explicit.
142
146
 
143
147
  ```ts
144
148
  writeJsonSync("./prefs.json", { theme: "dark" });
@@ -31,6 +31,18 @@ The equivalent environment variables are `FS_SAFE_NATIVE_MODE` and `OPENCLAW_FS_
31
31
 
32
32
  Configure the mode once during startup. Loading is lazy and cached; changing from `auto` to `require` after a failed load changes failure policy but does not repeatedly probe the binary.
33
33
 
34
+ [`tempWorkspace()` and its scoped/sync variants](temp.md#private-temp-workspaces)
35
+ remain available in every mode. Their default compatible cleanup uses guarded
36
+ JavaScript quarantine when owned native tree removal is unavailable.
37
+ `cleanupSafety: "require-bounded"` instead rejects before child creation unless
38
+ no-replace quarantine plus descriptor-relative owned-tree removal are available.
39
+ On Linux, admission probes the exact `openat2` child-directory flags, including
40
+ `RESOLVE_NO_XDEV`, at runtime. An unavailable or denied probe selects compatible
41
+ JavaScript cleanup even in global `require` mode; `require-bounded` rejects before
42
+ child creation.
43
+ Already-created strict workspaces retain their binding
44
+ and descriptors across later mode changes.
45
+
34
46
  [`stageFileInDirectory()`](staged-file.md) always requires native support on
35
47
  Linux/macOS and rejects before creation when off, unavailable, or missing the
36
48
  required capability. Windows is unsupported for this lifecycle. This does not
@@ -44,9 +56,9 @@ clone/copy/hash workers, and Windows security descriptor calls. The TypeScript
44
56
  layer owns policy, retries, filters, budgets, modes, cleanup, error
45
57
  normalization, and the decision to fall back.
46
58
 
47
- - Linux uses `openat2` with `RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS`, `renameat`, and `renameat2(RENAME_NOREPLACE)`.
48
- - macOS 15.4 and newer prefer `O_RESOLVE_BENEATH`; older kernels resolve components with `O_NOFOLLOW` and restart in-root symlinks from the pinned root descriptor. Both routes use an `F_GETPATH` post-open escape detector and report `best-effort` because directory rename races are not atomic with that check. Publication uses `renameat` for replacement and `renameatx_np(RENAME_EXCL)` for no-replace.
49
- - Windows uses handle-relative `NtCreateFile`, rejects reparse points, and uses `FileRenameInfoEx` with replacement selected explicitly by the TypeScript policy layer.
59
+ - Linux uses `openat2` with `RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS`, `renameat`, and `renameat2(RENAME_NOREPLACE)`. Owned-tree cleanup enumerates and unlinks through retained directory descriptors and rejects device crossings.
60
+ - macOS 15.4 and newer prefer `O_RESOLVE_BENEATH`; older kernels resolve components with `O_NOFOLLOW` and restart in-root symlinks from the pinned root descriptor. Both routes use an `F_GETPATH` post-open escape detector and report `best-effort` because directory rename races are not atomic with that check. Publication uses `renameat` for replacement and `renameatx_np(RENAME_EXCL)` for no-replace; owned-tree cleanup uses descriptor-relative `openat`/`unlinkat`.
61
+ - Windows uses handle-relative `NtCreateFile`, rejects reparse points during root-bounded traversal, uses `FileRenameInfoEx` with replacement selected explicitly by the TypeScript policy layer, and deletes owned trees through exact opened handles with `FileDispositionInfoEx`; symlink/reparse entries in owned trees are removed as leaves and never traversed.
50
62
 
51
63
  Native primitives back create-only and replacing pinned writes, async sidecar creation,
52
64
  guarded publication, archive acceleration, and direct Windows ACL operations.
package/docs/native.md CHANGED
@@ -64,12 +64,39 @@ returns a bounded manifest. TypeScript applies the shared path, filter, strip,
64
64
  mode, and byte policies and returns an index-bound extraction plan. Rust then
65
65
  creates only those planned entries beneath a private staging descriptor.
66
66
 
67
- A fixed-512-byte pass-through meter sits between decompression and the TAR
68
- crate. It reads only header type and octal/base-256 size fields. It never parses
69
- metadata content. Oversized GNU long-name/link metadata is rejected before
70
- buffering; PAX size overrides and GNU sparse entries are rejected as
71
- unmeterable rather than guessed. The JavaScript node-tar path receives the same
72
- `maxMetaEntryBytes` value and a matching fixed-header preflight.
67
+ A raw meter sits between decompression and the TAR crate, with matching
68
+ TypeScript admission before node-tar. It parses 512-byte headers and bounded
69
+ local PAX `x` metadata, using supported effective sizes to locate the following
70
+ member body. GNU long-name/link `L`/`K` payloads remain
71
+ supported. `maxMetaEntryBytes` bounds each metadata body before allocation;
72
+ unsupported global/old metadata and sparse forms fail closed rather than being
73
+ interpreted as ordinary members. See [bounded local PAX support](archive.md#bounded-local-pax-support).
74
+
75
+ Every raw pass receives only TypeScript's resolved `maxEntries`,
76
+ `maxMetaEntryBytes`, and `maxDecodedBytes`. Shared resolution caps metadata and
77
+ decoded byte fields at JavaScript's safe-integer maximum and entry counts at
78
+ `2^32 - 1` before backend selection. Large finite limits remain accepted;
79
+ native conversion mirrors those caps and rejects malformed non-finite or
80
+ negative direct-call values before casting. Logical member headers count
81
+ before filtering/stripping; metadata records do not. `maxEntryBytes` and
82
+ `maxExtractedBytes` remain exclusively in TypeScript's accepted-plan builder,
83
+ after strip/filter policy, and are absent from the raw meter's interface.
84
+ Bounded reads use the default count/metadata/decoded bounds; public `maxBytes`
85
+ bounds only the requested output. TypeScript derives the internal decoded cap
86
+ by safely adding `maxExtractedBytes` and `maxArchiveBytes`, clamped to the safe
87
+ integer maximum. Every native pass receives that same cap and charges headers,
88
+ metadata, bodies, padding, EOF blocks, and trailing zeros. It rejects overflow
89
+ with `archive-decoded-size-exceeds-limit`; no ratio policy is implied.
90
+ Extraction and entry reads drain the metered reader through physical EOF after
91
+ TAR iteration. Trailing framing or decoded-limit failures propagate before
92
+ directory modes are finalized, staging is published, or selected bytes return.
93
+ Native reads stop at framing boundaries so a rejected header does not request
94
+ its body from the decoder; codec buffering can still read ahead internally.
95
+ Inspection finishes the complete bounded framing pass before parsing. Directory
96
+ and link bodies, missing two-block EOF, and nonzero trailers reject on both
97
+ backends, as detailed in [raw TAR framing](archive.md#raw-tar-framing). Raw and
98
+ padded sizes above JavaScript's safe-integer maximum reject as invalid framing
99
+ before applying member budgets, including when local PAX overrides the size.
73
100
 
74
101
  ## Publication and hashing
75
102
 
package/docs/output.md CHANGED
@@ -48,6 +48,8 @@ The requested `path` must name a file. Missing destination parents are created
48
48
  by the helper because the operation is "produce this output file under the
49
49
  root"; callers should choose the filename before calling this API.
50
50
 
51
+ `maxBytes` must be a non-negative safe integer or positive `Infinity`; zero is an active cap and `Infinity` disables it. Invalid values reject before the producer or filesystem staging runs.
52
+
51
53
  Use `maxBytes` when the external producer can create arbitrarily large files,
52
54
  and `mode` when the finalized file needs a specific POSIX mode. Both staging
53
55
  modes enforce them after the producer returns and before committing the target.
@@ -80,6 +82,14 @@ rename. If an error leaves the sibling temp in place and immediate cleanup
80
82
  fails, its verified identity remains registered for a best-effort process-exit
81
83
  cleanup retry.
82
84
 
85
+ Sibling staging shares the [callback sibling owner](temp.md#sibling-temp-writes):
86
+ it checks exact pre-open, descriptor, and current-path identities, retains the
87
+ descriptor through publication, and never chmods or reads a replacement by path.
88
+ Cleanup preserves unverified paths, including partial output when the callback
89
+ throws before admission. Native-off and Windows operation remain supported with
90
+ the platform limits and non-atomic rename/unlink identity checks described there.
91
+ When `mode` is omitted, output-sibling staging preserves the producer's mode.
92
+
83
93
  ## Why not pass the final path to the library?
84
94
 
85
95
  If a target parent can be swapped after validation, handing an external library
@@ -69,9 +69,12 @@ The file-lock diagnostics surface includes `FileLockHeldEntry`,
69
69
  manager controls; production code should not use them as lock recovery.
70
70
 
71
71
  Standalone walkers use the `WalkEntryKind` and `WalkSymlinkPolicy` unions.
72
- Private workspaces expose `TempPathIdentityReceipt` and the
73
- `TempWorkspaceCleanupResult` union so callers can distinguish removal,
74
- absence, and identity mismatch.
72
+ Private workspaces expose `TempPathIdentityReceipt`,
73
+ `TempWorkspaceCleanupResult`, and `TempWorkspaceCleanupSafety` so callers can
74
+ distinguish outcomes and select compatible or required bounded-tree cleanup.
75
+ Strict workspace creation requires native no-replace quarantine plus retained
76
+ parent/workspace descriptors; see the
77
+ [creation and cleanup contract](temp.md#private-temp-workspaces).
75
78
 
76
79
  ## Atomic replacement and durability
77
80
 
@@ -145,7 +145,7 @@ await withTempWorkspace({ rootDir: "/srv/jobs/tmp", prefix: "build-" }, async (w
145
145
  });
146
146
  ```
147
147
 
148
- The directory is mode `0700`, sits under a per-user secure temp root, and is removed when the callback returns or throws. See [Temp workspaces](temp.md).
148
+ The directory is mode `0700` under the caller-provided root, and cleanup runs when the callback returns or throws. Compatible cleanup remains available without native support; pass `cleanupSafety: "require-bounded"` to require native no-replace quarantine and descriptor-bounded tree removal before creating a child. Cleanup preserves raced or ambiguous entries under a private quarantine name. See the [temp workspace compatibility and security contract](temp.md#private-temp-workspaces).
149
149
 
150
150
  ## Where to next
151
151
 
package/docs/reading.md CHANGED
@@ -89,7 +89,7 @@ type RootReadOptions = {
89
89
  };
90
90
  ```
91
91
 
92
- `maxBytes` is enforced eagerly: the library reads up to `maxBytes + 1` and throws `too-large` if there is more, so a hostile target cannot silently exhaust memory.
92
+ `maxBytes` is enforced eagerly: the library reads up to `maxBytes + 1` and throws `too-large` if there is more, so a hostile target cannot silently exhaust memory. Values must be non-negative safe integers or positive `Infinity`; zero is an active cap, while `Infinity` disables it. Explicitly forwarding `undefined` preserves the Root default.
93
93
 
94
94
  `nonBlockingRead` remains as a compatibility hint. Safe reads always add the platform's nonblocking open flag where available so a raced FIFO cannot pin a worker indefinitely; regular-file descriptor reads retain normal Node behavior.
95
95
 
@@ -64,7 +64,11 @@ processLog(result.buffer);
64
64
  ```
65
65
 
66
66
  The result is `{ buffer, stat }`. Missing files preserve the normal `ENOENT`
67
- shape; non-regular targets throw.
67
+ shape; non-regular targets throw. Reads compare exact bigint identities from the
68
+ preview, opened descriptor, and current path before consuming bytes; the returned
69
+ `stat` remains numeric Node `Stats`. Unknown Windows identities receive one
70
+ re-inspection without reopening; persistent unknowns or replacements throw
71
+ `FsSafeError("path-mismatch")`.
68
72
 
69
73
  Throws `FsSafeError` with code `too-large` if the file exceeds `maxBytes`. Other I/O errors propagate as `NodeJS.ErrnoException`.
70
74
 
@@ -101,7 +105,10 @@ type AppendRegularFileOptions = {
101
105
  };
102
106
  ```
103
107
 
104
- The helper refuses symlink and hardlinked final targets. With
108
+ The helper refuses symlink and hardlinked final targets. Preview, opened
109
+ descriptor, and current pathname identities remain exact bigints through the
110
+ append boundary; rounded-equal replacements and persistent unknown Windows
111
+ identities reject before chmod or writing bytes. With
105
112
  `rejectSymlinkParents: true`, it also rejects symlinked ancestor directories.
106
113
 
107
114
  ### `appendRegularFileSync(options)`
package/docs/root.md CHANGED
@@ -38,6 +38,8 @@ type DenyMutationPolicy = {
38
38
 
39
39
  `defaults` apply to every method on the returned handle. Per-call options on individual methods override the defaults for that call only, except `denyMutations`: root and per-call deny entries are merged so a call cannot clear a root-level deny.
40
40
 
41
+ Every `maxBytes` value must be a non-negative safe integer or positive `Infinity`. Zero is an active zero-byte cap; `Infinity` explicitly disables the cap. An omitted or explicitly `undefined` per-call value preserves the configured Root default instead of clearing it.
42
+
41
43
  ## The `Root` interface
42
44
 
43
45
  Every method on the returned handle accepts paths relative to the root and rejects anything that would escape it.