@openclaw/fs-safe 0.4.6 → 0.5.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 (246) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +110 -41
  3. package/dist/archive-entry.d.ts.map +1 -1
  4. package/dist/archive-entry.js +5 -4
  5. package/dist/archive-errors.d.ts +11 -0
  6. package/dist/archive-errors.d.ts.map +1 -0
  7. package/dist/archive-errors.js +16 -0
  8. package/dist/archive-input.d.ts +12 -0
  9. package/dist/archive-input.d.ts.map +1 -0
  10. package/dist/archive-input.js +72 -0
  11. package/dist/archive-kind.d.ts +1 -1
  12. package/dist/archive-kind.d.ts.map +1 -1
  13. package/dist/archive-kind.js +18 -0
  14. package/dist/archive-limits.d.ts +10 -0
  15. package/dist/archive-limits.d.ts.map +1 -1
  16. package/dist/archive-limits.js +18 -0
  17. package/dist/archive-native.d.ts +18 -0
  18. package/dist/archive-native.d.ts.map +1 -0
  19. package/dist/archive-native.js +115 -0
  20. package/dist/archive-options.d.ts +21 -0
  21. package/dist/archive-options.d.ts.map +1 -0
  22. package/dist/archive-options.js +1 -0
  23. package/dist/archive-policy.d.ts +20 -0
  24. package/dist/archive-policy.d.ts.map +1 -0
  25. package/dist/archive-policy.js +29 -0
  26. package/dist/archive-read.d.ts +6 -0
  27. package/dist/archive-read.d.ts.map +1 -0
  28. package/dist/archive-read.js +200 -0
  29. package/dist/archive-staging.d.ts +2 -5
  30. package/dist/archive-staging.d.ts.map +1 -1
  31. package/dist/archive-staging.js +19 -10
  32. package/dist/archive-tar-meta.d.ts +6 -0
  33. package/dist/archive-tar-meta.d.ts.map +1 -0
  34. package/dist/archive-tar-meta.js +143 -0
  35. package/dist/archive-tar-runtime.d.ts +43 -0
  36. package/dist/archive-tar-runtime.d.ts.map +1 -0
  37. package/dist/archive-tar-runtime.js +8 -0
  38. package/dist/archive-tar.d.ts +5 -1
  39. package/dist/archive-tar.d.ts.map +1 -1
  40. package/dist/archive-tar.js +24 -6
  41. package/dist/archive-zip-entry.d.ts +15 -0
  42. package/dist/archive-zip-entry.d.ts.map +1 -0
  43. package/dist/archive-zip-entry.js +17 -0
  44. package/dist/archive.d.ts +7 -17
  45. package/dist/archive.d.ts.map +1 -1
  46. package/dist/archive.js +111 -108
  47. package/dist/atomic.d.ts +1 -1
  48. package/dist/atomic.d.ts.map +1 -1
  49. package/dist/config.d.ts +1 -1
  50. package/dist/config.d.ts.map +1 -1
  51. package/dist/config.js +1 -1
  52. package/dist/containment.d.ts +2 -0
  53. package/dist/containment.d.ts.map +1 -0
  54. package/dist/containment.js +1 -0
  55. package/dist/directory-durability.d.ts +44 -0
  56. package/dist/directory-durability.d.ts.map +1 -0
  57. package/dist/directory-durability.js +299 -0
  58. package/dist/durability.d.ts +4 -0
  59. package/dist/durability.d.ts.map +1 -0
  60. package/dist/durability.js +3 -0
  61. package/dist/errors.d.ts +4 -1
  62. package/dist/errors.d.ts.map +1 -1
  63. package/dist/errors.js +2 -0
  64. package/dist/file-hash.d.ts +10 -0
  65. package/dist/file-hash.d.ts.map +1 -0
  66. package/dist/file-hash.js +73 -0
  67. package/dist/file-lock-sync.d.ts +35 -0
  68. package/dist/file-lock-sync.d.ts.map +1 -0
  69. package/dist/file-lock-sync.js +233 -0
  70. package/dist/file-lock.d.ts +2 -0
  71. package/dist/file-lock.d.ts.map +1 -1
  72. package/dist/file-lock.js +1 -0
  73. package/dist/filename.d.ts.map +1 -1
  74. package/dist/filename.js +4 -1
  75. package/dist/fsync.d.ts +1 -1
  76. package/dist/fsync.d.ts.map +1 -1
  77. package/dist/fsync.js +1 -21
  78. package/dist/guarded-mkdir.d.ts +7 -1
  79. package/dist/guarded-mkdir.d.ts.map +1 -1
  80. package/dist/guarded-mkdir.js +45 -3
  81. package/dist/index.d.ts +4 -3
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +1 -1
  84. package/dist/json-document-store.d.ts.map +1 -1
  85. package/dist/json-document-store.js +30 -15
  86. package/dist/move-path.d.ts.map +1 -1
  87. package/dist/move-path.js +101 -17
  88. package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
  89. package/dist/native/darwin-x64/fs-safe-native.node +0 -0
  90. package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
  91. package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
  92. package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
  93. package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
  94. package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
  95. package/dist/native-binding.d.ts +79 -0
  96. package/dist/native-binding.d.ts.map +1 -0
  97. package/dist/native-binding.js +1 -0
  98. package/dist/native-config.d.ts +17 -0
  99. package/dist/native-config.d.ts.map +1 -0
  100. package/dist/native-config.js +71 -0
  101. package/dist/native-operations.d.ts +21 -0
  102. package/dist/native-operations.d.ts.map +1 -0
  103. package/dist/native-operations.js +115 -0
  104. package/dist/native-pinned-write.d.ts +5 -0
  105. package/dist/native-pinned-write.d.ts.map +1 -0
  106. package/dist/native-pinned-write.js +115 -0
  107. package/dist/native.d.ts +14 -0
  108. package/dist/native.d.ts.map +1 -0
  109. package/dist/native.js +204 -0
  110. package/dist/output-sibling.d.ts +8 -0
  111. package/dist/output-sibling.d.ts.map +1 -0
  112. package/dist/output-sibling.js +122 -0
  113. package/dist/output.d.ts +2 -0
  114. package/dist/output.d.ts.map +1 -1
  115. package/dist/output.js +32 -5
  116. package/dist/owner-dacl.d.ts +31 -0
  117. package/dist/owner-dacl.d.ts.map +1 -0
  118. package/dist/owner-dacl.js +27 -0
  119. package/dist/path-policy.js +1 -1
  120. package/dist/permissions-public.d.ts +2 -0
  121. package/dist/permissions-public.d.ts.map +1 -1
  122. package/dist/permissions-public.js +2 -0
  123. package/dist/permissions.d.ts.map +1 -1
  124. package/dist/permissions.js +6 -0
  125. package/dist/pinned-operation.d.ts +2 -0
  126. package/dist/pinned-operation.d.ts.map +1 -0
  127. package/dist/pinned-operation.js +35 -0
  128. package/dist/pinned-write.d.ts +3 -16
  129. package/dist/pinned-write.d.ts.map +1 -1
  130. package/dist/pinned-write.js +20 -85
  131. package/dist/private-directory.d.ts +5 -0
  132. package/dist/private-directory.d.ts.map +1 -0
  133. package/dist/private-directory.js +13 -0
  134. package/dist/private-temp-workspace.d.ts +6 -2
  135. package/dist/private-temp-workspace.d.ts.map +1 -1
  136. package/dist/private-temp-workspace.js +40 -13
  137. package/dist/publish-file-failure.d.ts +27 -0
  138. package/dist/publish-file-failure.d.ts.map +1 -0
  139. package/dist/publish-file-failure.js +21 -0
  140. package/dist/publish-file.d.ts +21 -0
  141. package/dist/publish-file.d.ts.map +1 -0
  142. package/dist/publish-file.js +351 -0
  143. package/dist/read-opened-file.d.ts +3 -0
  144. package/dist/read-opened-file.d.ts.map +1 -1
  145. package/dist/read-opened-file.js +1 -0
  146. package/dist/replace-file-copy-fallback.d.ts +34 -0
  147. package/dist/replace-file-copy-fallback.d.ts.map +1 -0
  148. package/dist/replace-file-copy-fallback.js +339 -0
  149. package/dist/replace-file.d.ts +6 -2
  150. package/dist/replace-file.d.ts.map +1 -1
  151. package/dist/replace-file.js +39 -90
  152. package/dist/root-context.d.ts +4 -1
  153. package/dist/root-context.d.ts.map +1 -1
  154. package/dist/root-context.js +20 -1
  155. package/dist/root-impl.d.ts +5 -0
  156. package/dist/root-impl.d.ts.map +1 -1
  157. package/dist/root-impl.js +128 -225
  158. package/dist/root-path-existing.d.ts +3 -0
  159. package/dist/root-path-existing.d.ts.map +1 -0
  160. package/dist/root-path-existing.js +69 -0
  161. package/dist/root-path.d.ts +1 -2
  162. package/dist/root-path.d.ts.map +1 -1
  163. package/dist/root-path.js +67 -81
  164. package/dist/root-walk.d.ts +41 -0
  165. package/dist/root-walk.d.ts.map +1 -0
  166. package/dist/root-walk.js +114 -0
  167. package/dist/root.d.ts +2 -0
  168. package/dist/root.d.ts.map +1 -1
  169. package/dist/secret-file.d.ts +5 -2
  170. package/dist/secret-file.d.ts.map +1 -1
  171. package/dist/secret-file.js +20 -2
  172. package/dist/secret-read-async.d.ts +4 -0
  173. package/dist/secret-read-async.d.ts.map +1 -0
  174. package/dist/secret-read-async.js +95 -0
  175. package/dist/secret.d.ts +2 -1
  176. package/dist/secret.d.ts.map +1 -1
  177. package/dist/secret.js +2 -1
  178. package/dist/sibling-temp.d.ts.map +1 -1
  179. package/dist/sibling-temp.js +2 -13
  180. package/dist/sidecar-lock-handle.d.ts +20 -0
  181. package/dist/sidecar-lock-handle.d.ts.map +1 -0
  182. package/dist/sidecar-lock-handle.js +28 -0
  183. package/dist/sidecar-lock-policy.d.ts +10 -0
  184. package/dist/sidecar-lock-policy.d.ts.map +1 -0
  185. package/dist/sidecar-lock-policy.js +29 -0
  186. package/dist/sidecar-lock-reclaim.d.ts +22 -6
  187. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  188. package/dist/sidecar-lock-reclaim.js +106 -20
  189. package/dist/sidecar-lock-types.d.ts +56 -0
  190. package/dist/sidecar-lock-types.d.ts.map +1 -0
  191. package/dist/sidecar-lock-types.js +1 -0
  192. package/dist/sidecar-lock.d.ts +2 -45
  193. package/dist/sidecar-lock.d.ts.map +1 -1
  194. package/dist/sidecar-lock.js +94 -55
  195. package/dist/temp-cleanup.d.ts +7 -1
  196. package/dist/temp-cleanup.d.ts.map +1 -1
  197. package/dist/temp-cleanup.js +35 -5
  198. package/dist/temp.d.ts +2 -1
  199. package/dist/temp.d.ts.map +1 -1
  200. package/dist/test-hooks.d.ts +3 -0
  201. package/dist/test-hooks.d.ts.map +1 -1
  202. package/dist/windows-permissions-native.d.ts +9 -0
  203. package/dist/windows-permissions-native.d.ts.map +1 -0
  204. package/dist/windows-permissions-native.js +31 -0
  205. package/docs/advanced.md +1 -1
  206. package/docs/archive.md +131 -13
  207. package/docs/atomic.md +30 -1
  208. package/docs/config.md +51 -21
  209. package/docs/durability.md +299 -0
  210. package/docs/errors.md +36 -6
  211. package/docs/filename.md +0 -0
  212. package/docs/index.md +11 -7
  213. package/docs/install.md +27 -26
  214. package/docs/json-store.md +18 -3
  215. package/docs/migrating-to-0.5.md +196 -0
  216. package/docs/native-helper.md +85 -0
  217. package/docs/native.md +134 -0
  218. package/docs/output.md +37 -11
  219. package/docs/path.md +1 -1
  220. package/docs/permissions.md +84 -1
  221. package/docs/quickstart.md +5 -2
  222. package/docs/reading.md +4 -4
  223. package/docs/root.md +31 -15
  224. package/docs/secret-file.md +50 -1
  225. package/docs/security-model.md +42 -8
  226. package/docs/sidecar-lock.md +92 -7
  227. package/docs/temp.md +24 -1
  228. package/docs/test-hooks.md +22 -2
  229. package/docs/testing.md +18 -7
  230. package/docs/types.md +3 -1
  231. package/docs/walk.md +55 -0
  232. package/docs/writing.md +3 -3
  233. package/package.json +12 -2
  234. package/dist/pinned-helper.d.ts +0 -9
  235. package/dist/pinned-helper.d.ts.map +0 -1
  236. package/dist/pinned-helper.js +0 -19
  237. package/dist/pinned-path.d.ts +0 -7
  238. package/dist/pinned-path.d.ts.map +0 -1
  239. package/dist/pinned-path.js +0 -21
  240. package/dist/pinned-python-config.d.ts +0 -9
  241. package/dist/pinned-python-config.d.ts.map +0 -1
  242. package/dist/pinned-python-config.js +0 -38
  243. package/dist/pinned-python.d.ts +0 -12
  244. package/dist/pinned-python.d.ts.map +0 -1
  245. package/dist/pinned-python.js +0 -688
  246. package/docs/python-helper.md +0 -103
package/CHANGELOG.md CHANGED
@@ -1,5 +1,74 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 - 2026-07-27
4
+
5
+ ### Highlights
6
+
7
+ - Add policy-driven archive entry filtering and mode handling, bounded single-entry archive reads, root-bounded async walking, synchronous sidecar locks, async secret reads, create-only secret writes, and exclusive file publication.
8
+ - Add public streaming `sha256File(path | FileHandle)` hashing with optional async native acceleration, plus policy-free Windows owner/DACL facts with owner and current-process-user SIDs and per-ACE masks and inheritance flags.
9
+ - Add native fd-relative ZIP and TAR extraction/read support with gzip, zstd, and bzip2 streaming; TypeScript evaluates the shared entry policy before Rust creates any output, and zstd/bzip2 report a typed native-required error when no binding is available.
10
+ - Bound PAX, GNU long-name/link, and sparse metadata with one `maxMetaEntryBytes` policy shared by node-tar and the native fixed-header metering reader, including typed failures for oversized or malformed metadata.
11
+ - Add `Root.walk()` subtree pruning and partial directory-error reporting for bounded best-effort consumers, plus a shared `maxEntryPathComponents` archive limit that rejects implicit-directory depth attacks before either extraction path creates output.
12
+ - Bundle all seven prebuilt native binaries inside the single `@openclaw/fs-safe` package for fd-relative opens, guarded directory creation and hardlinks, atomic no-replace rename, and file identity checks. This deliberately increases the package size in exchange for deterministic installs with no optional platform packages, downloads, postinstall step, or consumer Rust build; unsupported platforms silently use the guarded JavaScript fallback in `auto` mode.
13
+
14
+ ### Security and Correctness
15
+
16
+ - **Security — `resolveRootPath()` / `resolveRootPathSync()`:** Published releases through 0.4.7 validated a lexically normalized path spelling, so a caller-supplied path traversing an in-root symlink could pass validation while resolving outside the root. Version 0.5 fixes this with component-wise alias resolution. `root()` handles were **not** affected: their operations have contained this case since `5ddca80`, so exposure is limited to direct users of these two exported helpers.
17
+ - Prefer macOS 15.4's `O_RESOLVE_BENEATH` for native opens, retain the guarded component walk on older kernels, and apply an `F_GETPATH` post-open escape detector to both routes without claiming rename-race atomicity.
18
+ - Report open containment explicitly: native `openBeneath()` returns `{ fd, containment }` with `kernel-atomic` on Linux and `best-effort` on macOS/Windows, while JavaScript root open/read/writable results report `best-effort`.
19
+ - Serialize async `jsonStore` writes and read-modify-write updates in-process by canonical store path before taking the cross-process sidecar lock, preventing overlapping `write`, `update`, and `updateOr` calls from silently losing updates; reject nested same-path mutations with typed `store-reentrant-update` errors. Thanks @yetval for reporting this.
20
+ - Create `append`, `openWritable`, and fallback `copyIn` parents through guarded per-component walks and continue I/O through the resolved in-root parent, preventing symlink-swap races from creating directories outside the root while preserving valid in-root symlink parents. Thanks @yetval for reporting this.
21
+ - Add pinned-destination hardlink rejection and bounded original-content restoration to `replaceFileAtomic()` and its sync variant, including typed `restored` / `restore-failed` receipts for torn copy-fallback writes.
22
+ - Add sibling staging to `writeExternalFileWithinRoot()`: external producers can write a randomized file in the target directory for fsynced same-filesystem atomic replacement, while private workspace staging remains the cross-device-tolerant default; staged and final basenames share portable C0/C1 and Windows-invalid-character sanitization on every host.
23
+ - Enforce `movePathWithCopyFallback({ sourceHardlinks: "reject" })` with a streaming, entry-capped recursive preflight before mutation, closing a shipped 0.4.x gap where the common same-filesystem rename bypassed the policy; approved trees commit through a fresh staged copy with open-time and post-copy link-count fences so a scan/rename race cannot publish a hardlinked inode.
24
+ - Abort and tear down JavaScript TAR extraction immediately when entry policy, path validation, link rejection, or a budget fails, preventing node-tar from leaving a paused parser after rejected fleet-restore entries; both native and JavaScript paths now return the same typed archive-policy errors.
25
+ - Attach a post-creation failure receipt to `publishFileExclusive()` errors with the failing phase, whether this call created the target, its observed identity, and whether cleanup removed, preserved, or could not classify the target.
26
+ - Add `publishFileExclusive({ onSyncFailure: "rollback" | "preserve" })`: rollback remains the default, while preserve keeps a complete target after directory-sync failure and reports the failed sync outcome in the typed provenance receipt.
27
+ - Close the pinned publication source on parent-pinning failure so every acquired descriptor is released on every exit path.
28
+ - Remove the native loader's PATH-resolved `ldd` execution. Linux libc detection now uses the Node process report, conventional musl library filenames, and the Node executable's ELF interpreter without spawning a process at import time; an inconclusive probe conservatively attempts glibc and falls back normally in `auto` mode.
29
+ - Default archive extraction to `entryModes: "clamp"`, normalizing directories to `0o755` and files to `0o644` or `0o755` while always stripping setuid, setgid, and sticky bits; use `"preserve"` to retain safe archived rwx bits.
30
+ - Prefer native create-only commits, sidecar acquisition, hardlink publication, and the explicit `rename-noreplace` publication strategy when the platform binding is available, while retaining guarded JavaScript fallbacks for `auto` and `off` modes.
31
+ - Accelerate exclusive publication fallbacks with macOS `fclonefileat`, Linux `FICLONE` and `copy_file_range`, then the unchanged JavaScript byte loop; all paths retain exclusive creation, identity fencing, mode normalization, and SHA-256 verification through an async native hash task when available.
32
+ - Add direct Windows owner/DACL inspection and protected private-directory creation for the current owner, LocalSystem, and Administrators, while retaining the existing .NET/`icacls` behavior when native mode is unavailable, forced off, or encounters an unsupported descriptor form.
33
+ - Keep the public private-directory creator Windows-only and native-only so POSIX pathname races or inherited ACLs cannot weaken its privacy guarantee.
34
+ - Build Linux native opens on `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)`, macOS opens on an in-root `O_NOFOLLOW` component walk, and Windows opens on handle-relative `NtCreateFile` with reparse-point rejection.
35
+
36
+ ### Compatibility
37
+
38
+ - Remove the unsound process-scoped `allowReentrant` async file-lock option and replace it with owner-scoped `reentrantOwner` for async and sync locks: only matching, explicitly defined logical owners reuse a canonical in-process lock, releases are reference-counted and idempotent, and different or absent owners contend normally. Callers that passed the boolean must either remove it or migrate intentional nesting to a per-operation owner key; `jsonStore` remains ownerless and rejects nested same-file mutations immediately.
39
+ - Remove the persistent Python helper and its `pythonPath` configuration. Replace `configureFsSafePython`, `FS_SAFE_PYTHON_MODE`, and the OpenClaw Python aliases with `configureFsSafeNative` and `FS_SAFE_NATIVE_MODE`; 0.5 warns once and maps the former `auto`, `require`, and `off` policies solely as an upgrade bridge for shipped 0.4 consumers.
40
+ - Add `publishFileExclusive({ strategy: "rename-noreplace" })`; this strategy requires the native helper, atomically moves the source, and never replaces an existing destination.
41
+
42
+ ### Docs and Tooling
43
+
44
+ - Add an ordered 0.4-to-0.5 migration checklist and reconcile every new archive, native, publication, walk, lock, secret, permission, and temp-workspace contract with realistic examples and cross-links.
45
+ - Convert the repository to a pnpm workspace, test the Rust crate on Linux, macOS, and Windows, and publish all platform bindings, the native loader, and the root package through one protected-tag release pipeline with npm provenance.
46
+ - Replace unused napi-rs Android, FreeBSD, OpenHarmony, WASI, and unsupported-architecture loader branches with a checked-in loader for the seven packages actually published, and make publication benchmarks report the exercised clone/copy/JavaScript tier plus filesystem environment.
47
+
48
+ ## 0.4.7 - 2026-07-24
49
+
50
+ ### Features
51
+
52
+ - Add `@openclaw/fs-safe/durability` with identity-pinned directory handles,
53
+ explicit strict sync outcomes, synchronous and best-effort variants, and
54
+ durable nested-directory creation through every new parent edge.
55
+
56
+ ### Security and Correctness
57
+
58
+ - Reject final symlinks, FIFOs, non-directories, canonical-path drift, and
59
+ descriptor/path identity replacement before or after directory sync.
60
+ - Propagate POSIX directory synchronization failures while classifying known
61
+ unsupported Windows directory flushing only after revalidating the target;
62
+ directory-open access failures remain strict.
63
+ - Route sibling-temp, root, and pinned-write best-effort parent synchronization
64
+ through the shared guarded primitive instead of maintaining divergent implementations.
65
+
66
+ ### Docs and Tooling
67
+
68
+ - Document directory receipts, pin lifecycle, platform outcomes, custom
69
+ creation callbacks, and the boundary between filesystem durability and
70
+ application commit protocols.
71
+
3
72
  ## 0.4.6 - 2026-07-24
4
73
 
5
74
  ### Highlights
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.
12
12
 
13
- Think Go's `os.Root` / `OpenInRoot` or Rust's [`cap-std`](https://github.com/bytecodealliance/cap-std), but for Node. Hand `root()` a trusted directory and you get back a handle whose every method resolves relative paths against it and refuses to escape — through `..`, symlink swaps, hardlink aliases, or TOCTOU rename races between check and use.
13
+ Think Go's `os.Root` / `OpenInRoot` or Rust's [`cap-std`](https://github.com/bytecodealliance/cap-std), but for Node. Hand `root()` a trusted directory and you get back a handle whose every method resolves relative paths against it and defends against `..`, symlink swaps, hardlink aliases, and TOCTOU rename races. The exact containment strength is reported per mechanism: Linux native opens are kernel-atomic; macOS, Windows, and JavaScript paths are best-effort.
14
14
 
15
15
  ```ts
16
16
  import { root } from "@openclaw/fs-safe";
@@ -26,7 +26,7 @@ Full docs and reference at **[fs-safe.io](https://fs-safe.io)**.
26
26
 
27
27
  ## Contents
28
28
 
29
- [Why this exists](#why-this-exists) · [Not a sandbox](#not-a-sandbox) · [Install](#install) · [Quick start](#quick-start) · [Reading](#reading) · [Subpaths](#subpaths) · [Failure semantics](#failure-semantics-in-the-name) · [Atomic writes](#atomic-writes) · [External outputs](#external-outputs) · [Stores](#stores) · [Secure absolute reads](#secure-absolute-file-reads) · [Walking](#directory-walking) · [Archive extraction](#archive-extraction) · [Path scopes](#advanced-path-scopes) · [Errors](#errors) · [Safety model](#safety-model) · [Limitations](#limitations)
29
+ [Why this exists](#why-this-exists) · [Not a sandbox](#not-a-sandbox) · [Install](#install) · [0.5 migration](docs/migrating-to-0.5.md) · [Python migration](#migrating-from-the-python-helper) · [Quick start](#quick-start) · [Reading](#reading) · [Subpaths](#subpaths) · [Failure semantics](#failure-semantics-in-the-name) · [Directory durability](#directory-durability) · [Atomic writes](#atomic-writes) · [External outputs](#external-outputs) · [Stores](#stores) · [Secure absolute reads](#secure-absolute-file-reads) · [Walking](#directory-walking) · [Archive extraction](#archive-extraction) · [Path scopes](#advanced-path-scopes) · [Errors](#errors) · [Safety model](#safety-model) · [Limitations](#limitations)
30
30
 
31
31
  ## Why this exists
32
32
 
@@ -45,11 +45,11 @@ The same idea has landed in other languages. Go [added `os.Root` and `OpenInRoot
45
45
  | `path.resolve().startsWith()` | string check only | – | – | – | – |
46
46
  | [`write-file-atomic`](https://www.npmjs.com/package/write-file-atomic) | – | ✓ | – | – | – |
47
47
  | Go [`os.Root`](https://go.dev/blog/osroot) / Rust [`cap-std`](https://github.com/bytecodealliance/cap-std) | ✓ | platform | ✓ | ✓ | – |
48
- | **`@openclaw/fs-safe`** | **✓** | **✓** | **✓** | **✓ (POSIX fd-relative)** | **✓ (ZIP/TAR)** |
48
+ | **`@openclaw/fs-safe`** | **✓** | **✓** | **✓** | **Linux atomic; others best-effort** | **✓ (ZIP/TAR; native zstd/bzip2)** |
49
49
 
50
50
  ## Not a sandbox
51
51
 
52
- This is a **library-level guardrail**, not OS-level isolation. It does not replace containers, seccomp, AppArmor, or filesystem permissions. It is for code that already runs with the privileges of its workspace and wants to stop trivial path tricks from escaping it. If your threat model is a hostile process, you need OS isolation; if your threat model is "an agent, plugin, upload handler, or CLI will eventually be tricked into writing somewhere it shouldn't," `fs-safe` catches that.
52
+ This is a **library-level guardrail**, not OS-level isolation. It does not replace containers, seccomp, AppArmor, or filesystem permissions. It is for code that already runs with the privileges of its workspace and wants to stop trivial path tricks from escaping it. If your threat model is a hostile process, you need OS isolation; if your threat model is "an agent, plugin, upload handler, or CLI will eventually be tricked into writing somewhere it shouldn't," `fs-safe` catches that. The [security model](docs/security-model.md) describes the exact Linux, macOS, Windows, and JavaScript fallback guarantees and race boundaries.
53
53
 
54
54
  ## Install
55
55
 
@@ -59,27 +59,49 @@ pnpm add @openclaw/fs-safe
59
59
 
60
60
  Node 22 or newer. Core root/path/json/temp helpers avoid framework dependencies. Archive helpers use optional `jszip` and `tar` dependencies for ZIP/TAR support; installs that omit optional dependencies can still use every non-archive subpath.
61
61
 
62
- On POSIX, `root()` uses one process-global persistent Python helper for the
63
- fd-relative operations Node does not expose ergonomically (`renameat`,
64
- `unlinkat`, recursive `mkdirat`-style walks, and parent-fd writes). Configure it
65
- before first use when you need a strict environment policy:
62
+ The package bundles prebuilt native bindings for seven supported targets. They
63
+ supply fd-relative and atomic no-replace primitives that Node does not expose
64
+ directly. Configure the lazy loader before first use when you need a strict
65
+ environment policy:
66
66
 
67
67
  ```ts
68
- import { configureFsSafePython } from "@openclaw/fs-safe";
68
+ import { configureFsSafeNative } from "@openclaw/fs-safe";
69
69
 
70
- configureFsSafePython({ mode: "auto" }); // default: use helper, fall back if unavailable
71
- configureFsSafePython({ mode: "off" }); // never spawn Python; use best-effort Node fallbacks
72
- configureFsSafePython({ mode: "require" }); // fail closed if helper cannot start
70
+ configureFsSafeNative({ mode: "auto" }); // default: native when available
71
+ configureFsSafeNative({ mode: "off" }); // guarded JavaScript only
72
+ configureFsSafeNative({ mode: "require" }); // fail closed if the binding is unavailable
73
73
  ```
74
74
 
75
- Equivalent env vars: `FS_SAFE_PYTHON_MODE=auto|off|require` and
76
- `FS_SAFE_PYTHON=/path/to/python3`. Without Python, `fs-safe` keeps lexical and
77
- canonical root checks, no-follow opens, atomic temp+rename writes, and
78
- post-write identity verification. What you lose is the strongest POSIX
79
- fd-relative protection against a same-process-user racer swapping parent
80
- directories between validation and mutation. Windows already uses the Node
81
- fallback path. See the [Python helper policy](docs/python-helper.md) for
82
- deployment guidance.
75
+ Equivalent env var: `FS_SAFE_NATIVE_MODE=auto|off|require`. All seven binaries
76
+ ship inside `@openclaw/fs-safe`; there are no platform packages, postinstall
77
+ steps, downloads, or consumer Rust builds. This makes the tarball larger than
78
+ a per-platform package, but makes installation deterministic. On a platform
79
+ without a bundled binary, `auto` silently retains lexical and canonical root
80
+ checks, no-follow opens, guarded temp+rename writes, and post-write identity
81
+ verification. See the [native
82
+ helper policy](docs/native-helper.md) for the exact boundary and deployment
83
+ tradeoff, and [native architecture](docs/native.md) for the platform mechanisms
84
+ and policy ownership model.
85
+
86
+ Open results report the mechanism's containment class as `"kernel-atomic"` or
87
+ `"best-effort"`. Linux native `openBeneath()` is kernel-atomic; macOS, Windows,
88
+ and guarded JavaScript results are best-effort. See the [security model](docs/security-model.md#containment-guarantees-by-platform) before using that fact in higher-level policy.
89
+
90
+ ## Migrating from the Python helper
91
+
92
+ Version 0.5 replaces the persistent Python worker with bundled prebuilt native
93
+ bindings. The modes map directly: `configureFsSafePython({ mode: "auto" })`
94
+ becomes `configureFsSafeNative({ mode: "auto" })`, and likewise for `off` and
95
+ `require`. Replace `FS_SAFE_PYTHON_MODE` with `FS_SAFE_NATIVE_MODE`; remove
96
+ `pythonPath`, `FS_SAFE_PYTHON`, and interpreter provisioning because the native
97
+ loader does not spawn Python.
98
+
99
+ Version 0.5 retains the old function and documented `FS_SAFE_PYTHON*`
100
+ and OpenClaw Python environment names emit one `FS_SAFE_PYTHON_DEPRECATED`
101
+ warning and map the old mode to its native equivalent. They are migration
102
+ bridges for shipped 0.4 consumers, not an alternate helper contract. Update
103
+ startup configuration as part of the 0.5 upgrade rather than relying on the
104
+ warning path. Follow the [0.5 migration checklist](docs/migrating-to-0.5.md).
83
105
 
84
106
  ## Quick start
85
107
 
@@ -170,35 +192,36 @@ const locked = await root("/srv/workspace", {
170
192
  await locked.write(".env", "token"); // FsSafeError code "denied-path"
171
193
  ```
172
194
 
173
- `stat()`, `exists()`, and `list()` are boundary-checked, but they cannot pin a later operation to the same filesystem object. Use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()` for operations that must be race-resistant at the point of use.
195
+ `stat()`, `exists()`, and `list()` are boundary-checked, but they cannot pin a later operation to the same filesystem object. Use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()` for operation-local identity checks, and inspect `containment` when the platform distinction matters.
174
196
 
175
197
  ## Subpaths
176
198
 
177
- The main entry point is intentionally small: `root`, the root option/result
178
- types, and `FsSafeError`. Use subpaths for everything else. Low-level helpers
179
- that OpenClaw needs to compose higher-level APIs are grouped under
199
+ The main entry point collects the common root, config, output, lock, native-mode,
200
+ and error exports. Prefer focused subpaths when a consumer needs a narrower
201
+ contract. Low-level helpers that OpenClaw needs to compose higher-level APIs are grouped under
180
202
  `@openclaw/fs-safe/advanced` instead of being separate public leaf contracts.
181
203
 
182
204
  | Subpath | Contents |
183
205
  |---|---|
184
- | `@openclaw/fs-safe/root` | `root()`, `Root`, `RootDefaults`, related types |
185
- | `@openclaw/fs-safe/config` | process-global Python helper configuration |
206
+ | `@openclaw/fs-safe/root` | `root()`, `Root`, `RootDefaults`, and root-bounded walking with pruning/error markers |
207
+ | `@openclaw/fs-safe/config` | process-global native helper and lock defaults |
186
208
  | `@openclaw/fs-safe/path` | canonical path checks: `isPathInside`, `safeRealpathSync`, `isNotFoundPathError`, `isSymlinkOpenError` |
187
209
  | `@openclaw/fs-safe/json` | `tryReadJson`, `readJson`, `readJsonIfExists`, `writeJson`, sync variants |
188
210
  | `@openclaw/fs-safe/output` | `writeExternalFileWithinRoot` for external libraries that need a temp output path |
189
211
  | `@openclaw/fs-safe/store` | `fileStore`, `fileStoreSync`, and `jsonStore` |
190
- | `@openclaw/fs-safe/secret` | strict and try-style secret file read/write helpers |
212
+ | `@openclaw/fs-safe/secret` | sync/async strict and try-style secret reads, atomic replace, and create-only secret writes |
191
213
  | `@openclaw/fs-safe/atomic` | `replaceFileAtomic`, `replaceFileAtomicSync`, `replaceDirectoryAtomic`, `movePathWithCopyFallback` |
214
+ | `@openclaw/fs-safe/durability` | pinned directory identities, strict directory sync, durable nested-directory creation, exclusive publication, streaming SHA-256, provenance receipts, and sync-failure policy |
192
215
  | `@openclaw/fs-safe/temp` | `tempWorkspace`, `tempWorkspaceSync`, `withTempWorkspace`, `resolveSecureTempRoot` |
193
216
  | `@openclaw/fs-safe/secure-file` | fd-pinned absolute file reads with owner, mode, ACL, trusted-dir, size, and timeout checks |
194
- | `@openclaw/fs-safe/file-lock` | `acquireFileLock`, `withFileLock`, `createFileLockManager`, and related lock types |
195
- | `@openclaw/fs-safe/permissions` | POSIX mode and Windows ACL inspection plus remediation formatting helpers |
217
+ | `@openclaw/fs-safe/file-lock` | async/sync sidecar locks, root-bounded sidecars, ownership verification, and stale policy |
218
+ | `@openclaw/fs-safe/permissions` | POSIX mode and Windows ACL inspection, raw owner/ACE facts, private-directory creation, and remediation helpers |
196
219
  | `@openclaw/fs-safe/walk` | budget-bounded directory walking with symlink policy, filters, and truncation accounting; not root-bounded |
197
- | `@openclaw/fs-safe/archive` | `extractArchive`, `resolveArchiveKind`, `ArchiveLimitError`, preflight helpers |
220
+ | `@openclaw/fs-safe/archive` | policy-driven ZIP/TAR extraction, clamp/filter policy, metadata/path-depth limits, native gzip/zstd/bzip2, and bounded entry reads |
198
221
  | `@openclaw/fs-safe/advanced` | lower-level composition helpers such as path scopes, root-file open, bounded descriptor reads, install paths, filename sanitizing, temp-file targets, sibling-temp writes, local-root readers, regular-file helpers, `pathExists`, and `withTimeout`; less stable than focused public subpaths |
199
- | `@openclaw/fs-safe/errors` | `FsSafeError`, `FsSafeErrorCode` |
222
+ | `@openclaw/fs-safe/errors` | `FsSafeError`, closed codes/categories, causes, and operation-specific details receipts |
200
223
  | `@openclaw/fs-safe/types` | shared types: `DirEntry`, `PathStat`, … |
201
- | `@openclaw/fs-safe/test-hooks` | hooks the test suite uses to inject races; only active under `NODE_ENV=test` |
224
+ | `@openclaw/fs-safe/test-hooks` | hooks the test suite uses to inject races; registration requires `NODE_ENV=test` or `VITEST=true` |
202
225
 
203
226
  ## Failure semantics in the name
204
227
 
@@ -216,9 +239,44 @@ performs the root-bounded open and JSON object validation in one step. Use
216
239
  `readRootStructuredFileSync()` when the parser lives outside fs-safe, such as
217
240
  JSON5-backed plugin manifests.
218
241
 
242
+ ## Directory durability
243
+
244
+ ```ts
245
+ import { ensureDurableDirectory, pinDirectory } from "@openclaw/fs-safe/durability";
246
+
247
+ const receipt = await ensureDurableDirectory({
248
+ directoryPath: "/srv/backups/sqlite",
249
+ mode: 0o700,
250
+ });
251
+ const pinned = await pinDirectory(receipt);
252
+ try {
253
+ await publishSnapshot();
254
+ const outcome = await pinned.sync();
255
+ // `unsupported` is explicit on platforms without directory flushing.
256
+ console.log(outcome.status);
257
+ } finally {
258
+ await pinned.close();
259
+ }
260
+ ```
261
+
262
+ The durability subpath pins a directory descriptor to its pathname identity,
263
+ detects symlink/FIFO/replacement races, and synchronizes every new parent edge
264
+ when creating a nested directory. Strict sync propagates real I/O failures and
265
+ reports known Windows directory-flush limitations explicitly. Separate
266
+ best-effort helpers preserve operations that do not promise crash durability.
267
+
268
+ `publishFileExclusive()` adds no-clobber hardlink/copy/rename strategies and a
269
+ typed post-creation receipt. Its `onSyncFailure` policy defaults to
270
+ `"rollback"`; backup writers can choose `"preserve"` to keep a complete target
271
+ when parent-directory sync fails, then inspect `details.directorySync` and
272
+ retry or record the weaker durability state.
273
+
274
+ See [Directory durability](docs/durability.md) for the receipt, pin lifecycle,
275
+ publication policy, creation callback, and platform contract.
276
+
219
277
  ## Atomic writes
220
278
 
221
- `replaceFileAtomic()` writes a sibling temp file, optionally fsyncs it, and renames it over the destination. Mode preservation, rename retry / copy fallback on `EPERM`, 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.
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.
222
280
 
223
281
  ```ts
224
282
  import { replaceFileAtomic } from "@openclaw/fs-safe/atomic";
@@ -245,16 +303,19 @@ import { writeExternalFileWithinRoot } from "@openclaw/fs-safe/output";
245
303
  await writeExternalFileWithinRoot({
246
304
  rootDir: "/safe/workspace/downloads",
247
305
  path: "reports/today.pdf",
306
+ staging: "sibling",
248
307
  write: async (filePath) => {
249
308
  await download.saveAs(filePath);
250
309
  },
251
310
  });
252
311
  ```
253
312
 
254
- The callback receives a private temp file path, not the final destination. After
255
- the callback returns, fs-safe finalizes the staged file with `Root.copyIn()`,
256
- creating missing parents by default and rejecting traversal, symlink parent
257
- escapes, hardlinked final targets, and size-limit violations.
313
+ The callback receives a staged path, not the final destination. The default
314
+ `"workspace"` mode uses private temp storage plus `Root.copyIn()` for
315
+ cross-device-tolerant finalization. `"sibling"` stages in the target directory,
316
+ fsyncs the completed file, and atomically renames it over the target. Choose it
317
+ when the destination directory is itself the writable boundary and atomic
318
+ replacement matters.
258
319
 
259
320
  Use it when the final filename is known before the external writer runs. If the
260
321
  filename depends on sniffing the produced bytes, write to a private temp
@@ -376,6 +437,13 @@ for (const file of scan.entries) {
376
437
 
377
438
  Check `scan.truncated` before treating the result as complete, and `scan.failedDirs` to tell an incomplete scan (a directory that could not be read) from an empty one before pruning state from the listing.
378
439
 
440
+ For caller-controlled paths, `Root.walk()` is the root-bounded async iterator.
441
+ It supports entry/depth budgets, in-root symlink following, cancellation, and a
442
+ truncation marker (or typed error) when a budget is reached. Its `entryFilter`
443
+ can return `"skip-subtree"` to prune a directory, and
444
+ `onDirectoryError: "skip-and-report"` yields typed `"directory-error"` markers
445
+ while preserving entries from readable subtrees.
446
+
379
447
  ## Archive extraction
380
448
 
381
449
  `extractArchive()` handles ZIP and TAR behind one API, with traversal checks, blocked-link-type rejection, and entry-count and byte budgets.
@@ -396,6 +464,7 @@ await extractArchive({
396
464
  maxEntries: 50_000,
397
465
  maxExtractedBytes: 512 * 1024 * 1024,
398
466
  maxEntryBytes: 256 * 1024 * 1024,
467
+ maxEntryPathComponents: 64,
399
468
  },
400
469
  });
401
470
  ```
@@ -444,19 +513,19 @@ if (err instanceof FsSafeError) {
444
513
  }
445
514
  ```
446
515
 
447
- 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`, `symlink`, `timeout`, `too-large`, and `unsupported-platform`.
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`.
448
517
 
449
518
  ## Safety model
450
519
 
451
520
  - root-bounded APIs resolve paths against a configured root and reject canonical escapes
452
521
  - reads reject known unsafe device paths, open with `O_NOFOLLOW` where available, then verify fd identity matches the path identity before returning the buffer or handle
453
- - writes use pinned parent-directory helpers and atomic replacement on POSIX, with verified post-write identity
454
- - `remove`, `mkdir`, `move`, `stat`, `list`, and parent-fd writes use one persistent fd-relative Python helper on POSIX, with Node fallbacks when the helper is disabled or unavailable
522
+ - create-only writes, sidecar acquisition, and exclusive publication prefer fd-relative native primitives, with verified guarded JavaScript fallbacks
523
+ - `remove`, `mkdir`, `move`, `stat`, and `list` retain guarded JavaScript implementations with pre/post identity checks
455
524
  - archive extraction stages into a private directory and merges through the same boundary checks used by direct writes
456
525
 
457
526
  ## Limitations
458
527
 
459
- - Windows uses the safest Node-level behavior available; some fd-relative POSIX hardening is unavailable there.
528
+ - Windows native opens are handle-relative and reject reparse points; operations without native wiring use the guarded Node implementation.
460
529
  - Hardlink rejection depends on platform metadata. Treat it as defense-in-depth, not authorization.
461
530
  - `fs-safe` does not validate file contents or archive payload semantics beyond filesystem safety constraints. Schemas, signatures, and authorization belong in the layer above.
462
531
 
@@ -1 +1 @@
1
- {"version":3,"file":"archive-entry.d.ts","sourceRoot":"","sources":["../src/archive-entry.ts"],"names":[],"mappings":"AAGA,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,CAeN;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,CAQT"}
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,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { ArchiveSecurityError } from "./archive-errors.js";
2
3
  import { resolveSafeBaseDir } from "./path.js";
3
4
  export function isWindowsDrivePath(value) {
4
5
  return /^[a-zA-Z]:[\\/]/.test(value);
@@ -11,15 +12,15 @@ export function validateArchiveEntryPath(entryPath, params) {
11
12
  return;
12
13
  }
13
14
  if (isWindowsDrivePath(entryPath)) {
14
- throw new Error(`archive entry uses a drive path: ${entryPath}`);
15
+ throw new ArchiveSecurityError("entry-path", `archive entry uses a drive path: ${entryPath}`);
15
16
  }
16
17
  const normalized = path.posix.normalize(normalizeArchiveEntryPath(entryPath));
17
18
  const escapeLabel = params?.escapeLabel ?? "destination";
18
19
  if (normalized === ".." || normalized.startsWith("../")) {
19
- throw new Error(`archive entry escapes ${escapeLabel}: ${entryPath}`);
20
+ throw new ArchiveSecurityError("entry-path", `archive entry escapes ${escapeLabel}: ${entryPath}`);
20
21
  }
21
22
  if (path.posix.isAbsolute(normalized) || normalized.startsWith("//")) {
22
- throw new Error(`archive entry is absolute: ${entryPath}`);
23
+ throw new ArchiveSecurityError("entry-path", `archive entry is absolute: ${entryPath}`);
23
24
  }
24
25
  }
25
26
  export function stripArchivePath(entryPath, stripComponents) {
@@ -41,7 +42,7 @@ export function resolveArchiveOutputPath(params) {
41
42
  const outPath = path.resolve(params.rootDir, params.relPath);
42
43
  const escapeLabel = params.escapeLabel ?? "destination";
43
44
  if (!outPath.startsWith(safeBase)) {
44
- throw new Error(`archive entry escapes ${escapeLabel}: ${params.originalPath}`);
45
+ throw new ArchiveSecurityError("entry-path", `archive entry escapes ${escapeLabel}: ${params.originalPath}`);
45
46
  }
46
47
  return outPath;
47
48
  }
@@ -0,0 +1,11 @@
1
+ export type ArchiveFormatErrorCode = "archive-header-invalid";
2
+ export type ArchiveSecurityErrorCode = "destination-not-directory" | "destination-symlink" | "destination-symlink-traversal" | "entry-filtered" | "entry-link" | "entry-path";
3
+ export declare class ArchiveSecurityError extends Error {
4
+ readonly code: ArchiveSecurityErrorCode;
5
+ constructor(code: ArchiveSecurityErrorCode, message: string, options?: ErrorOptions);
6
+ }
7
+ export declare class ArchiveFormatError extends Error {
8
+ readonly code: ArchiveFormatErrorCode;
9
+ constructor(message: string, options?: ErrorOptions);
10
+ }
11
+ //# sourceMappingURL=archive-errors.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,16 @@
1
+ export class ArchiveSecurityError extends Error {
2
+ code;
3
+ constructor(code, message, options) {
4
+ super(message, options);
5
+ this.code = code;
6
+ this.name = "ArchiveSecurityError";
7
+ }
8
+ }
9
+ export class ArchiveFormatError extends Error {
10
+ code;
11
+ constructor(message, options) {
12
+ super(message, options);
13
+ this.name = "ArchiveFormatError";
14
+ this.code = "archive-header-invalid";
15
+ }
16
+ }
@@ -0,0 +1,12 @@
1
+ import type { ExtractionDeadline } from "./archive-deadline.js";
2
+ import { type ResolvedArchiveExtractLimits } from "./archive-limits.js";
3
+ export type StagedArchiveFile = {
4
+ path: string;
5
+ cleanup: () => Promise<void>;
6
+ };
7
+ export declare function stageArchiveFileForExtraction(params: {
8
+ archivePath: string;
9
+ limits: ResolvedArchiveExtractLimits;
10
+ deadline: ExtractionDeadline;
11
+ }): Promise<StagedArchiveFile>;
12
+ //# sourceMappingURL=archive-input.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,72 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { writeFileHandleFully } from "./archive-file-io.js";
5
+ import { ARCHIVE_LIMIT_ERROR_CODE, ArchiveLimitError, } from "./archive-limits.js";
6
+ import { sameFileIdentity } from "./file-identity.js";
7
+ import { tempFile } from "./temp-target.js";
8
+ async function closeFileHandle(handle) {
9
+ if (handle)
10
+ await handle.close().catch(() => undefined);
11
+ }
12
+ export async function stageArchiveFileForExtraction(params) {
13
+ params.deadline.check();
14
+ const sourcePath = path.resolve(params.archivePath);
15
+ const initialStat = await fs.lstat(sourcePath);
16
+ if (initialStat.isSymbolicLink() || !initialStat.isFile()) {
17
+ throw new Error(`archive is not a regular file: ${params.archivePath}`);
18
+ }
19
+ if (initialStat.size > params.limits.maxArchiveBytes) {
20
+ throw new ArchiveLimitError(ARCHIVE_LIMIT_ERROR_CODE.ARCHIVE_SIZE_EXCEEDS_LIMIT);
21
+ }
22
+ const noFollow = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants ? fsConstants.O_NOFOLLOW : 0;
23
+ const handle = await fs.open(sourcePath, fsConstants.O_RDONLY | noFollow);
24
+ let staged;
25
+ let output;
26
+ try {
27
+ staged = await tempFile({
28
+ prefix: "fs-safe-archive-input",
29
+ fileName: path.basename(sourcePath),
30
+ });
31
+ const openedStat = await handle.stat();
32
+ const pathStat = await fs.lstat(sourcePath);
33
+ if (!openedStat.isFile() ||
34
+ pathStat.isSymbolicLink() ||
35
+ !pathStat.isFile() ||
36
+ !sameFileIdentity(initialStat, openedStat) ||
37
+ !sameFileIdentity(pathStat, openedStat)) {
38
+ throw new Error("archive changed during validation");
39
+ }
40
+ const flags = fsConstants.O_WRONLY |
41
+ fsConstants.O_CREAT |
42
+ fsConstants.O_EXCL |
43
+ (process.platform !== "win32" && "O_NOFOLLOW" in fsConstants
44
+ ? fsConstants.O_NOFOLLOW
45
+ : 0);
46
+ output = await fs.open(staged.path, flags, 0o600);
47
+ const buffer = Buffer.allocUnsafe(64 * 1024);
48
+ let written = 0;
49
+ while (true) {
50
+ params.deadline.check();
51
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
52
+ if (bytesRead === 0)
53
+ break;
54
+ written += bytesRead;
55
+ if (written > params.limits.maxArchiveBytes) {
56
+ throw new ArchiveLimitError(ARCHIVE_LIMIT_ERROR_CODE.ARCHIVE_SIZE_EXCEEDS_LIMIT);
57
+ }
58
+ await writeFileHandleFully({ handle: output, buffer, bytes: bytesRead, deadline: params.deadline });
59
+ }
60
+ await output.close();
61
+ output = undefined;
62
+ return staged;
63
+ }
64
+ catch (error) {
65
+ await closeFileHandle(output);
66
+ await staged?.cleanup().catch(() => undefined);
67
+ throw error;
68
+ }
69
+ finally {
70
+ await closeFileHandle(handle);
71
+ }
72
+ }
@@ -1,4 +1,4 @@
1
- export type ArchiveKind = "tar" | "zip";
1
+ export type ArchiveKind = "tar" | "tar-bzip2" | "tar-zstd" | "zip";
2
2
  export declare function resolveArchiveKind(filePath: string): ArchiveKind | null;
3
3
  type ResolvePackedRootDirOptions = {
4
4
  rootMarkers?: string[];
@@ -1 +1 @@
1
- {"version":3,"file":"archive-kind.d.ts","sourceRoot":"","sources":["../src/archive-kind.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,WAAW,GAAG,KAAK,GAAG,KAAK,CAAC;AAIxC,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CASvE;AAED,KAAK,2BAA2B,GAAG;IACjC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB,CAAC;AAkBF,wBAAsB,oBAAoB,CACxC,UAAU,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE,2BAA2B,GACpC,OAAO,CAAC,MAAM,CAAC,CA4BjB"}
1
+ {"version":3,"file":"archive-kind.d.ts","sourceRoot":"","sources":["../src/archive-kind.ts"],"names":[],"mappings":"AAMA,MAAM,MAAM,WAAW,GAAG,KAAK,GAAG,WAAW,GAAG,UAAU,GAAG,KAAK,CAAC;AAmBnE,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAcvE;AAED,KAAK,2BAA2B,GAAG;IACjC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB,CAAC;AAkBF,wBAAsB,oBAAoB,CACxC,UAAU,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE,2BAA2B,GACpC,OAAO,CAAC,MAAM,CAAC,CA4BjB"}
@@ -1,12 +1,30 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { FsSafeError } from "./errors.js";
4
+ import { getNativeBinding } from "./native.js";
3
5
  import { normalizeLowercaseStringOrEmpty } from "./string-coerce.js";
4
6
  const TAR_SUFFIXES = [".tgz", ".tar.gz", ".tar"];
7
+ const NATIVE_TAR_SUFFIXES = [
8
+ { suffixes: [".tbz2", ".tbz", ".tar.bz2"], kind: "tar-bzip2" },
9
+ { suffixes: [".tzst", ".tar.zst", ".tar.zstd"], kind: "tar-zstd" },
10
+ ];
11
+ function requireNativeArchiveKind(kind) {
12
+ if (!getNativeBinding()) {
13
+ throw new FsSafeError("helper-unavailable", `${kind} archives require a supported bundled native binding; ` +
14
+ "use FS_SAFE_NATIVE_MODE=auto or require on a supported platform");
15
+ }
16
+ return kind;
17
+ }
5
18
  export function resolveArchiveKind(filePath) {
6
19
  const lower = normalizeLowercaseStringOrEmpty(filePath);
7
20
  if (lower.endsWith(".zip")) {
8
21
  return "zip";
9
22
  }
23
+ for (const { suffixes, kind } of NATIVE_TAR_SUFFIXES) {
24
+ if (suffixes.some((suffix) => lower.endsWith(suffix))) {
25
+ return requireNativeArchiveKind(kind);
26
+ }
27
+ }
10
28
  if (TAR_SUFFIXES.some((suffix) => lower.endsWith(suffix))) {
11
29
  return "tar";
12
30
  }
@@ -10,16 +10,25 @@ export type ArchiveExtractLimits = {
10
10
  maxExtractedBytes?: number;
11
11
  /** Max extracted bytes for a single file entry. */
12
12
  maxEntryBytes?: number;
13
+ /** Max bytes in one PAX, GNU long-name, or related TAR metadata entry. */
14
+ maxMetaEntryBytes?: number;
15
+ /** Max path components in one extracted entry after stripComponents. */
16
+ maxEntryPathComponents?: number;
13
17
  };
14
18
  export declare const DEFAULT_MAX_ARCHIVE_BYTES_ZIP: number;
15
19
  export declare const DEFAULT_MAX_ENTRIES = 50000;
16
20
  export declare const DEFAULT_MAX_EXTRACTED_BYTES: number;
17
21
  export declare const DEFAULT_MAX_ENTRY_BYTES: number;
22
+ export declare const DEFAULT_MAX_META_ENTRY_BYTES: number;
23
+ export declare const DEFAULT_MAX_ENTRY_PATH_COMPONENTS = 256;
18
24
  export declare const ARCHIVE_LIMIT_ERROR_CODE: {
19
25
  readonly ARCHIVE_SIZE_EXCEEDS_LIMIT: "archive-size-exceeds-limit";
20
26
  readonly ENTRY_COUNT_EXCEEDS_LIMIT: "archive-entry-count-exceeds-limit";
21
27
  readonly ENTRY_EXTRACTED_SIZE_EXCEEDS_LIMIT: "archive-entry-extracted-size-exceeds-limit";
22
28
  readonly EXTRACTED_SIZE_EXCEEDS_LIMIT: "archive-extracted-size-exceeds-limit";
29
+ readonly META_ENTRY_SIZE_EXCEEDS_LIMIT: "archive-meta-entry-size-exceeds-limit";
30
+ readonly MANIFEST_SIZE_EXCEEDS_LIMIT: "archive-manifest-size-exceeds-limit";
31
+ readonly ENTRY_PATH_COMPONENTS_EXCEEDS_LIMIT: "archive-entry-path-components-exceeds-limit";
23
32
  };
24
33
  export type ArchiveLimitErrorCode = (typeof ARCHIVE_LIMIT_ERROR_CODE)[keyof typeof ARCHIVE_LIMIT_ERROR_CODE];
25
34
  export declare class ArchiveLimitError extends Error {
@@ -28,6 +37,7 @@ export declare class ArchiveLimitError extends Error {
28
37
  }
29
38
  export type ResolvedArchiveExtractLimits = Required<ArchiveExtractLimits>;
30
39
  export declare function resolveExtractLimits(limits?: ArchiveExtractLimits): ResolvedArchiveExtractLimits;
40
+ export declare function assertArchiveEntryPathComponentsWithinLimit(entryPath: string, limits: ResolvedArchiveExtractLimits): void;
31
41
  export declare function assertArchiveEntryCountWithinLimit(entryCount: number, limits: ResolvedArchiveExtractLimits): void;
32
42
  export declare function createByteBudgetTracker(limits: ResolvedArchiveExtractLimits): {
33
43
  startEntry: () => void;
@@ -1 +1 @@
1
- {"version":3,"file":"archive-limits.d.ts","sourceRoot":"","sources":["../src/archive-limits.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,MAAM,MAAM,oBAAoB,GAAG;IACjC;;OAEG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,sDAAsD;IACtD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,8CAA8C;IAC9C,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,mDAAmD;IACnD,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,eAAO,MAAM,6BAA6B,QAAoB,CAAC;AAC/D,eAAO,MAAM,mBAAmB,QAAS,CAAC;AAC1C,eAAO,MAAM,2BAA2B,QAAoB,CAAC;AAC7D,eAAO,MAAM,uBAAuB,QAAoB,CAAC;AAEzD,eAAO,MAAM,wBAAwB;aACnC,0BAA0B,EAAE,4BAA4B;aACxD,yBAAyB,EAAE,mCAAmC;aAC9D,kCAAkC,EAAE,4CAA4C;aAChF,4BAA4B,EAAE,sCAAsC;CAC5D,CAAC;AAEX,MAAM,MAAM,qBAAqB,GAC/B,CAAC,OAAO,wBAAwB,CAAC,CAAC,MAAM,OAAO,wBAAwB,CAAC,CAAC;AAU3E,qBAAa,iBAAkB,SAAQ,KAAK;IAC1C,QAAQ,CAAC,IAAI,EAAE,qBAAqB,CAAC;IAErC,YAAY,IAAI,EAAE,qBAAqB,EAItC;CACF;AAED,MAAM,MAAM,4BAA4B,GAAG,QAAQ,CAAC,oBAAoB,CAAC,CAAC;AAU1E,wBAAgB,oBAAoB,CAClC,MAAM,CAAC,EAAE,oBAAoB,GAC5B,4BAA4B,CAQ9B;AAED,wBAAgB,kCAAkC,CAChD,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,4BAA4B,QAKrC;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,4BAA4B,GAAG;IAC7E,UAAU,EAAE,MAAM,IAAI,CAAC;IACvB,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACtC,CAiCA;AAED,wBAAgB,4BAA4B,CAAC,MAAM,EAAE;IACnD,YAAY,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACvC,GAAG,SAAS,CAYZ"}
1
+ {"version":3,"file":"archive-limits.d.ts","sourceRoot":"","sources":["../src/archive-limits.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,MAAM,MAAM,oBAAoB,GAAG;IACjC;;OAEG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,sDAAsD;IACtD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,8CAA8C;IAC9C,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,mDAAmD;IACnD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,0EAA0E;IAC1E,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,wEAAwE;IACxE,sBAAsB,CAAC,EAAE,MAAM,CAAC;CACjC,CAAC;AAEF,eAAO,MAAM,6BAA6B,QAAoB,CAAC;AAC/D,eAAO,MAAM,mBAAmB,QAAS,CAAC;AAC1C,eAAO,MAAM,2BAA2B,QAAoB,CAAC;AAC7D,eAAO,MAAM,uBAAuB,QAAoB,CAAC;AACzD,eAAO,MAAM,4BAA4B,QAAc,CAAC;AACxD,eAAO,MAAM,iCAAiC,MAAM,CAAC;AAErD,eAAO,MAAM,wBAAwB;aACnC,0BAA0B,EAAE,4BAA4B;aACxD,yBAAyB,EAAE,mCAAmC;aAC9D,kCAAkC,EAAE,4CAA4C;aAChF,4BAA4B,EAAE,sCAAsC;aACpE,6BAA6B,EAAE,uCAAuC;aACtE,2BAA2B,EAAE,qCAAqC;aAClE,mCAAmC,EAAE,6CAA6C;CAC1E,CAAC;AAEX,MAAM,MAAM,qBAAqB,GAC/B,CAAC,OAAO,wBAAwB,CAAC,CAAC,MAAM,OAAO,wBAAwB,CAAC,CAAC;AAgB3E,qBAAa,iBAAkB,SAAQ,KAAK;IAC1C,QAAQ,CAAC,IAAI,EAAE,qBAAqB,CAAC;IAErC,YAAY,IAAI,EAAE,qBAAqB,EAItC;CACF;AAED,MAAM,MAAM,4BAA4B,GAAG,QAAQ,CAAC,oBAAoB,CAAC,CAAC;AAU1E,wBAAgB,oBAAoB,CAClC,MAAM,CAAC,EAAE,oBAAoB,GAC5B,4BAA4B,CAY9B;AAED,wBAAgB,2CAA2C,CACzD,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,4BAA4B,GACnC,IAAI,CASN;AAED,wBAAgB,kCAAkC,CAChD,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,4BAA4B,QAKrC;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,4BAA4B,GAAG;IAC7E,UAAU,EAAE,MAAM,IAAI,CAAC;IACvB,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACtC,CAiCA;AAED,wBAAgB,4BAA4B,CAAC,MAAM,EAAE;IACnD,YAAY,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACvC,GAAG,SAAS,CAYZ"}