@openclaw/fs-safe 0.4.7 → 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.
- package/CHANGELOG.md +45 -0
- package/README.md +82 -43
- package/dist/archive-entry.d.ts.map +1 -1
- package/dist/archive-entry.js +5 -4
- package/dist/archive-errors.d.ts +11 -0
- package/dist/archive-errors.d.ts.map +1 -0
- package/dist/archive-errors.js +16 -0
- package/dist/archive-input.d.ts +12 -0
- package/dist/archive-input.d.ts.map +1 -0
- package/dist/archive-input.js +72 -0
- package/dist/archive-kind.d.ts +1 -1
- package/dist/archive-kind.d.ts.map +1 -1
- package/dist/archive-kind.js +18 -0
- package/dist/archive-limits.d.ts +10 -0
- package/dist/archive-limits.d.ts.map +1 -1
- package/dist/archive-limits.js +18 -0
- package/dist/archive-native.d.ts +18 -0
- package/dist/archive-native.d.ts.map +1 -0
- package/dist/archive-native.js +115 -0
- package/dist/archive-options.d.ts +21 -0
- package/dist/archive-options.d.ts.map +1 -0
- package/dist/archive-options.js +1 -0
- package/dist/archive-policy.d.ts +20 -0
- package/dist/archive-policy.d.ts.map +1 -0
- package/dist/archive-policy.js +29 -0
- package/dist/archive-read.d.ts +6 -0
- package/dist/archive-read.d.ts.map +1 -0
- package/dist/archive-read.js +200 -0
- package/dist/archive-staging.d.ts +2 -5
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +19 -10
- package/dist/archive-tar-meta.d.ts +6 -0
- package/dist/archive-tar-meta.d.ts.map +1 -0
- package/dist/archive-tar-meta.js +143 -0
- package/dist/archive-tar-runtime.d.ts +43 -0
- package/dist/archive-tar-runtime.d.ts.map +1 -0
- package/dist/archive-tar-runtime.js +8 -0
- package/dist/archive-tar.d.ts +5 -1
- package/dist/archive-tar.d.ts.map +1 -1
- package/dist/archive-tar.js +24 -6
- package/dist/archive-zip-entry.d.ts +15 -0
- package/dist/archive-zip-entry.d.ts.map +1 -0
- package/dist/archive-zip-entry.js +17 -0
- package/dist/archive.d.ts +7 -17
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +111 -108
- package/dist/atomic.d.ts +1 -1
- package/dist/atomic.d.ts.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -1
- package/dist/containment.d.ts +2 -0
- package/dist/containment.d.ts.map +1 -0
- package/dist/containment.js +1 -0
- package/dist/durability.d.ts +2 -0
- package/dist/durability.d.ts.map +1 -1
- package/dist/durability.js +2 -0
- package/dist/errors.d.ts +4 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -0
- package/dist/file-hash.d.ts +10 -0
- package/dist/file-hash.d.ts.map +1 -0
- package/dist/file-hash.js +73 -0
- package/dist/file-lock-sync.d.ts +35 -0
- package/dist/file-lock-sync.d.ts.map +1 -0
- package/dist/file-lock-sync.js +233 -0
- package/dist/file-lock.d.ts +2 -0
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +1 -0
- package/dist/filename.d.ts.map +1 -1
- package/dist/filename.js +4 -1
- package/dist/guarded-mkdir.d.ts +7 -1
- package/dist/guarded-mkdir.d.ts.map +1 -1
- package/dist/guarded-mkdir.js +45 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/json-document-store.d.ts.map +1 -1
- package/dist/json-document-store.js +30 -15
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +101 -17
- package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
- package/dist/native/darwin-x64/fs-safe-native.node +0 -0
- package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
- package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
- package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
- package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
- package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
- package/dist/native-binding.d.ts +79 -0
- package/dist/native-binding.d.ts.map +1 -0
- package/dist/native-binding.js +1 -0
- package/dist/native-config.d.ts +17 -0
- package/dist/native-config.d.ts.map +1 -0
- package/dist/native-config.js +71 -0
- package/dist/native-operations.d.ts +21 -0
- package/dist/native-operations.d.ts.map +1 -0
- package/dist/native-operations.js +115 -0
- package/dist/native-pinned-write.d.ts +5 -0
- package/dist/native-pinned-write.d.ts.map +1 -0
- package/dist/native-pinned-write.js +115 -0
- package/dist/native.d.ts +14 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +204 -0
- package/dist/output-sibling.d.ts +8 -0
- package/dist/output-sibling.d.ts.map +1 -0
- package/dist/output-sibling.js +122 -0
- package/dist/output.d.ts +2 -0
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +32 -5
- package/dist/owner-dacl.d.ts +31 -0
- package/dist/owner-dacl.d.ts.map +1 -0
- package/dist/owner-dacl.js +27 -0
- package/dist/path-policy.js +1 -1
- package/dist/permissions-public.d.ts +2 -0
- package/dist/permissions-public.d.ts.map +1 -1
- package/dist/permissions-public.js +2 -0
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +6 -0
- package/dist/pinned-operation.d.ts +2 -0
- package/dist/pinned-operation.d.ts.map +1 -0
- package/dist/pinned-operation.js +35 -0
- package/dist/pinned-write.d.ts +3 -16
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +20 -85
- package/dist/private-directory.d.ts +5 -0
- package/dist/private-directory.d.ts.map +1 -0
- package/dist/private-directory.js +13 -0
- package/dist/private-temp-workspace.d.ts +6 -2
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +40 -13
- package/dist/publish-file-failure.d.ts +27 -0
- package/dist/publish-file-failure.d.ts.map +1 -0
- package/dist/publish-file-failure.js +21 -0
- package/dist/publish-file.d.ts +21 -0
- package/dist/publish-file.d.ts.map +1 -0
- package/dist/publish-file.js +351 -0
- package/dist/read-opened-file.d.ts +3 -0
- package/dist/read-opened-file.d.ts.map +1 -1
- package/dist/read-opened-file.js +1 -0
- package/dist/replace-file-copy-fallback.d.ts +34 -0
- package/dist/replace-file-copy-fallback.d.ts.map +1 -0
- package/dist/replace-file-copy-fallback.js +339 -0
- package/dist/replace-file.d.ts +6 -2
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +39 -90
- package/dist/root-context.d.ts +4 -1
- package/dist/root-context.d.ts.map +1 -1
- package/dist/root-context.js +20 -1
- package/dist/root-impl.d.ts +5 -0
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +128 -225
- package/dist/root-path-existing.d.ts +3 -0
- package/dist/root-path-existing.d.ts.map +1 -0
- package/dist/root-path-existing.js +69 -0
- package/dist/root-path.d.ts +1 -2
- package/dist/root-path.d.ts.map +1 -1
- package/dist/root-path.js +67 -81
- package/dist/root-walk.d.ts +41 -0
- package/dist/root-walk.d.ts.map +1 -0
- package/dist/root-walk.js +114 -0
- package/dist/root.d.ts +2 -0
- package/dist/root.d.ts.map +1 -1
- package/dist/secret-file.d.ts +5 -2
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +20 -2
- package/dist/secret-read-async.d.ts +4 -0
- package/dist/secret-read-async.d.ts.map +1 -0
- package/dist/secret-read-async.js +95 -0
- package/dist/secret.d.ts +2 -1
- package/dist/secret.d.ts.map +1 -1
- package/dist/secret.js +2 -1
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +1 -0
- package/dist/sidecar-lock-handle.d.ts +20 -0
- package/dist/sidecar-lock-handle.d.ts.map +1 -0
- package/dist/sidecar-lock-handle.js +28 -0
- package/dist/sidecar-lock-policy.d.ts +10 -0
- package/dist/sidecar-lock-policy.d.ts.map +1 -0
- package/dist/sidecar-lock-policy.js +29 -0
- package/dist/sidecar-lock-reclaim.d.ts +22 -6
- package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
- package/dist/sidecar-lock-reclaim.js +106 -20
- package/dist/sidecar-lock-types.d.ts +56 -0
- package/dist/sidecar-lock-types.d.ts.map +1 -0
- package/dist/sidecar-lock-types.js +1 -0
- package/dist/sidecar-lock.d.ts +2 -45
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +94 -55
- package/dist/temp-cleanup.d.ts +7 -1
- package/dist/temp-cleanup.d.ts.map +1 -1
- package/dist/temp-cleanup.js +35 -5
- package/dist/temp.d.ts +2 -1
- package/dist/temp.d.ts.map +1 -1
- package/dist/test-hooks.d.ts +3 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/dist/windows-permissions-native.d.ts +9 -0
- package/dist/windows-permissions-native.d.ts.map +1 -0
- package/dist/windows-permissions-native.js +31 -0
- package/docs/archive.md +131 -13
- package/docs/atomic.md +30 -1
- package/docs/config.md +51 -21
- package/docs/durability.md +211 -1
- package/docs/errors.md +36 -6
- package/docs/filename.md +0 -0
- package/docs/index.md +11 -8
- package/docs/install.md +27 -26
- package/docs/json-store.md +18 -3
- package/docs/migrating-to-0.5.md +196 -0
- package/docs/native-helper.md +85 -0
- package/docs/native.md +134 -0
- package/docs/output.md +37 -11
- package/docs/path.md +1 -1
- package/docs/permissions.md +84 -1
- package/docs/quickstart.md +5 -2
- package/docs/reading.md +4 -4
- package/docs/root.md +31 -15
- package/docs/secret-file.md +50 -1
- package/docs/security-model.md +29 -8
- package/docs/sidecar-lock.md +92 -7
- package/docs/temp.md +24 -1
- package/docs/test-hooks.md +22 -2
- package/docs/testing.md +18 -7
- package/docs/types.md +3 -1
- package/docs/walk.md +55 -0
- package/docs/writing.md +3 -3
- package/package.json +8 -2
- package/dist/pinned-helper.d.ts +0 -9
- package/dist/pinned-helper.d.ts.map +0 -1
- package/dist/pinned-helper.js +0 -19
- package/dist/pinned-path.d.ts +0 -7
- package/dist/pinned-path.d.ts.map +0 -1
- package/dist/pinned-path.js +0 -21
- package/dist/pinned-python-config.d.ts +0 -9
- package/dist/pinned-python-config.d.ts.map +0 -1
- package/dist/pinned-python-config.js +0 -38
- package/dist/pinned-python.d.ts +0 -12
- package/dist/pinned-python.d.ts.map +0 -1
- package/dist/pinned-python.js +0 -688
- package/docs/python-helper.md +0 -103
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
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
|
+
|
|
3
48
|
## 0.4.7 - 2026-07-24
|
|
4
49
|
|
|
5
50
|
### Features
|
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
|
|
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) · [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)
|
|
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`** | **✓** | **✓** | **✓** |
|
|
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
|
-
|
|
63
|
-
fd-relative
|
|
64
|
-
|
|
65
|
-
|
|
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 {
|
|
68
|
+
import { configureFsSafeNative } from "@openclaw/fs-safe";
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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,36 +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
|
|
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
|
|
178
|
-
|
|
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`,
|
|
185
|
-
| `@openclaw/fs-safe/config` | process-global
|
|
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
|
|
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` |
|
|
192
|
-
| `@openclaw/fs-safe/durability` | pinned directory identities, strict
|
|
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 |
|
|
193
215
|
| `@openclaw/fs-safe/temp` | `tempWorkspace`, `tempWorkspaceSync`, `withTempWorkspace`, `resolveSecureTempRoot` |
|
|
194
216
|
| `@openclaw/fs-safe/secure-file` | fd-pinned absolute file reads with owner, mode, ACL, trusted-dir, size, and timeout checks |
|
|
195
|
-
| `@openclaw/fs-safe/file-lock` |
|
|
196
|
-
| `@openclaw/fs-safe/permissions` | POSIX mode and Windows ACL inspection
|
|
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 |
|
|
197
219
|
| `@openclaw/fs-safe/walk` | budget-bounded directory walking with symlink policy, filters, and truncation accounting; not root-bounded |
|
|
198
|
-
| `@openclaw/fs-safe/archive` |
|
|
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 |
|
|
199
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 |
|
|
200
|
-
| `@openclaw/fs-safe/errors` | `FsSafeError`,
|
|
222
|
+
| `@openclaw/fs-safe/errors` | `FsSafeError`, closed codes/categories, causes, and operation-specific details receipts |
|
|
201
223
|
| `@openclaw/fs-safe/types` | shared types: `DirEntry`, `PathStat`, … |
|
|
202
|
-
| `@openclaw/fs-safe/test-hooks` | hooks the test suite uses to inject races;
|
|
224
|
+
| `@openclaw/fs-safe/test-hooks` | hooks the test suite uses to inject races; registration requires `NODE_ENV=test` or `VITEST=true` |
|
|
203
225
|
|
|
204
226
|
## Failure semantics in the name
|
|
205
227
|
|
|
@@ -243,12 +265,18 @@ when creating a nested directory. Strict sync propagates real I/O failures and
|
|
|
243
265
|
reports known Windows directory-flush limitations explicitly. Separate
|
|
244
266
|
best-effort helpers preserve operations that do not promise crash durability.
|
|
245
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
|
+
|
|
246
274
|
See [Directory durability](docs/durability.md) for the receipt, pin lifecycle,
|
|
247
|
-
creation callback, and platform contract.
|
|
275
|
+
publication policy, creation callback, and platform contract.
|
|
248
276
|
|
|
249
277
|
## Atomic writes
|
|
250
278
|
|
|
251
|
-
`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.
|
|
252
280
|
|
|
253
281
|
```ts
|
|
254
282
|
import { replaceFileAtomic } from "@openclaw/fs-safe/atomic";
|
|
@@ -275,16 +303,19 @@ import { writeExternalFileWithinRoot } from "@openclaw/fs-safe/output";
|
|
|
275
303
|
await writeExternalFileWithinRoot({
|
|
276
304
|
rootDir: "/safe/workspace/downloads",
|
|
277
305
|
path: "reports/today.pdf",
|
|
306
|
+
staging: "sibling",
|
|
278
307
|
write: async (filePath) => {
|
|
279
308
|
await download.saveAs(filePath);
|
|
280
309
|
},
|
|
281
310
|
});
|
|
282
311
|
```
|
|
283
312
|
|
|
284
|
-
The callback receives a
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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.
|
|
288
319
|
|
|
289
320
|
Use it when the final filename is known before the external writer runs. If the
|
|
290
321
|
filename depends on sniffing the produced bytes, write to a private temp
|
|
@@ -406,6 +437,13 @@ for (const file of scan.entries) {
|
|
|
406
437
|
|
|
407
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.
|
|
408
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
|
+
|
|
409
447
|
## Archive extraction
|
|
410
448
|
|
|
411
449
|
`extractArchive()` handles ZIP and TAR behind one API, with traversal checks, blocked-link-type rejection, and entry-count and byte budgets.
|
|
@@ -426,6 +464,7 @@ await extractArchive({
|
|
|
426
464
|
maxEntries: 50_000,
|
|
427
465
|
maxExtractedBytes: 512 * 1024 * 1024,
|
|
428
466
|
maxEntryBytes: 256 * 1024 * 1024,
|
|
467
|
+
maxEntryPathComponents: 64,
|
|
429
468
|
},
|
|
430
469
|
});
|
|
431
470
|
```
|
|
@@ -474,19 +513,19 @@ if (err instanceof FsSafeError) {
|
|
|
474
513
|
}
|
|
475
514
|
```
|
|
476
515
|
|
|
477
|
-
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`.
|
|
478
517
|
|
|
479
518
|
## Safety model
|
|
480
519
|
|
|
481
520
|
- root-bounded APIs resolve paths against a configured root and reject canonical escapes
|
|
482
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
|
|
483
|
-
- writes
|
|
484
|
-
- `remove`, `mkdir`, `move`, `stat`, `list
|
|
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
|
|
485
524
|
- archive extraction stages into a private directory and merges through the same boundary checks used by direct writes
|
|
486
525
|
|
|
487
526
|
## Limitations
|
|
488
527
|
|
|
489
|
-
- Windows
|
|
528
|
+
- Windows native opens are handle-relative and reject reparse points; operations without native wiring use the guarded Node implementation.
|
|
490
529
|
- Hardlink rejection depends on platform metadata. Treat it as defense-in-depth, not authorization.
|
|
491
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.
|
|
492
531
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"archive-entry.d.ts","sourceRoot":"","sources":["../src/archive-entry.ts"],"names":[],"mappings":"
|
|
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"}
|
package/dist/archive-entry.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/dist/archive-kind.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"archive-kind.d.ts","sourceRoot":"","sources":["../src/archive-kind.ts"],"names":[],"mappings":"
|
|
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"}
|
package/dist/archive-kind.js
CHANGED
|
@@ -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
|
}
|
package/dist/archive-limits.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/archive-limits.js
CHANGED
|
@@ -3,17 +3,25 @@ export const DEFAULT_MAX_ARCHIVE_BYTES_ZIP = 256 * 1024 * 1024;
|
|
|
3
3
|
export const DEFAULT_MAX_ENTRIES = 50_000;
|
|
4
4
|
export const DEFAULT_MAX_EXTRACTED_BYTES = 512 * 1024 * 1024;
|
|
5
5
|
export const DEFAULT_MAX_ENTRY_BYTES = 256 * 1024 * 1024;
|
|
6
|
+
export const DEFAULT_MAX_META_ENTRY_BYTES = 1024 * 1024;
|
|
7
|
+
export const DEFAULT_MAX_ENTRY_PATH_COMPONENTS = 256;
|
|
6
8
|
export const ARCHIVE_LIMIT_ERROR_CODE = {
|
|
7
9
|
ARCHIVE_SIZE_EXCEEDS_LIMIT: "archive-size-exceeds-limit",
|
|
8
10
|
ENTRY_COUNT_EXCEEDS_LIMIT: "archive-entry-count-exceeds-limit",
|
|
9
11
|
ENTRY_EXTRACTED_SIZE_EXCEEDS_LIMIT: "archive-entry-extracted-size-exceeds-limit",
|
|
10
12
|
EXTRACTED_SIZE_EXCEEDS_LIMIT: "archive-extracted-size-exceeds-limit",
|
|
13
|
+
META_ENTRY_SIZE_EXCEEDS_LIMIT: "archive-meta-entry-size-exceeds-limit",
|
|
14
|
+
MANIFEST_SIZE_EXCEEDS_LIMIT: "archive-manifest-size-exceeds-limit",
|
|
15
|
+
ENTRY_PATH_COMPONENTS_EXCEEDS_LIMIT: "archive-entry-path-components-exceeds-limit",
|
|
11
16
|
};
|
|
12
17
|
const ARCHIVE_LIMIT_ERROR_MESSAGE = {
|
|
13
18
|
[ARCHIVE_LIMIT_ERROR_CODE.ARCHIVE_SIZE_EXCEEDS_LIMIT]: "archive size exceeds limit",
|
|
14
19
|
[ARCHIVE_LIMIT_ERROR_CODE.ENTRY_COUNT_EXCEEDS_LIMIT]: "archive entry count exceeds limit",
|
|
15
20
|
[ARCHIVE_LIMIT_ERROR_CODE.ENTRY_EXTRACTED_SIZE_EXCEEDS_LIMIT]: "archive entry extracted size exceeds limit",
|
|
16
21
|
[ARCHIVE_LIMIT_ERROR_CODE.EXTRACTED_SIZE_EXCEEDS_LIMIT]: "archive extracted size exceeds limit",
|
|
22
|
+
[ARCHIVE_LIMIT_ERROR_CODE.META_ENTRY_SIZE_EXCEEDS_LIMIT]: "archive metadata entry size exceeds limit",
|
|
23
|
+
[ARCHIVE_LIMIT_ERROR_CODE.MANIFEST_SIZE_EXCEEDS_LIMIT]: "archive manifest size exceeds limit",
|
|
24
|
+
[ARCHIVE_LIMIT_ERROR_CODE.ENTRY_PATH_COMPONENTS_EXCEEDS_LIMIT]: "archive entry path components exceed limit",
|
|
17
25
|
};
|
|
18
26
|
export class ArchiveLimitError extends Error {
|
|
19
27
|
code;
|
|
@@ -37,8 +45,18 @@ export function resolveExtractLimits(limits) {
|
|
|
37
45
|
maxEntries: clampLimit(limits?.maxEntries) ?? DEFAULT_MAX_ENTRIES,
|
|
38
46
|
maxExtractedBytes: clampLimit(limits?.maxExtractedBytes) ?? DEFAULT_MAX_EXTRACTED_BYTES,
|
|
39
47
|
maxEntryBytes: clampLimit(limits?.maxEntryBytes) ?? DEFAULT_MAX_ENTRY_BYTES,
|
|
48
|
+
maxMetaEntryBytes: clampLimit(limits?.maxMetaEntryBytes) ?? DEFAULT_MAX_META_ENTRY_BYTES,
|
|
49
|
+
maxEntryPathComponents: clampLimit(limits?.maxEntryPathComponents) ?? DEFAULT_MAX_ENTRY_PATH_COMPONENTS,
|
|
40
50
|
};
|
|
41
51
|
}
|
|
52
|
+
export function assertArchiveEntryPathComponentsWithinLimit(entryPath, limits) {
|
|
53
|
+
const components = entryPath
|
|
54
|
+
.split(/[\\/]+/u)
|
|
55
|
+
.filter((component) => component.length > 0 && component !== ".").length;
|
|
56
|
+
if (components > limits.maxEntryPathComponents) {
|
|
57
|
+
throw new ArchiveLimitError(ARCHIVE_LIMIT_ERROR_CODE.ENTRY_PATH_COMPONENTS_EXCEEDS_LIMIT);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
42
60
|
export function assertArchiveEntryCountWithinLimit(entryCount, limits) {
|
|
43
61
|
if (entryCount > limits.maxEntries) {
|
|
44
62
|
throw new ArchiveLimitError(ARCHIVE_LIMIT_ERROR_CODE.ENTRY_COUNT_EXCEEDS_LIMIT);
|