@openclaw/fs-safe 0.5.1 → 0.5.3

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 (231) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +19 -6
  3. package/dist/absolute-path.d.ts.map +1 -1
  4. package/dist/absolute-path.js +20 -12
  5. package/dist/archive-entry.d.ts +1 -0
  6. package/dist/archive-entry.d.ts.map +1 -1
  7. package/dist/archive-entry.js +37 -6
  8. package/dist/archive-errors.d.ts +1 -0
  9. package/dist/archive-errors.d.ts.map +1 -1
  10. package/dist/archive-errors.js +4 -0
  11. package/dist/archive-input.d.ts +7 -0
  12. package/dist/archive-input.d.ts.map +1 -1
  13. package/dist/archive-input.js +11 -1
  14. package/dist/archive-limits.js +1 -1
  15. package/dist/archive-native.d.ts.map +1 -1
  16. package/dist/archive-native.js +12 -6
  17. package/dist/archive-policy.d.ts.map +1 -1
  18. package/dist/archive-policy.js +2 -1
  19. package/dist/archive-read.d.ts.map +1 -1
  20. package/dist/archive-read.js +62 -37
  21. package/dist/archive-staging.d.ts.map +1 -1
  22. package/dist/archive-staging.js +3 -2
  23. package/dist/archive-tar-meta.d.ts.map +1 -1
  24. package/dist/archive-tar-meta.js +15 -1
  25. package/dist/archive-tar-runtime.d.ts +1 -0
  26. package/dist/archive-tar-runtime.d.ts.map +1 -1
  27. package/dist/archive-tar-runtime.js +11 -0
  28. package/dist/archive-tar.d.ts.map +1 -1
  29. package/dist/archive-tar.js +8 -4
  30. package/dist/archive-zip-entry.d.ts +7 -1
  31. package/dist/archive-zip-entry.d.ts.map +1 -1
  32. package/dist/archive-zip-entry.js +11 -1
  33. package/dist/archive-zip-integrity.d.ts +5 -0
  34. package/dist/archive-zip-integrity.d.ts.map +1 -0
  35. package/dist/archive-zip-integrity.js +54 -0
  36. package/dist/archive-zip-preflight.d.ts.map +1 -1
  37. package/dist/archive-zip-preflight.js +12 -1
  38. package/dist/archive.d.ts.map +1 -1
  39. package/dist/archive.js +9 -5
  40. package/dist/bounded-read-stream.d.ts.map +1 -1
  41. package/dist/bounded-read-stream.js +13 -2
  42. package/dist/deny-mutations.d.ts +1 -0
  43. package/dist/deny-mutations.d.ts.map +1 -1
  44. package/dist/deny-mutations.js +6 -39
  45. package/dist/device-path.d.ts +1 -0
  46. package/dist/device-path.d.ts.map +1 -1
  47. package/dist/device-path.js +6 -6
  48. package/dist/directory-guard.d.ts.map +1 -1
  49. package/dist/directory-guard.js +5 -4
  50. package/dist/error-detail.d.ts +2 -0
  51. package/dist/error-detail.d.ts.map +1 -0
  52. package/dist/error-detail.js +4 -0
  53. package/dist/errors.d.ts +1 -1
  54. package/dist/errors.d.ts.map +1 -1
  55. package/dist/errors.js +4 -0
  56. package/dist/file-hash.d.ts.map +1 -1
  57. package/dist/file-hash.js +4 -1
  58. package/dist/file-lock-sync.d.ts.map +1 -1
  59. package/dist/file-lock-sync.js +88 -35
  60. package/dist/file-store-boundary.d.ts +6 -0
  61. package/dist/file-store-boundary.d.ts.map +1 -1
  62. package/dist/file-store-boundary.js +20 -5
  63. package/dist/file-store-sync-write.d.ts +9 -0
  64. package/dist/file-store-sync-write.d.ts.map +1 -0
  65. package/dist/file-store-sync-write.js +97 -0
  66. package/dist/file-store.d.ts.map +1 -1
  67. package/dist/file-store.js +62 -139
  68. package/dist/filename.d.ts.map +1 -1
  69. package/dist/filename.js +27 -0
  70. package/dist/guarded-mkdir.d.ts.map +1 -1
  71. package/dist/guarded-mkdir.js +5 -6
  72. package/dist/install-path.js +1 -1
  73. package/dist/json-document-store.d.ts.map +1 -1
  74. package/dist/json-document-store.js +19 -13
  75. package/dist/json.d.ts.map +1 -1
  76. package/dist/json.js +1 -3
  77. package/dist/local-file-access.d.ts +3 -2
  78. package/dist/local-file-access.d.ts.map +1 -1
  79. package/dist/local-file-access.js +12 -6
  80. package/dist/local-roots.d.ts.map +1 -1
  81. package/dist/local-roots.js +35 -30
  82. package/dist/move-path.d.ts.map +1 -1
  83. package/dist/move-path.js +51 -12
  84. package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
  85. package/dist/native/darwin-x64/fs-safe-native.node +0 -0
  86. package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
  87. package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
  88. package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
  89. package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
  90. package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
  91. package/dist/native-binding.d.ts +1 -0
  92. package/dist/native-binding.d.ts.map +1 -1
  93. package/dist/native-operations.d.ts.map +1 -1
  94. package/dist/native-operations.js +14 -1
  95. package/dist/native-pinned-write.d.ts.map +1 -1
  96. package/dist/native-pinned-write.js +44 -17
  97. package/dist/output-sibling.d.ts.map +1 -1
  98. package/dist/output-sibling.js +6 -5
  99. package/dist/path-policy.d.ts.map +1 -1
  100. package/dist/path-policy.js +1 -7
  101. package/dist/path.d.ts.map +1 -1
  102. package/dist/path.js +4 -0
  103. package/dist/permission-exec.d.ts +6 -0
  104. package/dist/permission-exec.d.ts.map +1 -0
  105. package/dist/permission-exec.js +28 -0
  106. package/dist/permissions-windows.d.ts +56 -0
  107. package/dist/permissions-windows.d.ts.map +1 -0
  108. package/dist/permissions-windows.js +285 -0
  109. package/dist/permissions.d.ts +3 -46
  110. package/dist/permissions.d.ts.map +1 -1
  111. package/dist/permissions.js +17 -328
  112. package/dist/pinned-open.d.ts.map +1 -1
  113. package/dist/pinned-open.js +21 -4
  114. package/dist/pinned-write.js +2 -1
  115. package/dist/private-temp-workspace.d.ts.map +1 -1
  116. package/dist/private-temp-workspace.js +45 -4
  117. package/dist/read-error.d.ts +2 -0
  118. package/dist/read-error.d.ts.map +1 -0
  119. package/dist/read-error.js +11 -0
  120. package/dist/regular-file.d.ts.map +1 -1
  121. package/dist/regular-file.js +21 -2
  122. package/dist/replace-directory.d.ts.map +1 -1
  123. package/dist/replace-directory.js +10 -1
  124. package/dist/replace-file-copy-fallback.d.ts +3 -0
  125. package/dist/replace-file-copy-fallback.d.ts.map +1 -1
  126. package/dist/replace-file-copy-fallback.js +18 -11
  127. package/dist/replace-file-descriptor.d.ts +33 -0
  128. package/dist/replace-file-descriptor.d.ts.map +1 -0
  129. package/dist/replace-file-descriptor.js +96 -0
  130. package/dist/replace-file.d.ts +9 -2
  131. package/dist/replace-file.d.ts.map +1 -1
  132. package/dist/replace-file.js +64 -72
  133. package/dist/root-context.d.ts +8 -0
  134. package/dist/root-context.d.ts.map +1 -1
  135. package/dist/root-context.js +43 -2
  136. package/dist/root-errors.d.ts +7 -0
  137. package/dist/root-errors.d.ts.map +1 -1
  138. package/dist/root-errors.js +46 -1
  139. package/dist/root-file.d.ts +1 -0
  140. package/dist/root-file.d.ts.map +1 -1
  141. package/dist/root-file.js +2 -0
  142. package/dist/root-impl.d.ts.map +1 -1
  143. package/dist/root-impl.js +216 -169
  144. package/dist/root-path-symlink.d.ts +3 -0
  145. package/dist/root-path-symlink.d.ts.map +1 -0
  146. package/dist/root-path-symlink.js +35 -0
  147. package/dist/root-path.d.ts +1 -0
  148. package/dist/root-path.d.ts.map +1 -1
  149. package/dist/root-path.js +129 -190
  150. package/dist/root-walk.d.ts.map +1 -1
  151. package/dist/root-walk.js +15 -0
  152. package/dist/safe-path-segment.d.ts +2 -0
  153. package/dist/safe-path-segment.d.ts.map +1 -1
  154. package/dist/safe-path-segment.js +12 -0
  155. package/dist/secret-file.d.ts.map +1 -1
  156. package/dist/secret-file.js +29 -6
  157. package/dist/secret-read-async.d.ts.map +1 -1
  158. package/dist/secret-read-async.js +5 -1
  159. package/dist/secure-temp-dir.d.ts.map +1 -1
  160. package/dist/secure-temp-dir.js +6 -1
  161. package/dist/short-path.d.ts +2 -0
  162. package/dist/short-path.d.ts.map +1 -0
  163. package/dist/short-path.js +7 -0
  164. package/dist/sidecar-lock-acquire.d.ts +30 -0
  165. package/dist/sidecar-lock-acquire.d.ts.map +1 -0
  166. package/dist/sidecar-lock-acquire.js +260 -0
  167. package/dist/sidecar-lock-policy.d.ts +3 -0
  168. package/dist/sidecar-lock-policy.d.ts.map +1 -1
  169. package/dist/sidecar-lock-policy.js +20 -3
  170. package/dist/sidecar-lock-reclaim.d.ts +5 -1
  171. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  172. package/dist/sidecar-lock-reclaim.js +59 -9
  173. package/dist/sidecar-lock.d.ts.map +1 -1
  174. package/dist/sidecar-lock.js +27 -226
  175. package/dist/symlink-parents.d.ts.map +1 -1
  176. package/dist/symlink-parents.js +9 -8
  177. package/dist/temp-target.d.ts.map +1 -1
  178. package/dist/temp-target.js +15 -4
  179. package/dist/test-hooks.d.ts +1 -0
  180. package/dist/test-hooks.d.ts.map +1 -1
  181. package/dist/timing.d.ts +2 -0
  182. package/dist/timing.d.ts.map +1 -1
  183. package/dist/timing.js +9 -0
  184. package/dist/trash.d.ts.map +1 -1
  185. package/dist/trash.js +10 -1
  186. package/dist/walk.d.ts.map +1 -1
  187. package/dist/walk.js +15 -0
  188. package/docs/advanced.md +7 -1
  189. package/docs/archive.md +27 -20
  190. package/docs/assets/readme-banner.jpg +0 -0
  191. package/docs/atomic.md +48 -19
  192. package/docs/errors.md +34 -8
  193. package/docs/file-store.md +14 -1
  194. package/docs/filename.md +17 -16
  195. package/docs/index.md +1 -0
  196. package/docs/install-path.md +9 -6
  197. package/docs/json.md +8 -2
  198. package/docs/local-roots.md +79 -103
  199. package/docs/native-helper.md +11 -5
  200. package/docs/native.md +2 -2
  201. package/docs/output.md +8 -3
  202. package/docs/path-scope.md +4 -1
  203. package/docs/path.md +7 -3
  204. package/docs/permissions.md +8 -0
  205. package/docs/public-api.md +109 -0
  206. package/docs/quickstart.md +6 -2
  207. package/docs/reading.md +13 -10
  208. package/docs/root.md +26 -4
  209. package/docs/secret-file.md +21 -4
  210. package/docs/security-model.md +21 -4
  211. package/docs/sidecar-lock.md +15 -5
  212. package/docs/temp.md +42 -5
  213. package/docs/test-hooks.md +2 -0
  214. package/docs/types.md +32 -15
  215. package/docs/writing.md +25 -12
  216. package/package.json +15 -4
  217. package/dist/archive-file-io.d.ts +0 -9
  218. package/dist/archive-file-io.d.ts.map +0 -1
  219. package/dist/archive-file-io.js +0 -11
  220. package/dist/file-store-source.d.ts +0 -5
  221. package/dist/file-store-source.d.ts.map +0 -1
  222. package/dist/file-store-source.js +0 -30
  223. package/dist/file-url.d.ts +0 -2
  224. package/dist/file-url.d.ts.map +0 -1
  225. package/dist/file-url.js +0 -1
  226. package/dist/path-stat.d.ts +0 -4
  227. package/dist/path-stat.d.ts.map +0 -1
  228. package/dist/path-stat.js +0 -15
  229. package/dist/windows-permissions-native.d.ts +0 -9
  230. package/dist/windows-permissions-native.d.ts.map +0 -1
  231. package/dist/windows-permissions-native.js +0 -31
package/CHANGELOG.md CHANGED
@@ -1,5 +1,91 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.3 - 2026-08-08
4
+
5
+ ### Security and Correctness
6
+
7
+ - Reject NTFS alternate data stream archive entry names on Windows before extraction, keep JavaScript and native TAR/ZIP policy aligned, and fix one-code-unit native rename and hardlink metadata buffers.
8
+ - Reject synchronous secret reads when the path is retargeted after the preview check, matching the asynchronous reader's `path-mismatch` contract instead of returning bytes from the replacement file.
9
+ - Preserve dangling symlinks when trash moves cross filesystems instead of failing while following their missing targets.
10
+ - Reject non-canonical FileStore keys and malformed archive names before filesystem access, keep JavaScript/native TAR and ZIP rejection semantics aligned (including full-width base-256 sizes and empty ZIP files), and add deterministic property-based regression coverage for path aliasing, parser boundaries, collisions, truncation, and extraction limits.
11
+
12
+ ### Compatibility
13
+
14
+ - Report filesystem I/O failures from secret, `FileStore`, and temp-workspace twin readers as the new operational `read-failed` code with the original error in `cause`, replacing synchronous secret `invalid-path`, asynchronous secret and synchronous store `path-mismatch`, and raw Node errors; consumers matching the old wrapper or `EIO`-style top-level code should match `read-failed` and inspect `cause.code` instead.
15
+ - Preserve semantic path and validation codes in synchronous `FileStore` and temp-workspace reads: missing temp leaves now report `not-found`, stable directories report `not-file`, and hardlinks and symlinks report `hardlink` and `symlink`, replacing `path-mismatch` and fabricated raw `ENOENT` respectively to match their asynchronous twins; consumers treating either old result as absence or identity drift should match the specific path-state code instead.
16
+ - Report an existing non-directory ancestor as `not-file` from both `assertNoSymlinkParents()` variants, replacing asynchronous success and the synchronous helper's platform-dependent success or raw `ENOTDIR` under default `allowMissing`; callers relying on that acceptance should ensure every existing prefix component is a directory or handle `not-file`.
17
+
18
+ ### Docs and Tooling
19
+
20
+ - Measure coverage once per operating system and merge the platform reports before enforcing thresholds, so coverage reflects existing cross-platform execution rather than implying new test coverage.
21
+ - Refresh Vite and its Rolldown toolchain, and declare the native build CLI's Emscripten runtime peer explicitly.
22
+
23
+ ## 0.5.2 - 2026-08-02
24
+
25
+ ### Security and Correctness
26
+
27
+ - Reject negative, malformed, and oversized base-256 TAR sizes using the full encoded field, preventing high-order size bytes from bypassing archive metadata metering.
28
+ - Preserve unrelated files when the create-only JavaScript `Root.write()` fallback loses a parent-directory race during post-write verification, limiting failure cleanup to the inode created by the operation.
29
+ - Report the documented `not-file` code when a writable Root open reaches a non-regular descriptor on Windows, matching the existing POSIX `EISDIR` mapping.
30
+ - Serialize same-target `Root.write()` and `Root.copyIn()` calls before inspecting the existing destination, so ordinary overlapping writers do not race the mode-preservation open against another writer's atomic replacement.
31
+ - Serialize same-target directory replacements, confine their randomized backup names to the target parent, retain identity-bound exit cleanup after transient atomic/output/move staging cleanup failures, and refuse to publish EXDEV move copies when descriptor-bound mode application fails.
32
+ - Stream native pinned-write inputs only after create-only collision checks, avoiding backend-dependent eager buffering, and preserve explicit zero file modes instead of replacing them with `0o600`.
33
+ - Verify synchronous file-store publication by inode after rename and avoid pathname-based post-publication mode changes, so a raced symlink or hardlink swap fails closed without changing an unrelated target's permissions.
34
+ - Track live JSON-store mutation ownership separately from inherited async context, so continuations scheduled by an update can mutate the store after that update finishes while genuinely nested mutations remain rejected.
35
+ - Serialize private secret and file-store publications by canonical destination, preventing ordinary overlapping writers from tripping the fallback's post-rename identity fence while retaining atomic last-writer-wins replacement.
36
+ - Preserve replacement sidecars when asynchronous lock setup fails, remove an identity-matching `Root`-backed sidecar when its post-create open fails, and clean up synchronous sidecars on normal process exit.
37
+ - Pin each `Root` handle to the canonical root directory identity so root-path replacement cannot expose outside metadata through `stat()`, `exists()`, `list()`, or `walk()`, and preserve a swapped-in writable leaf when failed-open cleanup no longer owns its inode.
38
+ - Fail closed on invalid walk and absolute-path policies, keep standalone walk budgets bounded at runtime, reject final symlinks from absolute writable paths, and observe aborts that arrive during directory listing.
39
+ - Canonicalize configured local-root symlinks, recognize case-insensitive `file:` URL schemes (including unsafe device targets) using the requested platform's path semantics, validate every configured root, confine secure-temp fallback prefixes to one segment, preserve replaced temp-file directories during cleanup, return absolute install paths, and avoid splitting Unicode surrogate pairs while truncating filenames.
40
+ - Reject archive output names that collide after case or Unicode normalization, keeping extraction deterministic across JavaScript/native backends and case-insensitive filesystems.
41
+ - Escape control characters in archive-entry and root-path diagnostics, and reject NUL inputs plus embedded Windows drive-relative segments in the direct sync and async root resolvers before Node can echo raw attacker-controlled paths from a syscall error or resolve the two variants differently.
42
+ - Shell-quote POSIX permission-remediation paths, and report a secret file that grows past the synchronous read limit as `too-large` just like the asynchronous reader.
43
+ - Fence pathname SHA-256 hashing against pre-open identity replacement, propagate bounded-stream source failures while tearing down file streams on early consumer close, and report synchronous regular-file disappearance races as `path-mismatch` consistently with the async reader.
44
+ - Pin synchronous file-store roots across every parent-walk segment, so private and non-private writes reject a store root replaced during directory creation, and route deny-mutation ancestor canonicalization through the shared root resolver on Windows and POSIX.
45
+ - Keep the POSIX parent-directory no-follow identity check active for synchronous atomic-replacement adapters that omit `fchmodSync`, preventing the documented default adapter path from writing through a symlinked parent.
46
+ - Synchronize the actual destination after descriptor-bound mode application when atomic rename uses copy fallback, and include both bytes and mode in bounded fallback restoration.
47
+ - Continue accepting legacy atomic-replacement adapter literals that expose pathname `chmod` or `chmodSync` methods while keeping those methods unused.
48
+ - Reject archive NUL names, drive-relative path segments, duplicate output names (including collisions introduced by `stripComponents`), and ZIP CRC or declared-size mismatches before publishing output. Explicit zero archive limits now remain zero, and stripped TAR entries count toward `maxEntries`, keeping the native and JavaScript policies aligned.
49
+
50
+ - Reject non-file sidecars without spinning, and read contended async and synchronous sidecar locks through bounded, no-follow, identity-checked descriptors; keep valid `createdAt` timestamps authoritative under filesystem clock skew; fail closed when fallback Windows ACL inspection returns no verifiable access entries; preserve synchronous stale-reclaim guards owned by another acquirer; and share one process-exit cleanup listener across file-lock manager domains.
51
+
52
+ - Route `Root.copyIn()` and overwrite-capable `Root.write()` commits through a new descriptor-relative native replace rename, closing a parent-symlink swap that could create a missing destination directory outside the root before the JavaScript fallback detected the escape. Native `auto` and `require` mode now protect both create-only and replacing pinned writes; the explicitly best-effort JavaScript fallback remains available in `off` mode or when `auto` cannot load a binding.
53
+ - Report `not-found` rather than `invalid-path` or raw `ENOENT` when pinned `Root.write()` and `Root.copyIn()` calls cannot find their parent with `mkdir: false`, preserving the documented operational error category.
54
+ - Reject stable intermediate symlinks in default `Root` reads and the sync/async root-file helpers, while preserving `device-path` precedence for lexically explicit unsafe namespaces such as `/dev/fd`; compare the pre-open path identity with the opened descriptor so an in-root parent swap reports `path-mismatch`, open reads nonblocking where supported so a raced FIFO cannot pin a worker, and report followed symlink loops with the documented `symlink` code.
55
+ - Reject surrounding whitespace in every async and sync `FileStore` key instead of silently trimming one caller-supplied key onto another.
56
+ - Keep Windows-ignored trailing spaces and dots from disguising reserved device basenames such as `CON .` in `sanitizeUntrustedFileName()` output.
57
+ - Report overlong `Root` inputs as `invalid-path` instead of misclassifying the filesystem's `ENAMETOOLONG` failure as `outside-workspace`.
58
+
59
+ - Apply `movePathWithCopyFallback()` file and POSIX directory modes through staging descriptors before publication, so a replaceable staging pathname cannot redirect `chmod` to an unrelated symlink target. Windows no longer uses a pathname fallback for directory modes because Node cannot portably open a directory descriptor there and does not enforce POSIX modes.
60
+
61
+ - Reject drive-relative segments such as `C:secret.txt` and `a/C:b` in portable relative-path parsing and every `FileStore` key, and reject leading drive-relative spellings on `Root` destinations and `resolve()` with `invalid-path`. Existing-object Root operations, including reads, inspection, removal, and the source of `move()`, continue to accept legal POSIX filenames such as `c:notes.txt`; thanks @Yigtwxx for the fix.
62
+
63
+ - Apply atomic-replacement parent-directory modes through verified no-follow descriptors on POSIX, so a directory-entry swap cannot redirect `chmod` through a symlink to an unrelated directory. Windows keeps its explicit `mkdir(mode)`-only behavior because Node does not enforce POSIX directory modes there.
64
+ - Retry a contended file-lock acquisition when Windows denies access to a lock file whose directory entry is still being torn down, including when the native binding performs the exclusive create. Both the exclusive create and the holder's snapshot read reported that transient `EPERM` as a hard failure, so concurrent `acquireFileLock()` calls failed intermittently on Windows even though the very next attempt would have succeeded. Retries stay bounded, so a genuine permission denial still surfaces as `EPERM` rather than a lock timeout; thanks @Yigtwxx for the fix.
65
+ - Apply `replaceFileAtomic()` and `replaceFileAtomicSync()` modes through pinned temp-file descriptors before rename, and through pinned copy-fallback descriptors, so a post-rename symlink swap cannot redirect `chmod` to an unrelated file while exact modes remain independent of umask; thanks @yetval for reporting this (#86).
66
+ - Bound fallback Windows owner and ACL command execution to 30 seconds per process, tolerating loaded runners while still terminating wedged commands, and report owner-query failures as unverified instead of returning a partial permission classification; thanks @Yigtwxx for the diagnosis.
67
+ - Verify published npm bytes directly when registry integrity metadata conflicts, and cryptographically verify the registry signature plus Sigstore provenance against the package digest and the trusted fs-safe release workflow (#81).
68
+ - Report the documented `remove()` failure codes instead of collapsing every failure to `path-alias`. A missing target now throws `not-found`, a non-empty directory throws `not-empty`, and any other filesystem failure throws `not-removable`, while directory identity drift keeps reporting `path-mismatch`; thanks @Yigtwxx for the fix.
69
+
70
+ ### Compatibility
71
+
72
+ - Report access-denied failures from native Windows operations as `EPERM` rather than `EACCES`, matching Node/libuv and the JavaScript fallback. Consumers that matched the previous native-only `EACCES` code should accept `EPERM` as well when supporting older package versions.
73
+ - Add an optional `fchmodSync` operation to the injectable synchronous atomic-replacement filesystem type. Async adapters need no new member because their required `open()` returns a mode-capable `FileHandle`; custom sync adapters that pass `mode`, `dirMode`, or `preserveExistingMode` now fail before mutation when `fchmodSync` is absent, while plain `node:fs` injection and adapters that request none of those options remain compatible.
74
+ - Classify `not-found`, `not-empty` and `not-removable` as operational rather than policy errors, so `FsSafeError.category` no longer reports routine filesystem outcomes as safety-policy rejections.
75
+
76
+ ### Features
77
+
78
+ - Suffix Windows reserved basenames with `_` in `sanitizeUntrustedFileName()` while preserving case and extensions on every platform, including dollar names and superscript COM/LPT variants; thanks @SebTardif (#67).
79
+
80
+ ### Docs and Tooling
81
+
82
+ - Refresh the native package build CLI to `@napi-rs/cli` 3.8.2.
83
+ - Give parallel native archive tests collision-free temporary paths so one fixture cannot remove another test's file on coarse-resolution clocks.
84
+ - Include the README banner in the npm tarball and require it during pack checks so the published README does not reference a missing package asset; sanitize pnpm-only npm configuration from package-smoke subprocesses so the documented release check stays warning-free on newer npm versions.
85
+ - Audit every public export and documented default against generated declarations and real filesystem behavior, add executable documentation-contract coverage, and correct stale examples and security guarantees across root writes, local roots, shared types, paths, temp roots, archives, errors, and filename portability.
86
+
87
+ - Create GitHub Releases as drafts before npm publication, then attach immutable verification proof and promote them only after the published package passes the shared registry verifier, avoiding stranded releases during registry propagation incidents (#81).
88
+
3
89
  ## 0.5.1 - 2026-08-01
4
90
 
5
91
  ### Security and Correctness
package/README.md CHANGED
@@ -72,6 +72,14 @@ configureFsSafeNative({ mode: "off" }); // guarded JavaScript only
72
72
  configureFsSafeNative({ mode: "require" }); // fail closed if the binding is unavailable
73
73
  ```
74
74
 
75
+ Native mode performs `write()`, `create()`, and `copyIn()` parent creation and
76
+ publication relative to pinned directory descriptors, including atomic
77
+ replacement. The JavaScript path selected by `off`, or by `auto` when no
78
+ binding can load, is explicitly best-effort: a same-privilege peer that can
79
+ replace a writable parent between its identity check and Node's pathname
80
+ mutation can redirect that mutation outside the root before the post-check
81
+ reports the escape. Use `require` when hostile concurrent mutation is in scope.
82
+
75
83
  Equivalent env var: `FS_SAFE_NATIVE_MODE=auto|off|require`. All seven binaries
76
84
  ship inside `@openclaw/fs-safe`; there are no platform packages, postinstall
77
85
  steps, downloads, or consumer Rust builds. This makes the tarball larger than
@@ -179,7 +187,7 @@ await using opened = await fs.openWritable("logs/current.log", { writeMode: "app
179
187
  }
180
188
  ```
181
189
 
182
- `nonBlockingRead` is the only I/O scheduling knob in `RootDefaults`; it applies to read/open operations because it changes how file descriptors are opened. Filesystem safety policy remains explicit through `hardlinks`, `symlinks`, and `denyMutations`.
190
+ `nonBlockingRead` remains as a compatibility hint in `RootDefaults`. Safe read/open operations already use nonblocking descriptor opens where the platform supports them so a raced FIFO cannot pin a worker; filesystem safety policy remains explicit through `hardlinks`, `symlinks`, and `denyMutations`.
183
191
 
184
192
  ```ts
185
193
  const locked = await root("/srv/workspace", {
@@ -276,7 +284,7 @@ publication policy, creation callback, and platform contract.
276
284
 
277
285
  ## Atomic writes
278
286
 
279
- `replaceFileAtomic()` writes a sibling temp file, optionally fsyncs it, and renames it over the destination. Mode preservation, pinned-destination hardlink rejection, rename retry / copy fallback on `EPERM`, bounded original-content restoration after a torn fallback, parent-directory fsync, and a `beforeRename` hook for backup or observer flows are all opt-in. `movePathWithCopyFallback()` stages cross-device moves before commit and removes only the copied source entries, so concurrent source additions or replacements are preserved.
287
+ `replaceFileAtomic()` writes a sibling temp file, applies its exact mode through the still-open descriptor, optionally fsyncs it, and renames it over the destination. It never follows the published destination path to set file permissions. Mode preservation, pinned-destination hardlink rejection, rename retry / copy fallback on `EPERM`, bounded original-content restoration after a torn fallback, parent-directory fsync, and a `beforeRename` hook for backup or observer flows are all opt-in. `movePathWithCopyFallback()` stages cross-device moves before commit and removes only the copied source entries, so concurrent source additions or replacements are preserved.
280
288
 
281
289
  ```ts
282
290
  import { replaceFileAtomic } from "@openclaw/fs-safe/atomic";
@@ -290,7 +298,7 @@ await replaceFileAtomic({
290
298
  });
291
299
  ```
292
300
 
293
- `replaceFileAtomicSync()` covers the synchronous case with the same options shape. Both accept an injectable `fileSystem` for tests.
301
+ `replaceFileAtomicSync()` covers the synchronous case with the same options shape. Both accept an injectable `fileSystem` for tests. Async adapters use `chmod()` on the `FileHandle` returned by their required `open()` operation; custom sync adapters using `mode` or `preserveExistingMode` provide the optional descriptor-bound `fchmodSync` operation.
294
302
 
295
303
  ## External outputs
296
304
 
@@ -506,14 +514,19 @@ Codes are grouped by category:
506
514
  ```ts
507
515
  if (err instanceof FsSafeError) {
508
516
  if (err.category === "policy") {
509
- // Unsafe caller input or filesystem state.
517
+ // Unsafe caller input or filesystem state rejected by a safety policy.
510
518
  } else {
511
- // Operational problem such as helper startup, timeout, or unverifiable permissions.
519
+ // Routine filesystem outcome or runtime/environment problem.
512
520
  }
513
521
  }
514
522
  ```
515
523
 
516
- Current `FsSafeErrorCode` values are `already-exists`, `denied-path`, `device-path`, `hardlink`, `helper-failed`, `helper-unavailable`, `invalid-path`, `insecure-permissions`, `not-empty`, `not-file`, `not-found`, `not-owned`, `not-removable`, `outside-workspace`, `path-alias`, `path-mismatch`, `permission-unverified`, `secret-exists`, `symlink`, `timeout`, `too-large`, and `unsupported-platform`.
524
+ Routine filesystem outcomes such as `not-found`, `not-empty`, and
525
+ `not-removable`, plus runtime failures such as `read-failed`, are operational;
526
+ they do not indicate that a filesystem
527
+ boundary policy was violated.
528
+
529
+ Current `FsSafeErrorCode` values are `already-exists`, `denied-path`, `device-path`, `hardlink`, `helper-failed`, `helper-unavailable`, `invalid-path`, `insecure-permissions`, `not-empty`, `not-file`, `not-found`, `not-owned`, `not-removable`, `outside-workspace`, `path-alias`, `path-mismatch`, `permission-unverified`, `read-failed`, `secret-exists`, `store-reentrant-update`, `symlink`, `timeout`, `too-large`, and `unsupported-platform`.
517
530
 
518
531
  ## Safety model
519
532
 
@@ -1 +1 @@
1
- {"version":3,"file":"absolute-path.d.ts","sourceRoot":"","sources":["../src/absolute-path.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,WAAW,EAAE,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAEhE,MAAM,MAAM,yBAAyB,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE5D,MAAM,MAAM,oBAAoB,GAAG;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,4BAA4B,GAAG,oBAAoB,GAAG;IAChE,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,OAAO,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,8BAA8B,GAAG;IAC3C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,6BAA6B,GACrC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC1B;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,eAAe,CAAC;IAAC,KAAK,EAAE,WAAW,CAAA;CAAE,CAAC;AAmL7D,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAWhE;AAWD,wBAAsB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAEnF;AAuBD,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,8BAAmC,GAC3C,OAAO,CAAC,6BAA6B,CAAC,CA+ExC;AAED,wBAAsB,iCAAiC,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAazF;AAED,wBAAsB,0BAA0B,CAC9C,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,yBAAyB,CAAA;CAAO,GACrD,OAAO,CAAC,oBAAoB,CAAC,CAe/B;AAED,wBAAsB,2BAA2B,CAC/C,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,yBAAyB,CAAA;CAAO,GACrD,OAAO,CAAC,4BAA4B,CAAC,CAsBvC"}
1
+ {"version":3,"file":"absolute-path.d.ts","sourceRoot":"","sources":["../src/absolute-path.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,WAAW,EAAE,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAGhE,MAAM,MAAM,yBAAyB,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAU5D,MAAM,MAAM,oBAAoB,GAAG;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,4BAA4B,GAAG,oBAAoB,GAAG;IAChE,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,OAAO,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,8BAA8B,GAAG;IAC3C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,6BAA6B,GACrC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC1B;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,eAAe,CAAC;IAAC,KAAK,EAAE,WAAW,CAAA;CAAE,CAAC;AAmL7D,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAWhE;AAED,wBAAsB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAEnF;AAuBD,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,8BAAmC,GAC3C,OAAO,CAAC,6BAA6B,CAAC,CA+ExC;AAED,wBAAsB,iCAAiC,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAazF;AAED,wBAAsB,0BAA0B,CAC9C,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,yBAAyB,CAAA;CAAO,GACrD,OAAO,CAAC,oBAAoB,CAAC,CAgB/B;AAED,wBAAsB,2BAA2B,CAC/C,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,yBAAyB,CAAA;CAAO,GACrD,OAAO,CAAC,4BAA4B,CAAC,CA6BvC"}
@@ -2,6 +2,15 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard, } from "./directory-guard.js";
4
4
  import { FsSafeError } from "./errors.js";
5
+ import { pathExists } from "./fs.js";
6
+ function resolveSymlinkPolicy(policy) {
7
+ if (policy === undefined)
8
+ return "reject";
9
+ if (policy !== "reject" && policy !== "follow") {
10
+ throw new TypeError(`invalid absolute path symlink policy: ${String(policy)}`);
11
+ }
12
+ return policy;
13
+ }
5
14
  function ensureDirectoryFailure(code, message, cause) {
6
15
  return {
7
16
  ok: false,
@@ -138,15 +147,6 @@ export function assertAbsolutePathInput(filePath) {
138
147
  }
139
148
  return path.normalize(filePath);
140
149
  }
141
- async function pathExists(filePath) {
142
- try {
143
- await fs.access(filePath);
144
- return true;
145
- }
146
- catch {
147
- return false;
148
- }
149
- }
150
150
  export async function findExistingAncestor(filePath) {
151
151
  return (await findExistingAncestorWithStat(filePath))?.path ?? null;
152
152
  }
@@ -258,6 +258,7 @@ export async function canonicalPathFromExistingAncestor(filePath) {
258
258
  return relative ? path.join(canonicalAncestor, relative) : canonicalAncestor;
259
259
  }
260
260
  export async function resolveAbsolutePathForRead(filePath, options = {}) {
261
+ const symlinks = resolveSymlinkPolicy(options.symlinks);
261
262
  const normalized = assertAbsolutePathInput(filePath);
262
263
  let canonicalPath;
263
264
  try {
@@ -269,16 +270,17 @@ export async function resolveAbsolutePathForRead(filePath, options = {}) {
269
270
  }
270
271
  throw err;
271
272
  }
272
- if ((options.symlinks ?? "reject") === "reject" && canonicalPath !== normalized) {
273
+ if (symlinks === "reject" && canonicalPath !== normalized) {
273
274
  throw new FsSafeError("symlink", "path traverses a symlink", { cause: { canonicalPath } });
274
275
  }
275
276
  return { path: normalized, canonicalPath };
276
277
  }
277
278
  export async function resolveAbsolutePathForWrite(filePath, options = {}) {
279
+ const symlinks = resolveSymlinkPolicy(options.symlinks);
278
280
  const normalized = assertAbsolutePathInput(filePath);
279
281
  const parentDir = path.dirname(normalized);
280
282
  const parentExists = await pathExists(parentDir);
281
- if ((options.symlinks ?? "reject") === "reject") {
283
+ if (symlinks === "reject") {
282
284
  const ancestor = await findExistingAncestor(parentDir);
283
285
  if (ancestor) {
284
286
  const canonicalAncestor = await fs.realpath(ancestor).catch(() => ancestor);
@@ -290,9 +292,15 @@ export async function resolveAbsolutePathForWrite(filePath, options = {}) {
290
292
  }
291
293
  }
292
294
  }
295
+ const canonicalPath = await canonicalPathFromExistingAncestor(normalized);
296
+ if (symlinks === "reject" && canonicalPath !== normalized) {
297
+ throw new FsSafeError("symlink", "path traverses a symlink", {
298
+ cause: { canonicalPath },
299
+ });
300
+ }
293
301
  return {
294
302
  path: normalized,
295
- canonicalPath: await canonicalPathFromExistingAncestor(normalized),
303
+ canonicalPath,
296
304
  parentDir,
297
305
  parentExists,
298
306
  };
@@ -4,6 +4,7 @@ export declare function validateArchiveEntryPath(entryPath: string, params?: {
4
4
  escapeLabel?: string;
5
5
  }): void;
6
6
  export declare function stripArchivePath(entryPath: string, stripComponents: number): string | null;
7
+ export declare function createArchiveOutputPathTracker(): (entryPath: string, originalPath: string) => void;
7
8
  export declare function resolveArchiveOutputPath(params: {
8
9
  rootDir: string;
9
10
  relPath: string;
@@ -1 +1 @@
1
- {"version":3,"file":"archive-entry.d.ts","sourceRoot":"","sources":["../src/archive-entry.ts"],"names":[],"mappings":"AAIA,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAEzD;AAED,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE7D;AAED,wBAAgB,wBAAwB,CACtC,SAAS,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE;IAAE,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,GAChC,IAAI,CAkBN;AAED,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAc1F;AAED,wBAAgB,wBAAwB,CAAC,MAAM,EAAE;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GAAG,MAAM,CAWT"}
1
+ {"version":3,"file":"archive-entry.d.ts","sourceRoot":"","sources":["../src/archive-entry.ts"],"names":[],"mappings":"AAKA,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAIzD;AAED,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE7D;AAED,wBAAgB,wBAAwB,CACtC,SAAS,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE;IAAE,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,GAChC,IAAI,CA2DN;AAED,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAc1F;AAED,wBAAgB,8BAA8B,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,IAAI,CAgBlG;AAED,wBAAgB,wBAAwB,CAAC,MAAM,EAAE;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GAAG,MAAM,CAWT"}
@@ -1,8 +1,11 @@
1
1
  import path from "node:path";
2
2
  import { ArchiveSecurityError } from "./archive-errors.js";
3
+ import { formatErrorDetail } from "./error-detail.js";
3
4
  import { resolveSafeBaseDir } from "./path.js";
4
5
  export function isWindowsDrivePath(value) {
5
- return /^[a-zA-Z]:[\\/]/.test(value);
6
+ return normalizeArchiveEntryPath(value)
7
+ .split("/")
8
+ .some((segment) => /^[a-zA-Z]:/.test(segment));
6
9
  }
7
10
  export function normalizeArchiveEntryPath(raw) {
8
11
  return raw.replaceAll("\\", "/");
@@ -12,15 +15,29 @@ export function validateArchiveEntryPath(entryPath, params) {
12
15
  return;
13
16
  }
14
17
  if (isWindowsDrivePath(entryPath)) {
15
- throw new ArchiveSecurityError("entry-path", `archive entry uses a drive path: ${entryPath}`);
18
+ throw new ArchiveSecurityError("entry-path", `archive entry uses a drive path: ${formatErrorDetail(entryPath)}`);
19
+ }
20
+ if (entryPath.includes("\0")) {
21
+ throw new ArchiveSecurityError("entry-path", `archive entry contains a NUL byte: ${formatErrorDetail(entryPath)}`);
22
+ }
23
+ const slashNormalized = normalizeArchiveEntryPath(entryPath);
24
+ if (process.platform === "win32" &&
25
+ slashNormalized.split("/").some((segment) => segment.includes(":"))) {
26
+ throw new ArchiveSecurityError("entry-path", `archive entry uses a Windows alternate data stream path: ${formatErrorDetail(entryPath)}`);
27
+ }
28
+ const normalized = path.posix.normalize(slashNormalized);
29
+ if (normalized.split("/").some((segment) => Math.max(Buffer.byteLength(segment.normalize("NFC")), Buffer.byteLength(segment.normalize("NFD"))) > 255)) {
30
+ throw new ArchiveSecurityError("entry-path", `archive entry has an overlong path component: ${formatErrorDetail(entryPath)}`);
16
31
  }
17
- const normalized = path.posix.normalize(normalizeArchiveEntryPath(entryPath));
18
32
  const escapeLabel = params?.escapeLabel ?? "destination";
19
33
  if (normalized === ".." || normalized.startsWith("../")) {
20
- throw new ArchiveSecurityError("entry-path", `archive entry escapes ${escapeLabel}: ${entryPath}`);
34
+ throw new ArchiveSecurityError("entry-path", `archive entry escapes ${escapeLabel}: ${formatErrorDetail(entryPath)}`);
21
35
  }
22
36
  if (path.posix.isAbsolute(normalized) || normalized.startsWith("//")) {
23
- throw new ArchiveSecurityError("entry-path", `archive entry is absolute: ${entryPath}`);
37
+ throw new ArchiveSecurityError("entry-path", `archive entry is absolute: ${formatErrorDetail(entryPath)}`);
38
+ }
39
+ if (slashNormalized.split("/").includes("..")) {
40
+ throw new ArchiveSecurityError("entry-path", `archive entry contains a parent segment: ${formatErrorDetail(entryPath)}`);
24
41
  }
25
42
  }
26
43
  export function stripArchivePath(entryPath, stripComponents) {
@@ -37,12 +54,26 @@ export function stripArchivePath(entryPath, stripComponents) {
37
54
  }
38
55
  return result;
39
56
  }
57
+ export function createArchiveOutputPathTracker() {
58
+ const seen = new Set();
59
+ return (entryPath, originalPath) => {
60
+ const normalized = path.posix.normalize(normalizeArchiveEntryPath(entryPath));
61
+ // Archive policy must not depend on the destination volume's case or
62
+ // Unicode-normalization behavior. Otherwise the JavaScript and native
63
+ // writers can disagree about which of two colliding entries wins.
64
+ const collisionKey = normalized.normalize("NFC").toLowerCase().normalize("NFC");
65
+ if (seen.has(collisionKey)) {
66
+ throw new ArchiveSecurityError("entry-path", `archive entries collide at output path ${formatErrorDetail(normalized)}: ${formatErrorDetail(originalPath)}`);
67
+ }
68
+ seen.add(collisionKey);
69
+ };
70
+ }
40
71
  export function resolveArchiveOutputPath(params) {
41
72
  const safeBase = resolveSafeBaseDir(params.rootDir);
42
73
  const outPath = path.resolve(params.rootDir, params.relPath);
43
74
  const escapeLabel = params.escapeLabel ?? "destination";
44
75
  if (!outPath.startsWith(safeBase)) {
45
- throw new ArchiveSecurityError("entry-path", `archive entry escapes ${escapeLabel}: ${params.originalPath}`);
76
+ throw new ArchiveSecurityError("entry-path", `archive entry escapes ${escapeLabel}: ${formatErrorDetail(params.originalPath)}`);
46
77
  }
47
78
  return outPath;
48
79
  }
@@ -8,4 +8,5 @@ export declare class ArchiveFormatError extends Error {
8
8
  readonly code: ArchiveFormatErrorCode;
9
9
  constructor(message: string, options?: ErrorOptions);
10
10
  }
11
+ export declare function isArchiveFormatErrorMessage(message: string): boolean;
11
12
  //# sourceMappingURL=archive-errors.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"archive-errors.d.ts","sourceRoot":"","sources":["../src/archive-errors.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,sBAAsB,GAAG,wBAAwB,CAAC;AAE9D,MAAM,MAAM,wBAAwB,GAChC,2BAA2B,GAC3B,qBAAqB,GACrB,+BAA+B,GAC/B,gBAAgB,GAChB,YAAY,GACZ,YAAY,CAAC;AAEjB,qBAAa,oBAAqB,SAAQ,KAAK;IAC7C,QAAQ,CAAC,IAAI,EAAE,wBAAwB,CAAC;IAExC,YAAY,IAAI,EAAE,wBAAwB,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,EAIlF;CACF;AAED,qBAAa,kBAAmB,SAAQ,KAAK;IAC3C,QAAQ,CAAC,IAAI,EAAE,sBAAsB,CAAC;IAEtC,YAAY,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,EAIlD;CACF"}
1
+ {"version":3,"file":"archive-errors.d.ts","sourceRoot":"","sources":["../src/archive-errors.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,sBAAsB,GAAG,wBAAwB,CAAC;AAE9D,MAAM,MAAM,wBAAwB,GAChC,2BAA2B,GAC3B,qBAAqB,GACrB,+BAA+B,GAC/B,gBAAgB,GAChB,YAAY,GACZ,YAAY,CAAC;AAEjB,qBAAa,oBAAqB,SAAQ,KAAK;IAC7C,QAAQ,CAAC,IAAI,EAAE,wBAAwB,CAAC;IAExC,YAAY,IAAI,EAAE,wBAAwB,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,EAIlF;CACF;AAED,qBAAa,kBAAmB,SAAQ,KAAK;IAC3C,QAAQ,CAAC,IAAI,EAAE,sBAAsB,CAAC;IAEtC,YAAY,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,EAIlD;CACF;AAED,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAKpE"}
@@ -14,3 +14,7 @@ export class ArchiveFormatError extends Error {
14
14
  this.code = "archive-header-invalid";
15
15
  }
16
16
  }
17
+ export function isArchiveFormatErrorMessage(message) {
18
+ return (message.includes("archive-header-invalid") ||
19
+ message.includes("archive entry size did not match its manifest"));
20
+ }
@@ -1,9 +1,16 @@
1
+ import type { FileHandle } from "node:fs/promises";
1
2
  import type { ExtractionDeadline } from "./archive-deadline.js";
2
3
  import { type ResolvedArchiveExtractLimits } from "./archive-limits.js";
3
4
  export type StagedArchiveFile = {
4
5
  path: string;
5
6
  cleanup: () => Promise<void>;
6
7
  };
8
+ export declare function writeFileHandleFully(params: {
9
+ handle: FileHandle;
10
+ buffer: Buffer;
11
+ bytes: number;
12
+ deadline: ExtractionDeadline;
13
+ }): Promise<void>;
7
14
  export declare function stageArchiveFileForExtraction(params: {
8
15
  archivePath: string;
9
16
  limits: ResolvedArchiveExtractLimits;
@@ -1 +1 @@
1
- {"version":3,"file":"archive-input.d.ts","sourceRoot":"","sources":["../src/archive-input.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAEhE,OAAO,EAGL,KAAK,4BAA4B,EAClC,MAAM,qBAAqB,CAAC;AAI7B,MAAM,MAAM,iBAAiB,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CAAC;AAM/E,wBAAsB,6BAA6B,CAAC,MAAM,EAAE;IAC1D,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,4BAA4B,CAAC;IACrC,QAAQ,EAAE,kBAAkB,CAAC;CAC9B,GAAG,OAAO,CAAC,iBAAiB,CAAC,CA8D7B"}
1
+ {"version":3,"file":"archive-input.d.ts","sourceRoot":"","sources":["../src/archive-input.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAGnD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAGL,KAAK,4BAA4B,EAClC,MAAM,qBAAqB,CAAC;AAI7B,MAAM,MAAM,iBAAiB,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CAAC;AAM/E,wBAAsB,oBAAoB,CAAC,MAAM,EAAE;IACjD,MAAM,EAAE,UAAU,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,kBAAkB,CAAC;CAC9B,GAAG,OAAO,CAAC,IAAI,CAAC,CAchB;AAED,wBAAsB,6BAA6B,CAAC,MAAM,EAAE;IAC1D,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,4BAA4B,CAAC;IACrC,QAAQ,EAAE,kBAAkB,CAAC;CAC9B,GAAG,OAAO,CAAC,iBAAiB,CAAC,CA8D7B"}
@@ -1,7 +1,6 @@
1
1
  import { constants as fsConstants } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { writeFileHandleFully } from "./archive-file-io.js";
5
4
  import { ARCHIVE_LIMIT_ERROR_CODE, ArchiveLimitError, } from "./archive-limits.js";
6
5
  import { sameFileIdentity } from "./file-identity.js";
7
6
  import { tempFile } from "./temp-target.js";
@@ -9,6 +8,17 @@ async function closeFileHandle(handle) {
9
8
  if (handle)
10
9
  await handle.close().catch(() => undefined);
11
10
  }
11
+ export async function writeFileHandleFully(params) {
12
+ let offset = 0;
13
+ while (offset < params.bytes) {
14
+ params.deadline.check();
15
+ const { bytesWritten } = await params.handle.write(params.buffer, offset, params.bytes - offset);
16
+ if (bytesWritten <= 0) {
17
+ throw new Error("archive staging write made no progress");
18
+ }
19
+ offset += bytesWritten;
20
+ }
21
+ }
12
22
  export async function stageArchiveFileForExtraction(params) {
13
23
  params.deadline.check();
14
24
  const sourcePath = path.resolve(params.archivePath);
@@ -36,7 +36,7 @@ function clampLimit(value) {
36
36
  return undefined;
37
37
  }
38
38
  const v = Math.floor(value);
39
- return v > 0 ? v : undefined;
39
+ return v >= 0 ? v : undefined;
40
40
  }
41
41
  export function resolveExtractLimits(limits) {
42
42
  // Defaults: defensive, but should not break normal installs.
@@ -1 +1 @@
1
- {"version":3,"file":"archive-native.d.ts","sourceRoot":"","sources":["../src/archive-native.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAEhE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,EAOL,KAAK,oBAAoB,EAC1B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAOlE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAoBjD,wBAAsB,oBAAoB,CAAC,MAAM,EAAE;IACjD,OAAO,EAAE,aAAa,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,WAAW,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,MAAM,CAAC,EAAE,oBAAoB,CAAC;IAC9B,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,UAAU,CAAC,EAAE,qBAAqB,CAAC,YAAY,CAAC,CAAC;IACjD,WAAW,CAAC,EAAE,qBAAqB,CAAC,aAAa,CAAC,CAAC;IACnD,UAAU,CAAC,EAAE,qBAAqB,CAAC,YAAY,CAAC,CAAC;CAClD,GAAG,OAAO,CAAC,IAAI,CAAC,CAsHhB"}
1
+ {"version":3,"file":"archive-native.d.ts","sourceRoot":"","sources":["../src/archive-native.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAEhE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,EAOL,KAAK,oBAAoB,EAC1B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAOlE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAuBjD,wBAAsB,oBAAoB,CAAC,MAAM,EAAE;IACjD,OAAO,EAAE,aAAa,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,WAAW,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,MAAM,CAAC,EAAE,oBAAoB,CAAC;IAC9B,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,UAAU,CAAC,EAAE,qBAAqB,CAAC,YAAY,CAAC,CAAC;IACjD,WAAW,CAAC,EAAE,qBAAqB,CAAC,aAAa,CAAC,CAAC;IACnD,UAAU,CAAC,EAAE,qBAAqB,CAAC,YAAY,CAAC,CAAC;CAClD,GAAG,OAAO,CAAC,IAAI,CAAC,CAwHhB"}
@@ -1,7 +1,8 @@
1
1
  import { constants as fsConstants } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
- import { ArchiveFormatError, ArchiveSecurityError } from "./archive-errors.js";
4
- import { resolveArchiveOutputPath, stripArchivePath, validateArchiveEntryPath } from "./archive-entry.js";
3
+ import { ArchiveFormatError, ArchiveSecurityError, isArchiveFormatErrorMessage, } from "./archive-errors.js";
4
+ import { formatErrorDetail } from "./error-detail.js";
5
+ import { createArchiveOutputPathTracker, resolveArchiveOutputPath, stripArchivePath, validateArchiveEntryPath, } from "./archive-entry.js";
5
6
  import { stageArchiveFileForExtraction } from "./archive-input.js";
6
7
  import { ARCHIVE_LIMIT_ERROR_CODE, ArchiveLimitError, assertArchiveEntryCountWithinLimit, assertArchiveEntryPathComponentsWithinLimit, createByteBudgetTracker, resolveExtractLimits, } from "./archive-limits.js";
7
8
  import { resolveArchiveEntryMode, shouldExtractArchiveEntry } from "./archive-policy.js";
@@ -19,9 +20,12 @@ function throwMappedNativeError(error) {
19
20
  if (error.message.includes(code))
20
21
  throw new ArchiveLimitError(code);
21
22
  }
22
- if (error.message.includes("archive-header-invalid")) {
23
+ if (isArchiveFormatErrorMessage(error.message)) {
23
24
  throw new ArchiveFormatError(error.message, { cause: error });
24
25
  }
26
+ if (error.code === "InvalidArg") {
27
+ throw new ArchiveFormatError(`invalid archive: ${error.message}`, { cause: error });
28
+ }
25
29
  }
26
30
  throw error;
27
31
  }
@@ -45,6 +49,7 @@ export async function extractNativeArchive(params) {
45
49
  assertArchiveEntryCountWithinLimit(manifest.length, limits);
46
50
  const strip = Math.max(0, Math.floor(params.stripComponents ?? 0));
47
51
  const budget = createByteBudgetTracker(limits);
52
+ const trackOutputPath = createArchiveOutputPathTracker();
48
53
  const plan = [];
49
54
  for (const entry of manifest) {
50
55
  params.deadline.check();
@@ -54,6 +59,7 @@ export async function extractNativeArchive(params) {
54
59
  continue;
55
60
  validateArchiveEntryPath(relPath);
56
61
  assertArchiveEntryPathComponentsWithinLimit(relPath, limits);
62
+ trackOutputPath(relPath, entry.path);
57
63
  resolveArchiveOutputPath({ rootDir: stagingDir, relPath, originalPath: entry.path });
58
64
  const kind = policyKind(entry.kind);
59
65
  if (!shouldExtractArchiveEntry({
@@ -64,11 +70,11 @@ export async function extractNativeArchive(params) {
64
70
  continue;
65
71
  }
66
72
  if (entry.kind === "sparse") {
67
- throw new ArchiveFormatError(`GNU sparse archive entry is not supported: ${entry.path}`);
73
+ throw new ArchiveFormatError(`GNU sparse archive entry is not supported: ${formatErrorDetail(entry.path)}`);
68
74
  }
69
75
  if (kind === "symlink") {
70
76
  const label = params.kind === "zip" ? "zip" : "tar";
71
- throw new ArchiveSecurityError("entry-link", `${label} entry is a link: ${entry.path}`);
77
+ throw new ArchiveSecurityError("entry-link", `${label} entry is a link: ${formatErrorDetail(entry.path)}`);
72
78
  }
73
79
  if (!Number.isSafeInteger(entry.size) || entry.size < 0) {
74
80
  throw new ArchiveLimitError(ARCHIVE_LIMIT_ERROR_CODE.ENTRY_EXTRACTED_SIZE_EXCEEDS_LIMIT);
@@ -95,7 +101,7 @@ export async function extractNativeArchive(params) {
95
101
  (typeof fsConstants.O_DIRECTORY === "number" ? fsConstants.O_DIRECTORY : 0));
96
102
  try {
97
103
  params.deadline.check();
98
- await params.binding.extractArchiveNative(stagedArchive.path, params.kind, directory.fd, plan, limits.maxMetaEntryBytes, params.deadline.signal);
104
+ await params.binding.extractArchiveNative(stagedArchive.path, params.kind, directory.fd, plan, limits.maxMetaEntryBytes, params.deadline.signal).catch(throwMappedNativeError);
99
105
  }
100
106
  finally {
101
107
  await directory.close().catch(() => undefined);
@@ -1 +1 @@
1
- {"version":3,"file":"archive-policy.d.ts","sourceRoot":"","sources":["../src/archive-policy.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,WAAW,GAAG,SAAS,GAAG,OAAO,CAAC;AAC1E,MAAM,MAAM,sBAAsB,GAAG,OAAO,GAAG,UAAU,CAAC;AAC1D,MAAM,MAAM,0BAA0B,GAAG,gBAAgB,GAAG,YAAY,CAAC;AACzE,MAAM,MAAM,kBAAkB,GAAG,CAAC,KAAK,EAAE;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,gBAAgB,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;CACd,KAAK,SAAS,GAAG,MAAM,CAAC;AAEzB,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,CAK1E;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE;IAC9C,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;IAC3B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,EAAE,sBAAsB,CAAC;CACjC,GAAG,MAAM,CAST;AAED,wBAAgB,yBAAyB,CAAC,MAAM,EAAE;IAChD,MAAM,CAAC,EAAE,kBAAkB,CAAC;IAC5B,UAAU,CAAC,EAAE,0BAA0B,CAAC;IACxC,KAAK,EAAE,UAAU,CAAC,kBAAkB,CAAC,CAAC,CAAC,CAAC,CAAC;CAC1C,GAAG,OAAO,CAWV"}
1
+ {"version":3,"file":"archive-policy.d.ts","sourceRoot":"","sources":["../src/archive-policy.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,WAAW,GAAG,SAAS,GAAG,OAAO,CAAC;AAC1E,MAAM,MAAM,sBAAsB,GAAG,OAAO,GAAG,UAAU,CAAC;AAC1D,MAAM,MAAM,0BAA0B,GAAG,gBAAgB,GAAG,YAAY,CAAC;AACzE,MAAM,MAAM,kBAAkB,GAAG,CAAC,KAAK,EAAE;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,gBAAgB,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;CACd,KAAK,SAAS,GAAG,MAAM,CAAC;AAEzB,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,CAK1E;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE;IAC9C,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;IAC3B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,EAAE,sBAAsB,CAAC;CACjC,GAAG,MAAM,CAST;AAED,wBAAgB,yBAAyB,CAAC,MAAM,EAAE;IAChD,MAAM,CAAC,EAAE,kBAAkB,CAAC;IAC5B,UAAU,CAAC,EAAE,0BAA0B,CAAC;IACxC,KAAK,EAAE,UAAU,CAAC,kBAAkB,CAAC,CAAC,CAAC,CAAC,CAAC;CAC1C,GAAG,OAAO,CAWV"}
@@ -1,4 +1,5 @@
1
1
  import { ArchiveSecurityError } from "./archive-errors.js";
2
+ import { formatErrorDetail } from "./error-detail.js";
2
3
  export function archiveEntryKindFromTarType(type) {
3
4
  if (type === "Directory" || type === "GNUDumpDir")
4
5
  return "directory";
@@ -23,7 +24,7 @@ export function shouldExtractArchiveEntry(params) {
23
24
  return true;
24
25
  }
25
26
  if ((params.onFiltered ?? "reject-archive") === "reject-archive") {
26
- throw new ArchiveSecurityError("entry-filtered", `archive entry rejected by filter: ${params.entry.path}`);
27
+ throw new ArchiveSecurityError("entry-filtered", `archive entry rejected by filter: ${formatErrorDetail(params.entry.path)}`);
27
28
  }
28
29
  return false;
29
30
  }
@@ -1 +1 @@
1
- {"version":3,"file":"archive-read.d.ts","sourceRoot":"","sources":["../src/archive-read.ts"],"names":[],"mappings":"AASA,OAAO,EAAsB,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAkLzE,wBAAsB,gBAAgB,CACpC,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,WAAW,CAAA;CAAE,GAChD,OAAO,CAAC,MAAM,CAAC,CAwEjB"}
1
+ {"version":3,"file":"archive-read.d.ts","sourceRoot":"","sources":["../src/archive-read.ts"],"names":[],"mappings":"AAcA,OAAO,EAAsB,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAqMzE,wBAAsB,gBAAgB,CACpC,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,WAAW,CAAA;CAAE,GAChD,OAAO,CAAC,MAAM,CAAC,CAmFjB"}