@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
package/docs/archive.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Archive extraction
2
2
 
3
- `@openclaw/fs-safe/archive` extracts ZIP and TAR archives behind one API, with traversal checks, blocked-link-type rejection, and entry-count and byte budgets. When the bundled native binding is available for the current platform, Rust streams ZIP, TAR, gzip, zstd, and bzip2 while TypeScript remains the sole policy owner; every accepted output is created fd-relative in a private staging root. Extraction then merges through the same safe-open boundary used by direct writes — a symlinked entry can't trick the merge into following an out-of-tree path.
3
+ `@openclaw/fs-safe/archive` extracts ZIP and TAR archives behind one API, with traversal checks, blocked-link-type rejection, and entry-count and byte budgets. When the native binding is available for the current platform, Rust streams ZIP, TAR, gzip, zstd, and bzip2 while TypeScript remains the sole policy owner; every accepted output is created fd-relative in a private staging root. Extraction then merges through the same safe-open boundary used by direct writes — a symlinked entry can't trick the merge into following an out-of-tree path.
4
4
 
5
5
  The guarded JavaScript fallback uses optional runtime dependencies: `jszip` for
6
6
  ZIP and `tar` for TAR/gzip. The native path does not use those packages. Installs
@@ -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,14 +68,43 @@ 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
- An `entryFilter` sees the validated archive path, entry kind, and declared
76
- size. Returning `"skip"` rejects the whole archive unless `onFiltered` is
77
- explicitly `"skip-entry"`. Path traversal and archive-wide entry-count checks
78
- still apply to skipped entries.
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.
76
+
77
+ `stripComponents` removes leading nonempty, non-`.` path components after
78
+ normalizing separators. For example, `./pkg/hello.txt` with
79
+ `stripComponents: 1` extracts to `hello.txt` on both backends. Entries with no
80
+ remaining components are skipped before the filter callback, but still count
81
+ toward `maxEntries` and undergo traversal validation. JavaScript TAR extraction
82
+ passes node-tar this accepted output path with its own stripping disabled, so
83
+ depth checks, collision checks, writes, and mode application agree.
84
+
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 `/`.
99
+ Returning `"skip"` rejects the whole archive unless `onFiltered` is
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.
79
108
 
80
109
  For example, a fleet restore can omit regenerated cache entries while rejecting
81
110
  any other policy mismatch by default:
@@ -100,14 +129,33 @@ If skipping was not explicitly part of the restore contract, omit
100
129
  `onFiltered`; the first `"skip"` then rejects the complete archive with
101
130
  `ArchiveSecurityError("entry-filtered")`.
102
131
 
103
- Policy rejection is prompt on both implementations. The JavaScript TAR path
104
- owns the file stream and aborts node-tar through a pipeline on filter, path,
105
- link, limit, validation, or timeout failure, which destroys both ends instead
106
- of leaving a paused parser to drain indefinitely. The native path finishes its
107
- bounded manifest read before TypeScript policy evaluation, so a rejected plan
108
- never starts the extraction worker.
109
-
110
- 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.
111
159
 
112
160
  ### Limits
113
161
 
@@ -115,8 +163,8 @@ If `kind` is omitted, the helper calls `resolveArchiveKind(archivePath)` and thr
115
163
  type ArchiveExtractLimits = {
116
164
  maxArchiveBytes?: number; // refuse if archivePath stat'd size exceeds this
117
165
  maxEntries?: number; // refuse before extracting if entry count > this
118
- maxExtractedBytes?: number; // refuse mid-stream if total extracted bytes > this
119
- 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
120
168
  maxMetaEntryBytes?: number; // refuse one PAX/GNU metadata body above this
121
169
  maxEntryPathComponents?: number; // bound output path depth after stripComponents
122
170
  };
@@ -131,13 +179,24 @@ A limit violation throws `ArchiveLimitError`. Its constant and string code are:
131
179
  | `ARCHIVE_SIZE_EXCEEDS_LIMIT` | `archive-size-exceeds-limit` |
132
180
  | `ENTRY_COUNT_EXCEEDS_LIMIT` | `archive-entry-count-exceeds-limit` |
133
181
  | `EXTRACTED_SIZE_EXCEEDS_LIMIT` | `archive-extracted-size-exceeds-limit` |
182
+ | `DECODED_SIZE_EXCEEDS_LIMIT` | `archive-decoded-size-exceeds-limit` |
134
183
  | `ENTRY_EXTRACTED_SIZE_EXCEEDS_LIMIT` | `archive-entry-extracted-size-exceeds-limit` |
135
184
  | `META_ENTRY_SIZE_EXCEEDS_LIMIT` | `archive-meta-entry-size-exceeds-limit` |
136
185
  | `ENTRY_PATH_COMPONENTS_EXCEEDS_LIMIT` | `archive-entry-path-components-exceeds-limit` |
137
186
  | `MANIFEST_SIZE_EXCEEDS_LIMIT` | `archive-manifest-size-exceeds-limit` |
138
187
 
139
- `MANIFEST_SIZE_EXCEEDS_LIMIT` is retained in the public compatibility union;
140
- 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.
141
200
 
142
201
  Catch and branch on the code to surface a meaningful response to the caller.
143
202
 
@@ -149,22 +208,161 @@ codes remain `"destination-not-directory"`, `"destination-symlink"`, and
149
208
  ## What it defends against
150
209
 
151
210
  - **Path traversal:** entries with `..`, absolute paths, NUL bytes, or Windows drive-relative segments such as `C:secret` and `nested/C:secret` are rejected (`ArchiveSecurityError`). On Windows, path segments containing `:` are also rejected as alternate data stream names before either backend writes to the filesystem.
152
- - **Symlink/hardlink entries:** rejected by default. Some archives ship symlink/hardlink entries that point outside the destination once resolved; `extractArchive` does not follow them.
211
+ - **Symlink/hardlink entries:** rejected by default, including ZIP entries whose Unix mode says symlink while their name ends in a slash or their DOS directory bit is set. An explicit `entryFilter` with `onFiltered: "skip-entry"` can omit these entries. Some archives ship symlink/hardlink entries that point outside the destination once resolved; `extractArchive` does not follow them.
153
212
  - **Ambiguous output names:** duplicate names and distinct names that collide after `stripComponents`, case normalization, or Unicode normalization are rejected instead of relying on backend- or volume-specific overwrite order.
154
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).
155
214
  - **Zip bombs:** `maxExtractedBytes` and `maxEntryBytes` apply to *post-decompression* bytes, so highly-compressed payloads hit the cap before they exhaust disk.
156
215
  - **Corrupt ZIP payloads:** streamed output must match both the central-directory CRC and declared uncompressed size before it can leave private staging.
157
- - **Slow-loris archives:** `timeoutMs` is a hard wall-clock budget. Extraction is aborted on overrun.
158
- - **Metadata bombs:** a fixed-header 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 size fields without interpreting metadata content.
159
-
160
- PAX headers can override the next entry's size from inside their content. The
161
- fixed-header meter deliberately never interprets that content, so PAX and GNU
162
- sparse entries are rejected with
163
- `ArchiveFormatError("archive-header-invalid")` rather than guessing. GNU sparse
164
- extension blocks are still metered in 512-byte units before rejection, ensuring
165
- malformed or excessive chains cannot bypass the metadata ceiling. GNU long-name
166
- and long-link entries remain supported because their fixed header size fully
167
- determines their layout.
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.
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.
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
+
297
+ ### Bounded local PAX support
298
+
299
+ Extraction and single-entry reads accept one nonempty local POSIX `x` header
300
+ (USTAR or GNU header format) immediately before one regular/contiguous file,
301
+ directory, symlink, or hardlink. `path`, `linkpath`, and `size` override that
302
+ member only. Effective paths still pass traversal validation before stripping,
303
+ then the output paths pass depth and collision checks. The filter receives the
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
306
+ budgets; `maxEntries` still counts members, not their metadata headers.
307
+
308
+ Records must have exact byte lengths, ASCII keys, a final newline, and no
309
+ duplicate keys, embedded newlines, or unconsumed bytes. Structural `path` and
310
+ `linkpath` values and ownership names must be nonempty printable ASCII. A PAX
311
+ member's raw name, USTAR prefix, and raw link target must also be printable
312
+ ASCII; raw link targets must be present only on links, even when overridden.
313
+ Unicode
314
+ PAX structural text is deliberately unsupported because the underlying parsers
315
+ do not agree when UTF-8 is split across input chunks. `size`, `uid`, and `gid`
316
+ must be canonical unsigned decimal safe integers (zero is valid; signs, leading
317
+ zeros, fractions, and exponents are not). Padded member sizes must also fit the
318
+ safe integer range. Raw and effective directory/link sizes must both be zero;
319
+ non-directory paths ending with a separator and `linkpath` on non-links are
320
+ rejected rather than allowing parser-specific type or framing changes.
321
+
322
+ The descriptive allowlist is `mtime`, `atime`, `ctime` (signed decimal seconds
323
+ with optional fractional digits, within JavaScript's Date range), `uid`, `gid`,
324
+ `uname`, and `gname`. These attributes are accepted but not restored to the
325
+ destination. `LIBARCHIVE.xattr.*` and `SCHILY.xattr.*` with nonempty ASCII
326
+ alphanumeric/dot/underscore/hyphen suffixes are also accepted as inert metadata,
327
+ never restored as extended attributes. Their values are byte-counted and may
328
+ contain NUL or non-UTF8 bytes, including macOS provenance metadata; embedded
329
+ newlines are rejected because they can disrupt downstream record parsing.
330
+
331
+ Global `g`, old `X`, old GNU `N`, empty/dangling/repeated local headers, mixed
332
+ PAX/GNU extension chains, unknown keys, charset declarations, ACL extensions,
333
+ and all sparse extensions (including `GNU.sparse.*`, `SCHILY.filetype`,
334
+ `SCHILY.realsize`, and `SCHILY.size`) fail closed with
335
+ `ArchiveFormatError("archive-header-invalid")`. GNU sparse extension blocks are still
336
+ metered in 512-byte units before rejection, preserving metadata-limit errors
337
+ for excessive chains. The per-body `maxMetaEntryBytes` limit bounds PAX storage
338
+ and duplicate-key state; one local header per member prevents local metadata
339
+ chains without introducing a new limit or changing defaults.
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.
168
366
 
169
367
  ## `resolveArchiveKind`
170
368
 
@@ -212,11 +410,24 @@ await extractArchive({
212
410
  regular-file entry into a bounded `Buffer` without extracting a tree. It pins
213
411
  and privately stages the archive input, rejects link, directory, and duplicate
214
412
  entries, verifies ZIP CRC and declared size,
215
- 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
216
419
  inputs retain the archive subpath's 256 MiB compressed-input ceiling.
217
420
  With a native binding it uses the same Rust decoders as extraction, including
218
421
  zstd and bzip2 TAR. Without native it retains the JS ZIP/TAR/gzip implementation.
219
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
+
220
431
  ```ts
221
432
  const rawManifest = await readArchiveEntry(uploadPath, "package/manifest.json", {
222
433
  maxBytes: 64 * 1024,
@@ -257,10 +468,14 @@ import {
257
468
 
258
469
  - `validateArchiveEntryPath(raw, opts)` — throws `ArchiveSecurityError` for `..`, absolute, NUL-containing, drive-relative, or otherwise unsafe entry paths, including alternate data stream names on Windows.
259
470
  - `normalizeArchiveEntryPath(raw)` — converts backslashes in the entry path to forward slashes.
260
- - `stripArchivePath(entryPath, n)` — strip the leading N path components, returning `null` if not enough remain.
471
+ - `stripArchivePath(entryPath, n)` — normalize separators, drop empty and `.` components, then strip the leading N components, returning `null` if none remain.
261
472
  - `resolveArchiveOutputPath({ destDir, entryPath })` — combines the entry path with the destination, after validation.
262
473
  - `isWindowsDrivePath(value)` — detects drive-relative segments such as `C:secret` or `nested/C:secret` that should be rejected.
263
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
+
264
479
  ## Common patterns
265
480
 
266
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
 
package/docs/config.md CHANGED
@@ -78,6 +78,13 @@ configureFsSafeLocks({
78
78
  });
79
79
  ```
80
80
 
81
+ These defaults apply to both `acquireFileLock()` / `withFileLock()` and
82
+ `acquireFileLockSync()` / `withFileLockSync()`. Each acquisition resolves
83
+ `retry`, `staleMs`, `staleRecovery`, and `timeoutMs` from the per-call option
84
+ first, then the process configuration, then the package default. Explicit zero
85
+ values are preserved. A per-call `retry` object replaces the configured object
86
+ as a whole; omitted retry fields use package defaults, not configured fields.
87
+
81
88
  Individual lock calls can override any default. Switching the global stale
82
89
  recovery mode does not provide the application-owned liveness proof required
83
90
  by `shouldRemoveStaleLock`.
@@ -46,23 +46,55 @@ pnpm check
46
46
  This runs the filesystem boundary checks, build, tests, and package
47
47
  tarball/import validation.
48
48
 
49
+ ### Native consumer installs
50
+
51
+ After `pnpm build` and a fresh `pnpm native:build`, run `pnpm package:smoke`.
52
+ It packs the real root and host binding, then runs root-only npm and the
53
+ declared pnpm version against a disposable loopback registry. The root's exact
54
+ optional dependencies stay unchanged. Each consumer lives outside the workspace
55
+ with isolated configuration, caches, and stores; the registry never proxies to
56
+ the Internet. The smoke verifies root integrity, consumer-local resolution,
57
+ OS/CPU/libc selection, a native-required SHA-256 operation, and fresh-process
58
+ `auto`/`off` fallbacks and `require` failures for missing bindings and omitted
59
+ optionals. Omitted-optionals installs also verify that all public subpaths can
60
+ be imported, without implying every operation remains available.
61
+
62
+ Host-only smoke supplies the six foreign packages using their unchanged real
63
+ manifests and clearly marked synthetic, non-executable payloads. Every foreign
64
+ metadata/tarball endpoint is checked before installation, so a missing fixture
65
+ cannot masquerade as successful platform filtering. These temporary fixtures
66
+ never enter `packages/`, release artifacts, or the publish manifest. They prove
67
+ installer filtering, not foreign native compilation or execution. Full release
68
+ collection uses the actual seven collected native tarballs instead. Run it with
69
+ `pnpm package:collect` after assembling all seven real bindings; missing targets
70
+ fail collection. `pnpm package:collect --allow-host-only` exercises the same
71
+ lifecycle boundary locally but proves only the host. Both collection commands
72
+ require the pnpm lifecycle CLI path; direct `node` invocation is unsupported. Archive
73
+ codecs and their dependencies are packed from the installed dependency graph.
74
+
75
+ PR CI builds and executes four host targets: Linux x64 glibc, Linux x64 musl
76
+ (Alpine), macOS arm64, and Windows x64. The root-only smoke runs on each. The
77
+ seven-target source build matrix runs on release tags; packaging all seven is
78
+ not execution proof for every architecture. The smoke writes manager versions,
79
+ cases, and synthetic-fixture scope to `release-artifacts/consumer-proof.json`.
80
+
49
81
  ## Docs
50
82
 
51
- The docs site is rendered from `docs/*.md` by `scripts/build-docs-site.mjs`. Build locally to preview:
83
+ The docs site is rendered recursively from Markdown files under `docs/` by `scripts/build-docs-site.mjs`. Build locally to preview:
52
84
 
53
85
  ```bash
54
- node scripts/build-docs-site.mjs
86
+ pnpm docs:site
55
87
  open dist/docs-site/index.html
56
88
  ```
57
89
 
58
- The build validates internal links and embedded anchors. Broken links fail the build — fix them before pushing.
90
+ The build validates internal links and embedded anchors. Broken links fail the build — fix them before pushing. Navigation must list every non-excluded Markdown page exactly once: missing, nonexistent, and duplicate entries fail before the build replaces existing site output. The builder and navigation tests share discovery and validation in `scripts/docs-site-navigation.mjs`.
59
91
 
60
92
  Adding a new doc page:
61
93
 
62
94
  1. Create `docs/<page>.md`. Use a leading `# Title` heading.
63
- 2. Add the page to the appropriate section in `scripts/build-docs-site.mjs` (`sections` array near the top).
95
+ 2. Add the page to the appropriate section in `scripts/docs-site-navigation.mjs` (`sections` array near the top). Nested pages use slash-separated paths relative to `docs/`, such as `guides/example.md`.
64
96
  3. Cross-link from `docs/index.md` if it's a major surface.
65
- 4. Re-run the local build.
97
+ 4. Run `pnpm test test/docs-site-navigation.test.ts` and re-run the local build.
66
98
 
67
99
  Internal links use relative `*.md` paths — the builder rewrites them to the rendered HTML. Code fences support GitHub-flavored markdown.
68
100
 
@@ -79,8 +111,14 @@ Small, focused PRs land faster. The general shape:
79
111
 
80
112
  Maintainers publish from a protected `vX.Y.Z` tag on `main` through
81
113
  `.github/workflows/release.yml`. The workflow requires the package version and a
82
- dated `CHANGELOG.md` section to match the tag, then publishes with npm trusted
83
- publishing and provenance before creating the GitHub release.
114
+ dated `CHANGELOG.md` section to match the tag. It builds and publishes all seven
115
+ platform packages before publishing `@openclaw/fs-safe`, verifies every registry
116
+ artifact and provenance statement, and then creates the GitHub release.
117
+
118
+ Each package needs its own npm trusted-publisher configuration for
119
+ `openclaw/fs-safe` and `release.yml`. A new platform package must be created and
120
+ configured on npm before the first tag that references it; npm trust is
121
+ package-specific and cannot be bootstrapped by the tag workflow itself.
84
122
 
85
123
  External contributors do not need to do anything beyond getting the pull
86
124
  request merged. Maintainers must not publish locally or add npm automation
@@ -63,6 +63,12 @@ into proof for a different directory.
63
63
 
64
64
  Call `close()` in `finally`. Closing is idempotent; using a closed pin fails.
65
65
 
66
+ These checks intentionally reject a moved or replaced pathname. For one file's
67
+ abort cleanup through its original directory after a move, use the separate
68
+ [retained-directory staging lifecycle](staged-file.md). Its cleanup authority
69
+ does not weaken `pinDirectory().assertCurrent()` or `.sync()`, and namespace
70
+ cleanup is not proof of crash durability.
71
+
66
72
  ## Durable directory creation
67
73
 
68
74
  `ensureDurableDirectory()` finds and pins the nearest existing ancestor,
@@ -84,7 +90,7 @@ target fails with `FsSafeError("path-mismatch")`.
84
90
  ## Exclusive file publication
85
91
 
86
92
  `publishFileExclusive()` materializes one file without clobbering an existing
87
- target. It pins the source with `O_NOFOLLOW`, optionally verifies
93
+ target. It pins the source with nonblocking `O_NOFOLLOW`, optionally verifies
88
94
  `expectedSourceIdentity`, tries a hardlink first, then synchronizes the target
89
95
  parent directory.
90
96
 
@@ -204,10 +210,17 @@ try {
204
210
  The result is `{ bytes, digest }`, where `digest` is lowercase hexadecimal.
205
211
  The handle overload never closes the caller's descriptor and uses positioned
206
212
  reads, so it does not alter the descriptor's current offset. The path overload
207
- rejects symbolic links and non-regular files, verifies the opened descriptor
208
- still names the requested path, and closes its own handle. POSIX opens are
209
- nonblocking, so a raced FIFO or device is rejected after descriptor inspection
210
- rather than waiting for a writer.
213
+ rejects symbolic links and non-regular files, compares lossless bigint identities
214
+ from the pre-open pathname inspection to the opened descriptor and from that
215
+ descriptor to the current pathname, and closes its own handle. All identity
216
+ checks complete before any JavaScript or native hashing. Each inspection allows
217
+ one bounded retry for unknown Windows identity components, retaining known
218
+ components and rejecting known differences immediately. Persistent unknown
219
+ identity fails closed with `path-mismatch`, even for benign files: this trades
220
+ availability for verifiable identity. Retries inspect the same descriptor or
221
+ pathname without reopening the file and repeat the symlink and file-type checks.
222
+ POSIX opens are nonblocking, so a raced FIFO or device is rejected after
223
+ descriptor inspection rather than waiting for a writer.
211
224
 
212
225
  When the optional binding is active, hashing runs as an async native task and
213
226
  does not occupy the JavaScript event loop with digest updates. With native mode
package/docs/errors.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  Every failure that's the library's job to surface lands as an `FsSafeError` with a closed `code` union you can branch on. Catch by code, not by message text — messages may change, codes will not.
4
4
 
5
+ Result-based APIs are an exception to throwing: [`pathScope().ensureDir()`](path-scope.md#ensuredir-rel-options)
6
+ and `ensureDirectoryWithinRoot()` return operational failures as
7
+ `{ ok: false, error: string, diagnostic: FsSafeError }`. The diagnostic uses
8
+ `helper-failed` / `operational`, retains the exact native error in `cause`, and
9
+ has the same message as `error`. Its display text names the native code/syscall
10
+ when available but omits raw paths and native messages. Policy failures keep
11
+ the string-only result without `diagnostic`; other `pathScope` methods do not
12
+ gain this field. Directory preparation can partially complete before failing.
13
+
5
14
  Path and archive-entry details embedded in diagnostics escape control characters
6
15
  as `\\uXXXX` sequences. This keeps attacker-controlled names on one log line;
7
16
  the escaped message is for diagnosis, not for reconstructing the original path.
@@ -105,7 +114,7 @@ type FsSafeErrorCode =
105
114
  | `device-path` | A read/open target is a known unsafe device or process-fd path. | `/dev/zero`, `/dev/random`, `/dev/stdin`, `/dev/fd/*`, `/proc/*/fd/*`, or a Windows reserved device name. |
106
115
  | `hardlink` | Read or copy with `hardlinks: "reject"` saw `nlink > 1`. | File is hardlinked — possibly an alias of an out-of-tree inode. |
107
116
  | `helper-failed` | A native mechanism or multi-step operational helper failed. | Inspect `cause` and any operation-specific `details`; retrying may be unsafe if the operation partially completed. |
108
- | `helper-unavailable` | Required native binding could not be loaded. | Unsupported platform, missing/incompatible bundled binary, or `FS_SAFE_NATIVE_MODE=require`. `auto` falls back where possible; `require` fails closed. |
117
+ | `helper-unavailable` | Required native binding could not be loaded. | Unsupported platform, omitted/missing/incompatible platform package, or `FS_SAFE_NATIVE_MODE=require`. `auto` falls back where possible; `require` fails closed. |
109
118
  | `insecure-permissions` | A secure file or path permission check found a mode/ACL that allows broader access than requested. | File or directory is group/world writable/readable; Windows ACL grants broad read. |
110
119
  | `invalid-path` | Input was empty, contained NUL, was an unparseable URL, or otherwise unusable. | Caller didn't validate input; input was a network path on Windows, a drive-relative segment in a portable relative path or store key, or a leading drive-relative spelling such as `C:name` used as a Root destination. Existing-object Root lookups retain legal POSIX drive-like names. |
111
120
  | `not-empty` | `remove()` on a non-empty directory. | Use `replaceDirectoryAtomic` or remove children first. |
@@ -125,6 +134,12 @@ type FsSafeErrorCode =
125
134
  | `too-large` | A read or bounded walk exceeded its configured budget. | Caller gave a too-permissive file or traversal limit. |
126
135
  | `unsupported-platform` | Reserved compatibility code for a platform-specific operation. | No current public helper emits this `FsSafeError` code. Platform-specific APIs currently return a typed unsupported result or use `helper-unavailable`; keep the union member when exhaustively switching across supported package versions. |
127
136
 
137
+ Pathname `sha256File()` also reports `path-mismatch` when pre-open, descriptor,
138
+ or current-path identity remains unknown after one bounded Windows retry, even
139
+ if the file is benign. It never reopens to recover identity. Preview symlinks
140
+ report `symlink`, preview or descriptor non-files report `not-file`, and a
141
+ current-path symlink or non-file reports `path-mismatch`.
142
+
128
143
  ## Branching
129
144
 
130
145
  ```ts
@@ -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/index.md CHANGED
@@ -39,6 +39,7 @@ await fs.remove("notes/archive/today.txt");
39
39
 
40
40
  - **First time?** [Install](install.md), then walk through the [Quickstart](quickstart.md). Five minutes from `pnpm add` to a working root.
41
41
  - **Upgrading from 0.4?** Follow [Migrating to 0.5](migrating-to-0.5.md) in order, including the archive clamp-default audit.
42
+ - **Upgrading from 0.5?** Follow [Migrating to 0.6](migrating-to-0.6.md) to adopt platform-native optional packages.
42
43
  - **Designing a workspace feature.** Read the [Security model](security-model.md) before you trust the boundary, the [native helper policy](native-helper.md) before you pick deployment defaults, and the [Errors](errors.md) reference so you know what to catch.
43
44
  - **Replacing ad-hoc atomic writes.** Jump to [Atomic writes](atomic.md) or, for keyed JSON state, [JSON files](json.md).
44
45
  - **Extracting an upload.** Start at [Archive extraction](archive.md) — handles ZIP and TAR with traversal, link, count, and byte limits.
@@ -51,7 +52,7 @@ await fs.remove("notes/archive/today.txt");
51
52
  |---|---|
52
53
  | [`root()`](root.md) | One boundary for read/write/move/remove and bounded recursive walking inside a trusted directory. |
53
54
  | [`@openclaw/fs-safe/config`](config.md) | Process-global native helper and lock-option defaults. |
54
- | [Native helper policy](native-helper.md) | Choose `auto`, `off`, or `require` for bundled native primitives. |
55
+ | [Native helper policy](native-helper.md) | Choose `auto`, `off`, or `require` for platform-native primitives. |
55
56
  | [Native architecture](native.md) | Understand the thin syscall layer, beneath model, platform mechanisms, and fallback boundary. |
56
57
  | [`replaceFileAtomic`](atomic.md) | Sibling-temp + rename, fsync hooks, mode preservation, copy fallback. |
57
58
  | [`@openclaw/fs-safe/durability`](durability.md) | Pinned directory identities, durable creation, exclusive publication, streaming SHA-256, provenance receipts, and sync-failure policy. |
@@ -74,6 +75,7 @@ await fs.remove("notes/archive/today.txt");
74
75
  | [`@openclaw/fs-safe/advanced`](advanced.md) | Directory of lower-level composition helpers (path scopes, regular-file I/O, install paths, sibling-temp writes, …). |
75
76
  | [`@openclaw/fs-safe/test-hooks`](test-hooks.md) | Test-only injection hooks for reproducing open/lstat races. |
76
77
  | [Migrating to 0.5](migrating-to-0.5.md) | End-to-end checklist for Python removal and 0.4 API behavior changes. |
78
+ | [Migrating to 0.6](migrating-to-0.6.md) | Installer-policy checklist for the platform-native package split. |
77
79
 
78
80
  ## Status
79
81