@openclaw/fs-safe 0.5.6 → 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 (289) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +23 -10
  3. package/dist/advanced.d.ts +2 -1
  4. package/dist/advanced.d.ts.map +1 -1
  5. package/dist/advanced.js +1 -0
  6. package/dist/archive-crc32.d.ts +2 -0
  7. package/dist/archive-crc32.d.ts.map +1 -0
  8. package/dist/archive-crc32.js +14 -0
  9. package/dist/archive-deadline.d.ts +3 -0
  10. package/dist/archive-deadline.d.ts.map +1 -1
  11. package/dist/archive-deadline.js +44 -8
  12. package/dist/archive-entry.d.ts.map +1 -1
  13. package/dist/archive-entry.js +1 -0
  14. package/dist/archive-errors.d.ts +1 -0
  15. package/dist/archive-errors.d.ts.map +1 -1
  16. package/dist/archive-errors.js +3 -0
  17. package/dist/archive-input.d.ts.map +1 -1
  18. package/dist/archive-input.js +26 -16
  19. package/dist/archive-kind.js +2 -2
  20. package/dist/archive-limits.d.ts +11 -3
  21. package/dist/archive-limits.d.ts.map +1 -1
  22. package/dist/archive-limits.js +24 -0
  23. package/dist/archive-native.d.ts +3 -2
  24. package/dist/archive-native.d.ts.map +1 -1
  25. package/dist/archive-native.js +31 -9
  26. package/dist/archive-policy.d.ts +2 -0
  27. package/dist/archive-policy.d.ts.map +1 -1
  28. package/dist/archive-policy.js +9 -1
  29. package/dist/archive-read.d.ts.map +1 -1
  30. package/dist/archive-read.js +88 -41
  31. package/dist/archive-staging.d.ts +3 -0
  32. package/dist/archive-staging.d.ts.map +1 -1
  33. package/dist/archive-staging.js +91 -43
  34. package/dist/archive-tar-admission.d.ts +7 -0
  35. package/dist/archive-tar-admission.d.ts.map +1 -0
  36. package/dist/archive-tar-admission.js +43 -0
  37. package/dist/archive-tar-gnu.d.ts +2 -0
  38. package/dist/archive-tar-gnu.d.ts.map +1 -0
  39. package/dist/archive-tar-gnu.js +20 -0
  40. package/dist/archive-tar-header.d.ts +8 -0
  41. package/dist/archive-tar-header.d.ts.map +1 -0
  42. package/dist/archive-tar-header.js +47 -0
  43. package/dist/archive-tar-meta.d.ts +29 -1
  44. package/dist/archive-tar-meta.d.ts.map +1 -1
  45. package/dist/archive-tar-meta.js +135 -19
  46. package/dist/archive-tar-pax.d.ts +8 -0
  47. package/dist/archive-tar-pax.d.ts.map +1 -0
  48. package/dist/archive-tar-pax.js +100 -0
  49. package/dist/archive-tar-runtime.d.ts +4 -0
  50. package/dist/archive-tar-runtime.d.ts.map +1 -1
  51. package/dist/archive-tar-runtime.js +3 -0
  52. package/dist/archive-tar.d.ts.map +1 -1
  53. package/dist/archive-tar.js +8 -2
  54. package/dist/archive-zip-admission.d.ts +7 -0
  55. package/dist/archive-zip-admission.d.ts.map +1 -0
  56. package/dist/archive-zip-admission.js +60 -0
  57. package/dist/archive-zip-count.d.ts +2 -0
  58. package/dist/archive-zip-count.d.ts.map +1 -0
  59. package/dist/archive-zip-count.js +140 -0
  60. package/dist/archive-zip-directory.d.ts +8 -0
  61. package/dist/archive-zip-directory.d.ts.map +1 -0
  62. package/dist/archive-zip-directory.js +224 -0
  63. package/dist/archive-zip-integrity.d.ts.map +1 -1
  64. package/dist/archive-zip-integrity.js +1 -14
  65. package/dist/archive-zip-names.d.ts +12 -0
  66. package/dist/archive-zip-names.d.ts.map +1 -0
  67. package/dist/archive-zip-names.js +99 -0
  68. package/dist/archive-zip-preflight.d.ts +1 -1
  69. package/dist/archive-zip-preflight.d.ts.map +1 -1
  70. package/dist/archive-zip-preflight.js +8 -145
  71. package/dist/archive.d.ts.map +1 -1
  72. package/dist/archive.js +63 -42
  73. package/dist/atomic.d.ts +1 -1
  74. package/dist/atomic.d.ts.map +1 -1
  75. package/dist/bounded-read-stream.d.ts.map +1 -1
  76. package/dist/bounded-read-stream.js +2 -4
  77. package/dist/bounded-read.d.ts.map +1 -1
  78. package/dist/bounded-read.js +3 -10
  79. package/dist/byte-budget.d.ts +5 -0
  80. package/dist/byte-budget.d.ts.map +1 -0
  81. package/dist/byte-budget.js +9 -0
  82. package/dist/file-hash.d.ts.map +1 -1
  83. package/dist/file-hash.js +25 -19
  84. package/dist/file-lock-sync.d.ts.map +1 -1
  85. package/dist/file-lock-sync.js +26 -9
  86. package/dist/file-store-boundary.d.ts.map +1 -1
  87. package/dist/file-store-boundary.js +9 -5
  88. package/dist/file-store-limit.d.ts +2 -0
  89. package/dist/file-store-limit.d.ts.map +1 -0
  90. package/dist/file-store-limit.js +8 -0
  91. package/dist/file-store-sync-write.d.ts.map +1 -1
  92. package/dist/file-store-sync-write.js +38 -8
  93. package/dist/file-store.d.ts.map +1 -1
  94. package/dist/file-store.js +26 -26
  95. package/dist/json-durable-queue-directory.d.ts +2 -0
  96. package/dist/json-durable-queue-directory.d.ts.map +1 -0
  97. package/dist/json-durable-queue-directory.js +20 -0
  98. package/dist/json-durable-queue-ownership.d.ts +14 -0
  99. package/dist/json-durable-queue-ownership.d.ts.map +1 -0
  100. package/dist/json-durable-queue-ownership.js +168 -0
  101. package/dist/json-durable-queue-retirement.d.ts +9 -0
  102. package/dist/json-durable-queue-retirement.d.ts.map +1 -0
  103. package/dist/json-durable-queue-retirement.js +126 -0
  104. package/dist/json-durable-queue-transfer-lock.d.ts +2 -0
  105. package/dist/json-durable-queue-transfer-lock.d.ts.map +1 -0
  106. package/dist/json-durable-queue-transfer-lock.js +19 -0
  107. package/dist/json-durable-queue.d.ts +1 -0
  108. package/dist/json-durable-queue.d.ts.map +1 -1
  109. package/dist/json-durable-queue.js +90 -57
  110. package/dist/json.d.ts.map +1 -1
  111. package/dist/json.js +27 -8
  112. package/dist/local-roots.d.ts.map +1 -1
  113. package/dist/local-roots.js +4 -2
  114. package/dist/native-binding.d.ts +15 -3
  115. package/dist/native-binding.d.ts.map +1 -1
  116. package/dist/native-operations.d.ts +4 -1
  117. package/dist/native-operations.d.ts.map +1 -1
  118. package/dist/native-operations.js +22 -6
  119. package/dist/native-pinned-write-windows.d.ts +8 -0
  120. package/dist/native-pinned-write-windows.d.ts.map +1 -0
  121. package/dist/native-pinned-write-windows.js +92 -0
  122. package/dist/native-pinned-write.d.ts.map +1 -1
  123. package/dist/native-pinned-write.js +136 -127
  124. package/dist/native-staged-file.d.ts +24 -0
  125. package/dist/native-staged-file.d.ts.map +1 -0
  126. package/dist/native-staged-file.js +337 -0
  127. package/dist/native.d.ts.map +1 -1
  128. package/dist/native.js +4 -4
  129. package/dist/opened-realpath.d.ts +2 -0
  130. package/dist/opened-realpath.d.ts.map +1 -1
  131. package/dist/opened-realpath.js +12 -7
  132. package/dist/output-sibling.d.ts.map +1 -1
  133. package/dist/output-sibling.js +11 -110
  134. package/dist/output.d.ts.map +1 -1
  135. package/dist/output.js +4 -2
  136. package/dist/owner-dacl.d.ts.map +1 -1
  137. package/dist/owner-dacl.js +2 -1
  138. package/dist/permission-exec.d.ts +19 -0
  139. package/dist/permission-exec.d.ts.map +1 -1
  140. package/dist/permission-exec.js +57 -11
  141. package/dist/permissions-public.d.ts +1 -1
  142. package/dist/permissions-public.d.ts.map +1 -1
  143. package/dist/permissions-windows.d.ts +3 -0
  144. package/dist/permissions-windows.d.ts.map +1 -1
  145. package/dist/permissions-windows.js +16 -5
  146. package/dist/permissions.d.ts +5 -0
  147. package/dist/permissions.d.ts.map +1 -1
  148. package/dist/pinned-open.d.ts.map +1 -1
  149. package/dist/pinned-open.js +27 -48
  150. package/dist/pinned-write.d.ts +6 -0
  151. package/dist/pinned-write.d.ts.map +1 -1
  152. package/dist/pinned-write.js +26 -37
  153. package/dist/private-directory.d.ts.map +1 -1
  154. package/dist/private-directory.js +3 -2
  155. package/dist/private-temp-workspace.d.ts +3 -1
  156. package/dist/private-temp-workspace.d.ts.map +1 -1
  157. package/dist/private-temp-workspace.js +81 -56
  158. package/dist/publish-file.d.ts.map +1 -1
  159. package/dist/publish-file.js +2 -4
  160. package/dist/read-opened-file.d.ts.map +1 -1
  161. package/dist/read-opened-file.js +6 -4
  162. package/dist/regular-file.d.ts.map +1 -1
  163. package/dist/regular-file.js +136 -82
  164. package/dist/replace-file-copy-fallback.d.ts +3 -1
  165. package/dist/replace-file-copy-fallback.d.ts.map +1 -1
  166. package/dist/replace-file-copy-fallback.js +21 -36
  167. package/dist/replace-file-copy-source.d.ts +21 -0
  168. package/dist/replace-file-copy-source.d.ts.map +1 -0
  169. package/dist/replace-file-copy-source.js +112 -0
  170. package/dist/replace-file-descriptor.d.ts +13 -3
  171. package/dist/replace-file-descriptor.d.ts.map +1 -1
  172. package/dist/replace-file-descriptor.js +32 -7
  173. package/dist/replace-file-rename-policy.d.ts +7 -0
  174. package/dist/replace-file-rename-policy.d.ts.map +1 -0
  175. package/dist/replace-file-rename-policy.js +30 -0
  176. package/dist/replace-file-temp-owner.d.ts +46 -0
  177. package/dist/replace-file-temp-owner.d.ts.map +1 -0
  178. package/dist/replace-file-temp-owner.js +346 -0
  179. package/dist/replace-file.d.ts +6 -1
  180. package/dist/replace-file.d.ts.map +1 -1
  181. package/dist/replace-file.js +72 -58
  182. package/dist/root-impl.d.ts.map +1 -1
  183. package/dist/root-impl.js +118 -98
  184. package/dist/root-paths.d.ts +11 -14
  185. package/dist/root-paths.d.ts.map +1 -1
  186. package/dist/root-paths.js +36 -27
  187. package/dist/root-write-verification.d.ts +11 -0
  188. package/dist/root-write-verification.d.ts.map +1 -0
  189. package/dist/root-write-verification.js +91 -0
  190. package/dist/secret-file.d.ts +1 -6
  191. package/dist/secret-file.d.ts.map +1 -1
  192. package/dist/secret-file.js +49 -120
  193. package/dist/secret-read-async.d.ts +1 -1
  194. package/dist/secret-read-async.d.ts.map +1 -1
  195. package/dist/secret-read-async.js +51 -72
  196. package/dist/secret-read-policy.d.ts +13 -0
  197. package/dist/secret-read-policy.d.ts.map +1 -0
  198. package/dist/secret-read-policy.js +28 -0
  199. package/dist/secret.d.ts +2 -1
  200. package/dist/secret.d.ts.map +1 -1
  201. package/dist/secret.js +2 -1
  202. package/dist/secure-file.d.ts.map +1 -1
  203. package/dist/secure-file.js +42 -29
  204. package/dist/sibling-staged-file.d.ts +15 -0
  205. package/dist/sibling-staged-file.d.ts.map +1 -0
  206. package/dist/sibling-staged-file.js +148 -0
  207. package/dist/sibling-temp.d.ts +3 -0
  208. package/dist/sibling-temp.d.ts.map +1 -1
  209. package/dist/sibling-temp.js +30 -74
  210. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  211. package/dist/sidecar-lock-acquire.js +48 -27
  212. package/dist/sidecar-lock-handle.d.ts +6 -2
  213. package/dist/sidecar-lock-handle.d.ts.map +1 -1
  214. package/dist/sidecar-lock-handle.js +17 -3
  215. package/dist/sidecar-lock-policy.d.ts +2 -0
  216. package/dist/sidecar-lock-policy.d.ts.map +1 -1
  217. package/dist/sidecar-lock-policy.js +29 -0
  218. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  219. package/dist/sidecar-lock-reclaim.js +29 -7
  220. package/dist/sidecar-lock.d.ts.map +1 -1
  221. package/dist/sidecar-lock.js +28 -16
  222. package/dist/staged-directory.d.ts +16 -0
  223. package/dist/staged-directory.d.ts.map +1 -0
  224. package/dist/staged-directory.js +60 -0
  225. package/dist/staged-file-types.d.ts +56 -0
  226. package/dist/staged-file-types.d.ts.map +1 -0
  227. package/dist/staged-file-types.js +1 -0
  228. package/dist/staged-file.d.ts +10 -0
  229. package/dist/staged-file.d.ts.map +1 -0
  230. package/dist/staged-file.js +15 -0
  231. package/dist/strict-file-identity.d.ts +6 -0
  232. package/dist/strict-file-identity.d.ts.map +1 -0
  233. package/dist/strict-file-identity.js +48 -0
  234. package/dist/suppressed-error.d.ts +6 -0
  235. package/dist/suppressed-error.d.ts.map +1 -0
  236. package/dist/suppressed-error.js +15 -0
  237. package/dist/temp-cleanup.d.ts +2 -0
  238. package/dist/temp-cleanup.d.ts.map +1 -1
  239. package/dist/temp-cleanup.js +25 -10
  240. package/dist/temp-workspace-owner.d.ts +23 -0
  241. package/dist/temp-workspace-owner.d.ts.map +1 -0
  242. package/dist/temp-workspace-owner.js +320 -0
  243. package/dist/temp.d.ts +1 -1
  244. package/dist/temp.d.ts.map +1 -1
  245. package/dist/test-hooks.d.ts +5 -0
  246. package/dist/test-hooks.d.ts.map +1 -1
  247. package/dist/windows-owner.d.ts +3 -0
  248. package/dist/windows-owner.d.ts.map +1 -1
  249. package/dist/windows-owner.js +10 -2
  250. package/docs/advanced.md +19 -2
  251. package/docs/archive.md +250 -35
  252. package/docs/atomic.md +11 -2
  253. package/docs/config.md +7 -0
  254. package/docs/contributing.md +45 -7
  255. package/docs/durability.md +18 -5
  256. package/docs/errors.md +16 -1
  257. package/docs/file-store.md +2 -0
  258. package/docs/index.md +3 -1
  259. package/docs/install.md +20 -8
  260. package/docs/json.md +8 -4
  261. package/docs/migrating-to-0.5.md +7 -7
  262. package/docs/migrating-to-0.6.md +43 -0
  263. package/docs/native-helper.md +30 -8
  264. package/docs/native.md +73 -17
  265. package/docs/output.md +10 -0
  266. package/docs/path-scope.md +28 -2
  267. package/docs/permissions.md +13 -2
  268. package/docs/public-api.md +6 -3
  269. package/docs/quickstart.md +1 -1
  270. package/docs/reading.md +1 -1
  271. package/docs/regular-file.md +9 -2
  272. package/docs/root.md +3 -1
  273. package/docs/secret-file.md +12 -0
  274. package/docs/secure-file.md +21 -3
  275. package/docs/security-model.md +33 -1
  276. package/docs/sidecar-lock.md +15 -1
  277. package/docs/staged-file.md +178 -0
  278. package/docs/store.md +13 -0
  279. package/docs/temp.md +128 -14
  280. package/docs/testing.md +1 -1
  281. package/docs/writing.md +19 -0
  282. package/package.json +16 -9
  283. package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
  284. package/dist/native/darwin-x64/fs-safe-native.node +0 -0
  285. package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
  286. package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
  287. package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
  288. package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
  289. package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
@@ -18,8 +18,8 @@ const { buffer, realPath, permissions } = await readSecureFile({
18
18
  The helper:
19
19
 
20
20
  - requires a local absolute path and rejects UNC/network paths by default
21
- - rejects directories and, by default, symlink paths
22
- - opens the file before reading and verifies the opened fd still matches the path and realpath
21
+ - rejects every non-regular preview and, by default, symlink paths
22
+ - opens POSIX paths no-follow and nonblocking before reading, then verifies the opened fd still matches the path and realpath; a FIFO swap cannot block before `timeoutMs` owns the byte read
23
23
  - optionally requires the real path to live under one of `trust.trustedDirs`
24
24
  - rejects hard-to-verify or unsafe permissions unless `permissions.allowInsecure` is set
25
25
  - rejects files owned by another POSIX uid
@@ -28,6 +28,8 @@ The helper:
28
28
 
29
29
  On POSIX, unsafe permissions mean group/world writable, and group/world readable unless `permissions.allowReadableByOthers` is true. On Windows, the helper uses the ACL inspection helpers from [`permissions`](permissions.md) and refuses the read if ACLs cannot be verified.
30
30
 
31
+ Descriptor, pathname, and realpath identity checks use lossless bigint stats internally. The returned `stat` remains a normal Node `Stats` object with numeric fields. A zero Windows device or inode is unverified, never a match: the helper re-inspects that identity once using the same descriptor or pathname, then rejects persistent ambiguity with `path-mismatch`. A definite mismatch rejects immediately; retries retain known identity components and still enforce symlink policy.
32
+
31
33
  ## Options
32
34
 
33
35
  ```ts
@@ -55,8 +57,12 @@ type SecureFileReadOptions = {
55
57
  };
56
58
  ```
57
59
 
60
+ `io.maxBytes` must be a non-negative safe integer or positive `Infinity`; zero is an active cap and `Infinity` disables the cap. Invalid limits reject before filesystem admission.
61
+
58
62
  `permissions.allowInsecure` is a migration escape hatch. Prefer fixing permissions and using [`formatPermissionRemediation`](permissions.md) to show the user what to run. `trust.allowNetworkPath` is off by default because UNC paths are remote authority, not local filesystem input. `inject` is for tests and platform adapters; production callers usually leave it unset.
59
63
 
64
+ `permissions.allowInsecure` bypasses only permission checks. Neither it nor `inject.platform` changes filesystem identity verification, which always uses the actual process platform. `trust.allowSymlink` permits an alias but still requires its target and realpath to match the opened descriptor.
65
+
60
66
  ## Errors
61
67
 
62
68
  `readSecureFile()` throws `FsSafeError` with codes such as:
@@ -67,7 +73,7 @@ type SecureFileReadOptions = {
67
73
  | `not-found` | The path could not be stat'd before open. |
68
74
  | `not-file` | The opened target is not a regular file. |
69
75
  | `symlink` | The path is a symlink and `trust.allowSymlink` is false. |
70
- | `path-mismatch` | The path or realpath changed between open and verification. |
76
+ | `path-mismatch` | The path or realpath changed between open and verification, or filesystem identity could not be verified after bounded re-inspection. |
71
77
  | `outside-workspace` | `realPath` is outside `trust.trustedDirs`. |
72
78
  | `permission-unverified` | Required mode/ACL checks could not be completed. |
73
79
  | `insecure-permissions` | Mode bits or ACLs grant broader access than allowed. |
@@ -75,6 +81,18 @@ type SecureFileReadOptions = {
75
81
  | `too-large` | File size or bytes read exceeded `maxBytes`. |
76
82
  | `timeout` | `timeoutMs` elapsed while reading. |
77
83
 
84
+ Windows inspection failures remain operational `permission-unverified` errors
85
+ and still refuse the read. Their message includes the underlying reason when
86
+ available. `details` includes `ownerError` for owner-query failures and, when
87
+ command diagnostics are available, `command`, `durationMs`, `timedOut`,
88
+ `exitCode`, `signal`, and `stderr`. Reasons and stderr are control-character
89
+ escaped and limited to 400 characters each (including a truncation marker).
90
+ No stdout or target file contents are copied into these display diagnostics.
91
+ The original inspection exception is retained as `cause`; built-in command
92
+ errors also retain their original execFile exception in the cause chain.
93
+ Treat causes as restricted local diagnostic data. No retries are performed,
94
+ and verification order and rejection conditions are unchanged.
95
+
78
96
  ## See also
79
97
 
80
98
  - [Permissions](permissions.md) — standalone POSIX mode and Windows ACL checks.
@@ -49,10 +49,19 @@ Every path is resolved against the canonicalized real path of the root, then che
49
49
 
50
50
  ### Symlinks (read side)
51
51
 
52
- `open()` and `read()` use `fs.open` with `O_NOFOLLOW` on POSIX where available. The library then `fstat`s the open fd and `realpath`s the original input, asserting the two refer to the same inode (`sameFileIdentity`). A symlink swap that happens between resolve and open will fail at the identity check rather than silently following the new target.
52
+ `open()` and `read()` use `fs.open` with `O_NOFOLLOW` on POSIX where available. The library then `fstat`s the open fd and `realpath`s the original input, asserting the two refer to the same inode. A symlink swap that happens between resolve and open will fail at the identity check rather than silently following the new target.
53
53
 
54
54
  Per-call `symlinks: "follow-within-root"` allows symlinks whose final target is still inside the root. The default is `"reject"`.
55
55
 
56
+ Guarded root reads compare lossless bigint identities from before open, the opened
57
+ descriptor, the input path, and the canonical target; numeric public `Stats`
58
+ receipts are not used as identity evidence. Unknown Windows device/inode values
59
+ receive one re-inspection without reopening the file. A definite mismatch or
60
+ persistent unknown identity rejects with `path-mismatch` before reading bytes.
61
+ Regular-file readers, root-file adapters, and archive input staging use the same
62
+ exact admission policy. `copyIn()` retains the admitted source identity for its
63
+ checks before and after copying, independently of its numeric metadata receipt.
64
+
56
65
  ### Symlinks (write side)
57
66
 
58
67
  With the native binding loaded, `write()`, `create()`, and `copyIn()` use a
@@ -70,6 +79,14 @@ parent symlink. A same-privilege peer with write access to the parent can
70
79
  therefore cause an out-of-root side effect before the operation throws. Use
71
80
  native `require` mode when concurrent hostile mutation is in scope.
72
81
 
82
+ The separate [retained-directory staging lifecycle](staged-file.md) keeps abort
83
+ cleanup anchored to the original directory after a parent or ancestor move.
84
+ It preserves observed substituted temporary entries and never cleans a recorded
85
+ publication. Directory anchoring is not expected-inode/CAS replacement, and
86
+ identity-check-then-unlink is not atomic conditional unlink. The guarantee
87
+ requires the temporary name to remain owned and removal to remain permitted;
88
+ application authorization and cooperative coordination remain necessary.
89
+
73
90
  ### Hardlink aliasing
74
91
 
75
92
  When `hardlinks: "reject"` is set, reads stat the target and refuse if `nlink > 1`, on the conservative assumption that a hardlinked file might alias an out-of-tree inode. This is defense-in-depth: the link count check is best-effort and platform-dependent. Treat it as a tripwire, not authorization.
@@ -107,6 +124,21 @@ durability guarantee.
107
124
 
108
125
  `extractArchive` first stages into a private temp directory (mode 0700) outside the destination, validates each entry path against `..` and absolute prefixes, refuses link-type entries by default, enforces entry count and byte budgets, and only then merges the staged tree into the destination through the same boundary checks used by direct writes.
109
126
 
127
+ TAR extraction and bounded reads validate the complete decompressed raw framing
128
+ before parser normalization: directory/link sizes must be zero, EOF must contain
129
+ two consecutive zero blocks, and only zero padding may follow EOF. This rejects
130
+ hidden headers and body/header smuggling consistently across the JavaScript and
131
+ native backends. The meter checks logical member count before consuming bodies,
132
+ even for unrequested or filtered members. Per-entry/cumulative payload budgets
133
+ apply only to entries accepted after strip/filter policy, outside the raw meter.
134
+ A separate absolute decoded cap includes every member body, headers, metadata,
135
+ all padding, and EOF, closing cumulative-metadata and zero-tail bypasses. Native
136
+ extraction/read passes drain through physical EOF before publication or
137
+ returning selected bytes, even when the TAR parser stops earlier. Bounded
138
+ reads use default archive admission limits and keep `maxBytes` scoped to the
139
+ requested output. Metadata retains its per-record bound. See [archive framing](archive.md#raw-tar-framing) for supported
140
+ metadata and compression formats.
141
+
110
142
  ## What "library-level" means
111
143
 
112
144
  A library cannot revoke its own caller's authority. If your code chooses to bypass `fs-safe` and call `fs.writeFile` directly with the same path, you bypass the defenses too. The contract `fs-safe` enforces is: *every filesystem operation that touches caller-controlled input goes through the boundary*. That contract is yours to keep.
@@ -27,6 +27,8 @@ Each new sidecar also carries an internal random ownership token encoded as JSON
27
27
 
28
28
  The raw sidecar bytes are not a canonical JSON representation: tools that trim or rewrite the trailing whitespace invalidate the ownership token, so release leaves the changed sidecar in place and fails closed. The token distinguishes cooperating acquisitions; it is not a secret and does not make pathname compare-and-remove atomic against a hostile process that can replace files outside the lock protocol.
29
29
 
30
+ `release()` propagates an I/O failure that prevents deletion of an unchanged, owned sidecar; it never reports successful cleanup while leaving that lock behind. The handle and manager retain the exact cleanup receipt after a failure, so the same handle can retry `release()` and `manager.drain()` can retry retained cleanup. A changed sidecar remains an ownership mismatch rather than a deletion failure and is left untouched. If both a `withFileLock()` callback and release fail, the release error is the primary `SuppressedError.error` and the callback failure remains available as `SuppressedError.suppressed`. Failed acquisition cleanup uses the same shape, with the cleanup error primary and the acquisition failure suppressed. On Node runtimes without the global `SuppressedError` constructor, fs-safe returns the equivalent `Error` shape with the same name and properties.
31
+
30
32
  ## API
31
33
 
32
34
  ```ts
@@ -92,6 +94,7 @@ type FileLockRetryOptions = {
92
94
  ```
93
95
 
94
96
  `payload` is a function so you can re-evaluate it on each retry (e.g. timestamp, PID).
97
+ Retry counts must be non-negative safe integers. Retry factors and delays must be finite and non-negative, and when both delay bounds are provided `minTimeout` cannot exceed `maxTimeout`. `timeoutMs` accepts a finite non-negative deadline or positive infinity for an unbounded wait; invalid numeric values reject before filesystem acquisition starts.
95
98
  `parsePayload` replaces JSON parsing for legacy or custom sidecars. Its `unknown`
96
99
  result is passed to `shouldReclaim` and `shouldRemoveStaleLock`, allowing PID,
97
100
  process-start, argv, or role schemas to remain application-owned.
@@ -176,7 +179,18 @@ detection, not revocation of work already in progress.
176
179
  retry, payload parsing, stale policy, owner-scoped reentrancy, guarded
177
180
  identity-conditioned reclaim, verification, and compromise monitoring. They do
178
181
  not use the async manager queue or support async callbacks. Retry waits block
179
- the calling thread; use the async API in request-serving code.
182
+ the calling thread; use the async API in request-serving code. The sync
183
+ compromise interval treats a thrown verification I/O error as a lost lock and
184
+ invokes `onCompromised` once, matching the asynchronous `.catch(() => false)`
185
+ contract. An explicit `verifyStillHeld()` call still propagates that I/O error.
186
+
187
+ Both synchronous helpers consume the [process-wide lock defaults](config.md#configurefssafelocks-config).
188
+ A synchronous retry sleep is clamped to the remaining finite deadline, so a long or jittered backoff cannot extend the configured timeout or block forever.
189
+ Per-call options take precedence, including zero values; a per-call `retry`
190
+ object replaces the entire configured retry object. A configured
191
+ `staleRecovery: "remove-if-unchanged"` still needs per-call
192
+ `shouldRemoveStaleLock` approval, and per-call `staleRecovery: "fail-closed"`
193
+ disables recovery even when the process configuration opts in.
180
194
 
181
195
  Always release in a `finally`:
182
196
 
@@ -0,0 +1,178 @@
1
+ ---
2
+ title: Retained-directory staging
3
+ description: "Prepare and publish one file while retaining its original directory for abort cleanup."
4
+ ---
5
+
6
+ # Retained-directory staging
7
+
8
+ `stageFileInDirectory()` from `@openclaw/fs-safe/advanced` prepares one file in
9
+ an existing directory and keeps that directory open until cleanup or disposal.
10
+ It requires the native binding on Linux or macOS, even in native mode `auto`.
11
+ Native off, an unavailable binding, or a binding without the required mechanism
12
+ fails before file creation. Windows is explicitly unsupported for this API.
13
+ Existing root, output, temp, lock, and durability APIs retain their documented
14
+ platform and fallback contracts.
15
+
16
+ ```ts
17
+ import type { DirectoryReceipt } from "@openclaw/fs-safe/durability";
18
+ import {
19
+ stageFileInDirectory,
20
+ type PublishedFileReceipt,
21
+ } from "@openclaw/fs-safe/advanced";
22
+
23
+ // Call while holding the application's cooperative writer lock. The caller
24
+ // supplies its authorization, manager, snapshot, and fingerprint checks.
25
+ export async function publishReviewedUpdate(options: {
26
+ directory: DirectoryReceipt;
27
+ basename: string;
28
+ bytes: Uint8Array;
29
+ assertAuthorizedAndCurrent(): Promise<void>;
30
+ }): Promise<PublishedFileReceipt> {
31
+ await options.assertAuthorizedAndCurrent();
32
+ await using staged = await stageFileInDirectory({
33
+ directory: options.directory,
34
+ content: options.bytes,
35
+ });
36
+ await options.assertAuthorizedAndCurrent();
37
+ await staged.assertCurrent();
38
+ return await staged.publish(options.basename, { overwrite: true });
39
+ }
40
+ ```
41
+
42
+ `await using` always disposes, including on exceptions. If both the body and
43
+ disposal fail, JavaScript preserves both errors in a `SuppressedError`. A
44
+ successful return means publication was observed and verified, not that a
45
+ separate application transaction committed. Application rollback and recovery
46
+ remain with the caller.
47
+
48
+ ## API
49
+
50
+ ```ts
51
+ function stageFileInDirectory(options: {
52
+ directory: string | DirectoryReceipt;
53
+ content: string | Uint8Array;
54
+ mode?: number;
55
+ }): Promise<StagedFile>;
56
+
57
+ interface StagedFile extends AsyncDisposable {
58
+ readonly receipt: StagedFileReceipt;
59
+ assertCurrent(): Promise<void>;
60
+ publish(basename: string, options: { overwrite: boolean }): Promise<PublishedFileReceipt>;
61
+ cleanup(): Promise<StagedFileCleanupReceipt>;
62
+ }
63
+ ```
64
+
65
+ Strings are UTF-8. `mode` is the requested **published** mode and defaults to
66
+ `0600`; exact final modes, including `000`, are supported. The unpublished file
67
+ stays at `0600` throughout preparation and any awaited application checks.
68
+ After rename succeeds and the published entry passes identity validation, the
69
+ owner applies the requested mode through its retained file descriptor and
70
+ synchronizes the file. No parent is created or chmodded.
71
+ Creation uses an exclusive, no-follow, close-on-exec open of a generated direct
72
+ child name. Writes use that descriptor. Inspection uses non-following metadata
73
+ operations, never a potentially blocking reopen of the leaf.
74
+
75
+ A supplied directory receipt must still match at admission. Its numeric
76
+ identity must be exactly representable; ambiguous identity fails closed.
77
+ Returned receipts are frozen descriptive snapshots, not mutable authority.
78
+ Changing a supplied receipt after admission cannot retarget the lifecycle.
79
+
80
+ `StagedFileReceipt` contains `directory` (`path`, `realPath`, and exact bigint
81
+ `identity.dev`/`identity.ino`), `temporaryBasename`, and the prepared file's
82
+ `identity` (`dev`, `ino`, `size`, `nlink`, `mtimeNs`, `ctimeNs` as bigint;
83
+ `mode`, `uid`, `gid` as numbers). This is a preparation-time snapshot of the
84
+ private stage: its mode is `0600`. Publication does not refresh this snapshot;
85
+ neither its mode nor its timestamps are a final-file fingerprint. No raw
86
+ descriptors are exposed.
87
+
88
+ `assertCurrent()` verifies the original pathname's directory identity and the
89
+ staged name against the retained file. A failed check does not disable cleanup.
90
+ After successful publication there is no staged name to check, so further
91
+ checks or publication reject. Cleanup closes the lifecycle; later checks and
92
+ publication reject before descriptor use. Rejected publication still carries its
93
+ phase, cause, and recorded publication outcome after closure. Concurrent calls
94
+ are serialized in invocation order, including cleanup and disposal. Repeated
95
+ cleanup returns the recorded outcome, or repeats the recorded error, without
96
+ touching descriptors.
97
+
98
+ ## Publication and failure evidence
99
+
100
+ `publish()` requires an explicit boolean `overwrite` and one direct-child
101
+ basename. Empty, dot, dotdot, separators, absolute paths, NUL, control characters,
102
+ drive-relative spellings, and the stage's own name are rejected.
103
+
104
+ With `overwrite: false`, publication is genuine kernel no-replace rename; a
105
+ collision leaves both names unchanged and raises `FsSafeError("already-exists")`.
106
+ The stage may then be cleaned or published under another name. With
107
+ `overwrite: true`, publication is plain atomic replacement. Neither route
108
+ copies. Both source and destination resolve through the retained original
109
+ parent, with checks immediately before rename and after publication.
110
+
111
+ `PublishedFileReceipt` has `status: "published"`, `staged`, `basename`, and
112
+ `overwrite`. Its `staged` field retains the private preparation snapshot, not
113
+ final metadata. Errors from publication carry typed `StagedFileFailureDetails`
114
+ in `FsSafeError.details`, including `phase` and `publication`. Publication is
115
+ `not-published`, `published` (with its receipt), or `indeterminate` (with the
116
+ attempted basename and overwrite policy) if a rename error cannot establish
117
+ whether it committed. The underlying error remains in `cause`.
118
+
119
+ A parent move after the final pre-check cannot divert the rename to a
120
+ replacement parent. It can publish inside the moved original and then fail
121
+ post-validation. Successful rename is recorded before those checks; cleanup
122
+ never deletes or rolls back a published final name. Indeterminate publication
123
+ also preserves names for caller-directed recovery.
124
+
125
+ The same rule applies if applying the published mode, synchronizing, or a later
126
+ check fails: the error reports `published`, and cleanup preserves the final
127
+ name. The file may still have mode `0600` or may already have the requested
128
+ mode, depending on which operation failed. A `published` failure receipt records
129
+ rename success, not successful permission finalization.
130
+
131
+ ## Cleanup guarantee and limits
132
+
133
+ If the temporary basename still names the object created by this lifecycle and
134
+ filesystem removal remains permitted, moving or replacing the actual parent
135
+ (or an ancestor) does not strand the unpublished temp. Cleanup resolves through
136
+ the retained original directory, even after `assertCurrent()` rejects drift.
137
+ Same-name sentinels in the replacement parent are not touched.
138
+
139
+ `StagedFileCleanupReceipt` records `temporaryBasename`, `publication`,
140
+ `resources` (`closed` or `close-failed`), and `status`:
141
+
142
+ | Status | Meaning |
143
+ |---|---|
144
+ | `removed` | The owned recorded name was unlinked through the retained directory. |
145
+ | `name-absent` | That name was absent in the original directory; this does not prove the inode has no other names. |
146
+ | `preserved` | An observed substitute or an indeterminate publication was left alone. |
147
+ | `failed` | Inspection or removal failed. |
148
+ | `not-needed` | Publication was recorded, or preparation failed before creating a file. |
149
+
150
+ Explicit cleanup returns preservation outcomes. Removal or close failures throw
151
+ an `FsSafeError` with the receipt in `details.cleanup` and underlying errors in
152
+ `cause`. Disposal also throws for preservation, so ignoring its return value
153
+ cannot hide incomplete cleanup. Setup failures preserve the original error;
154
+ when cleanup also fails, an aggregate cause retains both failures. Descriptors
155
+ are closed on every cleanup outcome, with no retry through recycled numbers.
156
+
157
+ This is **directory-relative targeting**, not expected-destination-inode/CAS
158
+ publication. Checking an identity before rename does not make rename CAS.
159
+ Likewise, the identity check followed by `unlinkat` is not an atomic conditional
160
+ unlink: an adversary can replace the leaf in that final syscall gap. Observed
161
+ substitutions are preserved, but this API does not guarantee recovery after
162
+ arbitrary child renames, permission revocation, I/O failure, or process death.
163
+ Keep application authorization, manager checks, snapshots, fingerprints,
164
+ cooperative locks, and conditional rollback.
165
+
166
+ Namespace cleanup is not crash durability. Staging retains the native writer's
167
+ file synchronization behavior, and publication synchronizes its directory
168
+ (with the existing `EPERM` exception); cleanup does not promise a durable unlink.
169
+ No successful receipt promises survival across a crash. See [Directory
170
+ durability](durability.md) when an application needs a separate durability proof.
171
+ `pinDirectory().assertCurrent()` and `.sync()` still require a current pathname;
172
+ they do not gain this cleanup authority.
173
+
174
+ ## Related pages
175
+
176
+ - [Advanced composition](advanced.md)
177
+ - [Native architecture](native.md)
178
+ - [Security model](security-model.md)
package/docs/store.md CHANGED
@@ -68,6 +68,19 @@ Use `ackJsonDurableQueueEntry()` after durable processing succeeds and
68
68
  `moveJsonDurableQueueEntryToFailed()` when the caller wants to quarantine an
69
69
  entry for inspection.
70
70
 
71
+ Loading serializes consumers for one ID through a sidecar lock, then creates `processingPath` with a no-replace hardlink and retires the source through a recoverable sibling record. Whichever producer generation occupies `.json` at the hardlink operation becomes the claim; a replacement published afterward remains pending. After a crash, `.processing` is retried before that pending replacement. Transfer locks are fail-closed and are never reclaimed automatically, so an operator must remove a proven-stale lock after a hard process crash. Acknowledgement atomically moves `.processing` through the short-lived `.delivered` marker, and existing delivered markers are cleaned during batch loading. `ackJsonDurableQueueEntry()` rejects while `.json` exists without a processing claim: callers that previously paired direct `readJsonDurableQueueEntry()` with acknowledgement must load through `loadJsonDurableQueueEntry()` before processing so acknowledgement is generation-bound.
72
+
73
+ Queue and failed directory creation fsyncs every newly-created parent edge from the leaf toward the trusted root. Enqueue and migration writes fsync the temp file and parent; claim, acknowledgement, quarantine, delivered-marker cleanup, and retirement transitions fsync every affected directory and propagate real sync failures. A transition may already be visible when a post-mutation sync fails, so retry the same operation to complete its crash-recovery state; quarantine retries with only failed evidence resync that destination before repairing the vanished queue source.
74
+
75
+ Failed destinations are create-only. Quarantine publishes the claimed file by hardlink, so the queue and failed directories must share a filesystem with hardlink support. If `failed/<id>.json` already exists, quarantine rejects while preserving both that earlier evidence and the current claimed entry instead of overwriting either file. The `read` callback continues to receive the logical `.json` path even though bytes are read and migrations are written through the claimed path.
76
+
77
+ Queue entry reads verify lossless file identities before opening, on the opened
78
+ descriptor, and at the current pathname before reading bytes. POSIX opens are
79
+ nonblocking, so a raced FIFO is rejected rather than stalling a consumer. On Windows, an
80
+ unknown identity gets one bounded reinspection; persistent ambiguity or a
81
+ mismatch rejects with `queue entry changed during read`. Each inspection still
82
+ rejects non-files, symlinks, hardlinks, and entries over the byte limit.
83
+
71
84
  ## Related pages
72
85
 
73
86
  - [`fileStore`](file-store.md) — full API for the multi-file store.
package/docs/temp.md CHANGED
@@ -14,7 +14,7 @@ import {
14
14
 
15
15
  ## Private temp workspaces
16
16
 
17
- A private workspace is a directory created at mode `0o700` under a caller-provided temp root. It is unique per call (random suffix) and cleaned up when you call `cleanup()` or leave an `await using` scope.
17
+ A private workspace is a directory created at mode `0o700` under a caller-provided temp root. It is unique per call (random suffix). Calling `cleanup()` or leaving an `await using` scope moves an unchanged workspace through a private quarantine before removal. Descriptor-bounded cleanup prevents recursive traversal of substitutions; the compatible JavaScript fallback has the narrower race contract documented below.
18
18
 
19
19
  ### `tempWorkspace`
20
20
 
@@ -31,7 +31,7 @@ type TempWorkspace = {
31
31
  writeJson(fileName: string, data: unknown, options?: { trailingNewline?: boolean }): Promise<string>;
32
32
  copyIn(fileName: string, sourcePath: string): Promise<string>;
33
33
  read(fileName: string): Promise<Buffer>;
34
- cleanup(): Promise<"removed" | "missing" | "identity-mismatch">;
34
+ cleanup(): Promise<"removed" | "missing" | "identity-mismatch" | "indeterminate">;
35
35
  [Symbol.asyncDispose](): Promise<void>;
36
36
  };
37
37
  ```
@@ -64,12 +64,66 @@ await state.write({ ready: true });
64
64
  The workspace owns cleanup; the store is only a view over the workspace
65
65
  directory.
66
66
 
67
- The identity receipt is captured when the workspace is created. Manual,
68
- disposal, and process-exit cleanup remove the path only while `lstat` still
69
- matches that receipt. If another actor renames the workspace away and places a
70
- new directory at the old name, cleanup returns `"identity-mismatch"` and leaves
71
- the replacement untouched. Disposal hooks perform the same check and ignore
72
- the returned status.
67
+ **Compatibility and security:** workspace creation remains available in native
68
+ `auto`, `off`, and unavailable-native environments. The default
69
+ `cleanupSafety: "compatible"` preserves the JavaScript cleanup behavior from
70
+ 0.6: it verifies the workspace identity, moves the public name to a fresh
71
+ `.fs-safe-workspace-cleanup-<uuid>` sibling, verifies that quarantine, and then
72
+ uses guarded pathname-recursive removal. This fallback never recursively
73
+ removes the public workspace name, but it is not atomic conditional deletion: a
74
+ same-privilege peer that discovers and replaces the private quarantine after
75
+ verification can still redirect the final pathname removal.
76
+
77
+ Set `cleanupSafety: "require-bounded"` when that concurrent attacker is in scope.
78
+ Creation then requires native no-replace directory rename, native owned-tree
79
+ removal, and a retained parent descriptor **before** `mkdtemp` creates
80
+ a child. If any capability is unavailable, creation throws
81
+ `FsSafeError("helper-unavailable")`; no child is created and a scoped callback is
82
+ not called. The compatible default retains its fallback even if process-global
83
+ native mode is `require`; select `require-bounded` to make cleanup capability
84
+ mandatory for this API.
85
+
86
+ On Linux, bounded cleanup requires a successful runtime probe of the exact
87
+ `openat2` child-directory flags, including `RESOLVE_NO_XDEV`, against the retained
88
+ parent descriptor. If the kernel or seccomp policy denies that capability,
89
+ compatible mode uses the guarded JavaScript fallback; `require-bounded` rejects
90
+ before child creation. The probe runs once at creation, without filesystem mutation.
91
+
92
+ Bounded cleanup checks the parent and public workspace identity, quarantines
93
+ the direct child without replacement, and verifies the quarantine against the
94
+ retained workspace descriptor. It binds every enumerated child to its native
95
+ identity before opening it, rejects mount crossings, and traverses descendants
96
+ only through opened directory handles; symlinks/reparse entries are removed as
97
+ leaves and never traversed. Windows marks the exact opened objects for deletion by handle.
98
+
99
+ POSIX has no unlink-by-fd or expected-inode unlink for directory entries. After
100
+ the final identity check, each `unlinkat` can still be raced; the possible side
101
+ effect is bounded to one substituted non-directory leaf or one empty directory
102
+ entry per raced syscall. A substituted nonempty directory is never recursively
103
+ traversed and is preserved as `"indeterminate"`, but a leaf replacement removed
104
+ in that irreducible final gap cannot be distinguished after the syscall.
105
+
106
+ The workspace captures its identity, binding, and descriptors until cleanup.
107
+ Later process-global mode changes or loader resets do not revoke that authority.
108
+ Manual, disposal, and process-exit cleanup share one serialized owner,
109
+ registered before store construction; a construction failure after registration
110
+ remains exit-cleanable. Earlier creation failures close retained descriptors
111
+ without deleting an unverified child.
112
+
113
+ If the quarantine does not match the creation descriptor, cleanup leaves it in
114
+ place without restoring the public name or recursively deleting it and returns
115
+ `"indeterminate"`. A collision, uncertain rename outcome, changed parent,
116
+ mount/device crossing, changed reparse state, or detected concurrent mutation also
117
+ preserves the remaining artifact. Recover `.fs-safe-workspace-cleanup-<uuid>` entries only
118
+ after excluding competing mutators and re-establishing ownership.
119
+
120
+ A missing workspace returns `"missing"`. A replacement observed at the public
121
+ name before quarantine returns `"identity-mismatch"` when the parent is stable;
122
+ an ambiguous parent returns `"indeterminate"`. After successful removal,
123
+ repeated cleanup returns `"missing"` without touching a recreated public name.
124
+ Other statuses remain stable. Operational removal errors propagate and later
125
+ cleanup returns `"indeterminate"` without retrying. Disposal and scoped helpers
126
+ ignore returned statuses, while manual cleanup exposes the result.
73
127
 
74
128
  When cleanup is part of a retention or audit decision, inspect the receipt
75
129
  instead of treating cleanup as fire-and-forget:
@@ -82,6 +136,8 @@ try {
82
136
  const cleanup = await workspace.cleanup();
83
137
  if (cleanup === "identity-mismatch") {
84
138
  alertOperator("restore workspace path was replaced; replacement preserved");
139
+ } else if (cleanup === "indeterminate") {
140
+ alertOperator("restore workspace cleanup could not establish safe completion; inspect retained entries");
85
141
  }
86
142
  }
87
143
  ```
@@ -91,7 +147,7 @@ types and a `FileStoreSync` at `workspace.store`.
91
147
 
92
148
  ### `withTempWorkspace`
93
149
 
94
- The recommended shape. Auto-cleanup on every exit path:
150
+ The recommended shape. Attempts cleanup on every exit path:
95
151
 
96
152
  ```ts
97
153
  import { withTempWorkspace } from "@openclaw/fs-safe/temp";
@@ -129,6 +185,7 @@ type TempWorkspaceOptions = {
129
185
  prefix: string; // dir prefix (sanitized)
130
186
  dirMode?: number; // dir mode; default 0o700
131
187
  mode?: number; // file write mode; default 0o600
188
+ cleanupSafety?: "compatible" | "require-bounded"; // default compatible
132
189
  };
133
190
  ```
134
191
 
@@ -205,11 +262,65 @@ const result = await writeSiblingTempFile<string>({
205
262
  // result.filePath, result.result (returned by writeTemp)
206
263
  ```
207
264
 
208
- `writeSiblingTempFile` chooses a random sibling name in `dir`, calls your `writeTemp()` callback, validates that `resolveFinalPath(result)` is still inside that same directory, and renames the temp file there.
209
-
210
- By default it preserves the historical private-helper behavior of chmodding
211
- `dir` to `dirMode` (default `0o700`). Pass `chmodDir: false` when the directory
212
- is a public staging/output path whose existing mode must be preserved.
265
+ `writeSiblingTempFile` chooses a random, initially absent sibling name in `dir`
266
+ and calls `writeTemp()`. After the callback succeeds, it validates the produced
267
+ regular file before taking ownership: symlinks, directories, other non-regular
268
+ files, hardlinks, and changes between the pre-open pathname, opened descriptor,
269
+ and current pathname are rejected. The callback must finish and close its
270
+ writer before returning. Its return value is preserved as `result`.
271
+
272
+ The helper retains one descriptor through requested mode application, opt-in
273
+ file synchronization, rename, and publication verification. It opens read-only
274
+ unless file synchronization is requested, so closed read-only producer output
275
+ remains publishable under the historical default. Omitting
276
+ `mode` preserves the callback-produced mode without chmod; explicit modes,
277
+ including `0`, are applied through that descriptor. File-mode errors are
278
+ tolerated for compatibility with the helper's historical best-effort behavior.
279
+ No chmod, content read, or reopen follows
280
+ the staged or published pathname. `resolveFinalPath(result)` must resolve to a
281
+ distinct direct child of the same directory. Final-path writes are serialized
282
+ within the process, and the retained descriptor and current name must still
283
+ have the admitted exact bigint identity and exactly one link before rename and
284
+ after publication. A verification failure after rename does not roll back or
285
+ delete the final name.
286
+
287
+ `syncTempFile` and `syncParentDir` retain their historical `false` defaults.
288
+ Explicit `syncTempFile: true` synchronizes the descriptor before rename;
289
+ file-sync errors propagate except for the existing `EPERM` compatibility case.
290
+ Explicit `syncParentDir: true` requests best-effort parent sync after rename.
291
+ Omitting either option or passing `false` skips that sync, never the identity
292
+ checks. Parent synchronization can be unsupported or fail without rejecting
293
+ the write, so success is not a strict crash-durability receipt.
294
+
295
+ Cleanup only unlinks an admitted file while the parent, pathname identity, and
296
+ single-link regular-file checks still agree. Observed substitutes are preserved,
297
+ including during process-exit cleanup. Operational cleanup failures retain an
298
+ identity-bound exit retry. If the callback throws or admission fails, no file
299
+ has been adopted: even a regular partial file is left for caller-directed
300
+ recovery. The helper never recursively removes a sibling temp.
301
+
302
+ On POSIX, admission uses no-follow and nonblocking open flags, so a FIFO swap
303
+ does not block the helper. Windows retains Node's guarded pathname-open behavior
304
+ because Node has no portable no-follow flag there; metadata is checked before
305
+ and after opening, and unknown Windows identities fail closed after one bounded
306
+ re-inspection without reopening. These helpers remain available with native
307
+ mode `off`; they do not acquire the native-required retained-directory contract
308
+ of [`stageFileInDirectory`](staged-file.md).
309
+
310
+ Identity checks and pathname rename/unlink are separate syscalls, not atomic
311
+ conditional mutations. A hostile process can still replace a leaf or parent in
312
+ the final syscall gap or mutate an open file's contents. Use an approved writable
313
+ directory and cooperative locking or OS isolation; a moved parent can leave an
314
+ unpublished original temp behind. Observed replacements are preserved, but
315
+ arbitrary concurrent namespace changes cannot be prevented by these helpers.
316
+
317
+ By default the helper attempts to set `dir` to `dirMode` (default `0o700`)
318
+ through the shared verified POSIX directory-descriptor helper. Only the actual
319
+ descriptor chmod error is tolerated, preserving the historical best-effort
320
+ directory-mode behavior. Directory lstat, open, type, identity, and close errors
321
+ still propagate; there is no pathname chmod fallback. Windows only passes the
322
+ directory mode to `mkdir`. Pass
323
+ `chmodDir: false` when an existing staging/output directory mode must be preserved.
213
324
 
214
325
  ### `writeViaSiblingTempPath`
215
326
 
@@ -232,6 +343,9 @@ await writeViaSiblingTempPath({
232
343
  If `replaceFileAtomic` does what you need, prefer that. Use
233
344
  `writeViaSiblingTempPath` when the producer needs a concrete temp pathname but
234
345
  the final destination still needs root-boundary checks.
346
+ Its private workspace uses the same identity-aware directory cleanup as
347
+ `tempFile()`: moving and replacing the workspace preserves the replacement.
348
+ This workspace owns its contents, unlike the unadmitted sibling pathname above.
235
349
 
236
350
  ## Secure temp root
237
351
 
package/docs/testing.md CHANGED
@@ -17,7 +17,7 @@ The double-underscore prefix is a deliberate "hands off" signal: production code
17
17
  ## When to reach for hooks
18
18
 
19
19
  - Reproduce a TOCTOU race deterministically: simulate a symlink swap between resolve and open, or between write and rename.
20
- - Force guarded JavaScript behavior without removing bundled binaries from your runners.
20
+ - Force guarded JavaScript behavior without removing platform packages from your runners.
21
21
  - Inject latency to test cancellation/timeout paths.
22
22
 
23
23
  If you don't need to inject a race, you don't need hooks — most tests should drive the library through normal calls and assert on observable behavior.
package/docs/writing.md CHANGED
@@ -33,6 +33,10 @@ old-or-new guarantee does not apply to `append()` or `openWritable()`, which
33
33
  write in place, or to lower-level atomic helpers when their explicitly
34
34
  non-atomic permission-error copy fallback is enabled.
35
35
 
36
+ Post-publication verification can still reject after a complete replacement has
37
+ been committed. Rejection does not promise that a successful rename was rolled
38
+ back; the published file or a raced replacement may remain at the destination.
39
+
36
40
  ## Denying mutations
37
41
 
38
42
  All mutation verbs accept `denyMutations?: DenyMutationPolicy`, either as a root default or per-call option:
@@ -64,6 +68,21 @@ await fs.write("notes/today.txt", "hello\n", { encoding: "utf8" });
64
68
 
65
69
  `data` accepts `string | Buffer`. `options` are `{ denyMutations?: DenyMutationPolicy; encoding?: BufferEncoding; mkdir?: boolean; mode?: number; overwrite?: boolean; renameIdentity?: RenameIdentityPolicy }`. `mode` sets the file's POSIX mode. If neither the call nor `RootDefaults` supplies it, a replacement preserves the existing file mode and a new file uses `0o600`. `mkdir` and `overwrite` both default to `true`; set `overwrite: false` for the same no-clobber behavior as `create()`.
66
70
 
71
+ POSIX modes without read permission, including `0o000` and `0o200`, succeed:
72
+ final verification uses a descriptor retained by the writer rather than reopening
73
+ the published file. The requested mode is not relaxed for verification.
74
+ Publication verification compares exact bigint descriptor and pathname identities,
75
+ including large file indexes that cannot be represented by a JavaScript number.
76
+ Later reads still obey OS permissions, and access checks on a pre-existing
77
+ destination are unchanged. The explicit FUSE compatibility policy still requires
78
+ a readable destination to prove matching content when rename changes its identity.
79
+
80
+ When Windows cannot report a pathname's identity, the publication verifier reopens
81
+ the name and compares that descriptor's exact identity with the original retained
82
+ file, without reading bytes. It also rechecks links and root/parent containment.
83
+ This write-specific proof does not relax ordinary reads: unknown pathname metadata
84
+ alone is never proof that the name still refers to the expected file.
85
+
67
86
  ### `fs.create(rel, data, options?)`
68
87
 
69
88
  Don't-clobber variant of `write()`. Throws `already-exists` if the target is there.