@openclaw/fs-safe 0.5.6 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +89 -0
- package/README.md +23 -10
- package/dist/advanced.d.ts +2 -1
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +1 -0
- package/dist/archive-crc32.d.ts +2 -0
- package/dist/archive-crc32.d.ts.map +1 -0
- package/dist/archive-crc32.js +14 -0
- package/dist/archive-deadline.d.ts +3 -0
- package/dist/archive-deadline.d.ts.map +1 -1
- package/dist/archive-deadline.js +44 -8
- package/dist/archive-entry.d.ts.map +1 -1
- package/dist/archive-entry.js +1 -0
- package/dist/archive-errors.d.ts +1 -0
- package/dist/archive-errors.d.ts.map +1 -1
- package/dist/archive-errors.js +3 -0
- package/dist/archive-input.d.ts.map +1 -1
- package/dist/archive-input.js +26 -16
- package/dist/archive-kind.js +2 -2
- package/dist/archive-limits.d.ts +11 -3
- package/dist/archive-limits.d.ts.map +1 -1
- package/dist/archive-limits.js +24 -0
- package/dist/archive-native.d.ts +3 -2
- package/dist/archive-native.d.ts.map +1 -1
- package/dist/archive-native.js +31 -9
- package/dist/archive-policy.d.ts +2 -0
- package/dist/archive-policy.d.ts.map +1 -1
- package/dist/archive-policy.js +9 -1
- package/dist/archive-read.d.ts.map +1 -1
- package/dist/archive-read.js +88 -41
- package/dist/archive-staging.d.ts +3 -0
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +91 -43
- package/dist/archive-tar-admission.d.ts +7 -0
- package/dist/archive-tar-admission.d.ts.map +1 -0
- package/dist/archive-tar-admission.js +43 -0
- package/dist/archive-tar-gnu.d.ts +2 -0
- package/dist/archive-tar-gnu.d.ts.map +1 -0
- package/dist/archive-tar-gnu.js +20 -0
- package/dist/archive-tar-header.d.ts +8 -0
- package/dist/archive-tar-header.d.ts.map +1 -0
- package/dist/archive-tar-header.js +47 -0
- package/dist/archive-tar-meta.d.ts +29 -1
- package/dist/archive-tar-meta.d.ts.map +1 -1
- package/dist/archive-tar-meta.js +135 -19
- package/dist/archive-tar-pax.d.ts +8 -0
- package/dist/archive-tar-pax.d.ts.map +1 -0
- package/dist/archive-tar-pax.js +100 -0
- package/dist/archive-tar-runtime.d.ts +4 -0
- package/dist/archive-tar-runtime.d.ts.map +1 -1
- package/dist/archive-tar-runtime.js +3 -0
- package/dist/archive-tar.d.ts.map +1 -1
- package/dist/archive-tar.js +8 -2
- package/dist/archive-zip-admission.d.ts +7 -0
- package/dist/archive-zip-admission.d.ts.map +1 -0
- package/dist/archive-zip-admission.js +60 -0
- package/dist/archive-zip-count.d.ts +2 -0
- package/dist/archive-zip-count.d.ts.map +1 -0
- package/dist/archive-zip-count.js +140 -0
- package/dist/archive-zip-directory.d.ts +8 -0
- package/dist/archive-zip-directory.d.ts.map +1 -0
- package/dist/archive-zip-directory.js +224 -0
- package/dist/archive-zip-integrity.d.ts.map +1 -1
- package/dist/archive-zip-integrity.js +1 -14
- package/dist/archive-zip-names.d.ts +12 -0
- package/dist/archive-zip-names.d.ts.map +1 -0
- package/dist/archive-zip-names.js +99 -0
- package/dist/archive-zip-preflight.d.ts +1 -1
- package/dist/archive-zip-preflight.d.ts.map +1 -1
- package/dist/archive-zip-preflight.js +8 -145
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +63 -42
- package/dist/atomic.d.ts +1 -1
- package/dist/atomic.d.ts.map +1 -1
- package/dist/bounded-read-stream.d.ts.map +1 -1
- package/dist/bounded-read-stream.js +2 -4
- package/dist/bounded-read.d.ts.map +1 -1
- package/dist/bounded-read.js +3 -10
- package/dist/byte-budget.d.ts +5 -0
- package/dist/byte-budget.d.ts.map +1 -0
- package/dist/byte-budget.js +9 -0
- package/dist/file-hash.d.ts.map +1 -1
- package/dist/file-hash.js +25 -19
- package/dist/file-lock-sync.d.ts.map +1 -1
- package/dist/file-lock-sync.js +26 -9
- package/dist/file-store-boundary.d.ts.map +1 -1
- package/dist/file-store-boundary.js +9 -5
- package/dist/file-store-limit.d.ts +2 -0
- package/dist/file-store-limit.d.ts.map +1 -0
- package/dist/file-store-limit.js +8 -0
- package/dist/file-store-sync-write.d.ts.map +1 -1
- package/dist/file-store-sync-write.js +38 -8
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +26 -26
- package/dist/json-durable-queue-directory.d.ts +2 -0
- package/dist/json-durable-queue-directory.d.ts.map +1 -0
- package/dist/json-durable-queue-directory.js +20 -0
- package/dist/json-durable-queue-ownership.d.ts +14 -0
- package/dist/json-durable-queue-ownership.d.ts.map +1 -0
- package/dist/json-durable-queue-ownership.js +168 -0
- package/dist/json-durable-queue-retirement.d.ts +9 -0
- package/dist/json-durable-queue-retirement.d.ts.map +1 -0
- package/dist/json-durable-queue-retirement.js +126 -0
- package/dist/json-durable-queue-transfer-lock.d.ts +2 -0
- package/dist/json-durable-queue-transfer-lock.d.ts.map +1 -0
- package/dist/json-durable-queue-transfer-lock.js +19 -0
- package/dist/json-durable-queue.d.ts +1 -0
- package/dist/json-durable-queue.d.ts.map +1 -1
- package/dist/json-durable-queue.js +90 -57
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +27 -8
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +4 -2
- package/dist/native-binding.d.ts +15 -3
- package/dist/native-binding.d.ts.map +1 -1
- package/dist/native-operations.d.ts +4 -1
- package/dist/native-operations.d.ts.map +1 -1
- package/dist/native-operations.js +22 -6
- package/dist/native-pinned-write-windows.d.ts +8 -0
- package/dist/native-pinned-write-windows.d.ts.map +1 -0
- package/dist/native-pinned-write-windows.js +92 -0
- package/dist/native-pinned-write.d.ts.map +1 -1
- package/dist/native-pinned-write.js +136 -127
- package/dist/native-staged-file.d.ts +24 -0
- package/dist/native-staged-file.d.ts.map +1 -0
- package/dist/native-staged-file.js +337 -0
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +4 -4
- package/dist/opened-realpath.d.ts +2 -0
- package/dist/opened-realpath.d.ts.map +1 -1
- package/dist/opened-realpath.js +12 -7
- package/dist/output-sibling.d.ts.map +1 -1
- package/dist/output-sibling.js +11 -110
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +4 -2
- package/dist/owner-dacl.d.ts.map +1 -1
- package/dist/owner-dacl.js +2 -1
- package/dist/permission-exec.d.ts +19 -0
- package/dist/permission-exec.d.ts.map +1 -1
- package/dist/permission-exec.js +57 -11
- package/dist/permissions-public.d.ts +1 -1
- package/dist/permissions-public.d.ts.map +1 -1
- package/dist/permissions-windows.d.ts +3 -0
- package/dist/permissions-windows.d.ts.map +1 -1
- package/dist/permissions-windows.js +16 -5
- package/dist/permissions.d.ts +5 -0
- package/dist/permissions.d.ts.map +1 -1
- package/dist/pinned-open.d.ts.map +1 -1
- package/dist/pinned-open.js +27 -48
- package/dist/pinned-write.d.ts +6 -0
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +26 -37
- package/dist/private-directory.d.ts.map +1 -1
- package/dist/private-directory.js +3 -2
- package/dist/private-temp-workspace.d.ts +3 -1
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +81 -56
- package/dist/publish-file.d.ts.map +1 -1
- package/dist/publish-file.js +2 -4
- package/dist/read-opened-file.d.ts.map +1 -1
- package/dist/read-opened-file.js +6 -4
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +136 -82
- package/dist/replace-file-copy-fallback.d.ts +3 -1
- package/dist/replace-file-copy-fallback.d.ts.map +1 -1
- package/dist/replace-file-copy-fallback.js +21 -36
- package/dist/replace-file-copy-source.d.ts +21 -0
- package/dist/replace-file-copy-source.d.ts.map +1 -0
- package/dist/replace-file-copy-source.js +112 -0
- package/dist/replace-file-descriptor.d.ts +13 -3
- package/dist/replace-file-descriptor.d.ts.map +1 -1
- package/dist/replace-file-descriptor.js +32 -7
- package/dist/replace-file-rename-policy.d.ts +7 -0
- package/dist/replace-file-rename-policy.d.ts.map +1 -0
- package/dist/replace-file-rename-policy.js +30 -0
- package/dist/replace-file-temp-owner.d.ts +46 -0
- package/dist/replace-file-temp-owner.d.ts.map +1 -0
- package/dist/replace-file-temp-owner.js +346 -0
- package/dist/replace-file.d.ts +6 -1
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +72 -58
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +118 -98
- package/dist/root-paths.d.ts +11 -14
- package/dist/root-paths.d.ts.map +1 -1
- package/dist/root-paths.js +36 -27
- package/dist/root-write-verification.d.ts +11 -0
- package/dist/root-write-verification.d.ts.map +1 -0
- package/dist/root-write-verification.js +91 -0
- package/dist/secret-file.d.ts +1 -6
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +49 -120
- package/dist/secret-read-async.d.ts +1 -1
- package/dist/secret-read-async.d.ts.map +1 -1
- package/dist/secret-read-async.js +51 -72
- package/dist/secret-read-policy.d.ts +13 -0
- package/dist/secret-read-policy.d.ts.map +1 -0
- package/dist/secret-read-policy.js +28 -0
- package/dist/secret.d.ts +2 -1
- package/dist/secret.d.ts.map +1 -1
- package/dist/secret.js +2 -1
- package/dist/secure-file.d.ts.map +1 -1
- package/dist/secure-file.js +42 -29
- package/dist/sibling-staged-file.d.ts +15 -0
- package/dist/sibling-staged-file.d.ts.map +1 -0
- package/dist/sibling-staged-file.js +148 -0
- package/dist/sibling-temp.d.ts +3 -0
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +30 -74
- package/dist/sidecar-lock-acquire.d.ts.map +1 -1
- package/dist/sidecar-lock-acquire.js +48 -27
- package/dist/sidecar-lock-handle.d.ts +6 -2
- package/dist/sidecar-lock-handle.d.ts.map +1 -1
- package/dist/sidecar-lock-handle.js +17 -3
- package/dist/sidecar-lock-policy.d.ts +2 -0
- package/dist/sidecar-lock-policy.d.ts.map +1 -1
- package/dist/sidecar-lock-policy.js +29 -0
- package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
- package/dist/sidecar-lock-reclaim.js +29 -7
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +28 -16
- package/dist/staged-directory.d.ts +16 -0
- package/dist/staged-directory.d.ts.map +1 -0
- package/dist/staged-directory.js +60 -0
- package/dist/staged-file-types.d.ts +56 -0
- package/dist/staged-file-types.d.ts.map +1 -0
- package/dist/staged-file-types.js +1 -0
- package/dist/staged-file.d.ts +10 -0
- package/dist/staged-file.d.ts.map +1 -0
- package/dist/staged-file.js +15 -0
- package/dist/strict-file-identity.d.ts +6 -0
- package/dist/strict-file-identity.d.ts.map +1 -0
- package/dist/strict-file-identity.js +48 -0
- package/dist/suppressed-error.d.ts +6 -0
- package/dist/suppressed-error.d.ts.map +1 -0
- package/dist/suppressed-error.js +15 -0
- package/dist/temp-cleanup.d.ts +2 -0
- package/dist/temp-cleanup.d.ts.map +1 -1
- package/dist/temp-cleanup.js +25 -10
- package/dist/temp-workspace-owner.d.ts +23 -0
- package/dist/temp-workspace-owner.d.ts.map +1 -0
- package/dist/temp-workspace-owner.js +320 -0
- package/dist/temp.d.ts +1 -1
- package/dist/temp.d.ts.map +1 -1
- package/dist/test-hooks.d.ts +5 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/dist/windows-owner.d.ts +3 -0
- package/dist/windows-owner.d.ts.map +1 -1
- package/dist/windows-owner.js +10 -2
- package/docs/advanced.md +19 -2
- package/docs/archive.md +250 -35
- package/docs/atomic.md +11 -2
- package/docs/config.md +7 -0
- package/docs/contributing.md +45 -7
- package/docs/durability.md +18 -5
- package/docs/errors.md +16 -1
- package/docs/file-store.md +2 -0
- package/docs/index.md +3 -1
- package/docs/install.md +20 -8
- package/docs/json.md +8 -4
- package/docs/migrating-to-0.5.md +7 -7
- package/docs/migrating-to-0.6.md +43 -0
- package/docs/native-helper.md +30 -8
- package/docs/native.md +73 -17
- package/docs/output.md +10 -0
- package/docs/path-scope.md +28 -2
- package/docs/permissions.md +13 -2
- package/docs/public-api.md +6 -3
- package/docs/quickstart.md +1 -1
- package/docs/reading.md +1 -1
- package/docs/regular-file.md +9 -2
- package/docs/root.md +3 -1
- package/docs/secret-file.md +12 -0
- package/docs/secure-file.md +21 -3
- package/docs/security-model.md +33 -1
- package/docs/sidecar-lock.md +15 -1
- package/docs/staged-file.md +178 -0
- package/docs/store.md +13 -0
- package/docs/temp.md +128 -14
- package/docs/testing.md +1 -1
- package/docs/writing.md +19 -0
- package/package.json +16 -9
- 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/docs/archive.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Archive extraction
|
|
2
2
|
|
|
3
|
-
`@openclaw/fs-safe/archive` extracts ZIP and TAR archives behind one API, with traversal checks, blocked-link-type rejection, and entry-count and byte budgets. When the
|
|
3
|
+
`@openclaw/fs-safe/archive` extracts ZIP and TAR archives behind one API, with traversal checks, blocked-link-type rejection, and entry-count and byte budgets. When the native binding is available for the current platform, Rust streams ZIP, TAR, gzip, zstd, and bzip2 while TypeScript remains the sole policy owner; every accepted output is created fd-relative in a private staging root. Extraction then merges through the same safe-open boundary used by direct writes — a symlinked entry can't trick the merge into following an out-of-tree path.
|
|
4
4
|
|
|
5
5
|
The guarded JavaScript fallback uses optional runtime dependencies: `jszip` for
|
|
6
6
|
ZIP and `tar` for TAR/gzip. The native path does not use those packages. Installs
|
|
@@ -23,7 +23,7 @@ await extractArchive({
|
|
|
23
23
|
archivePath: "/srv/uploads/plugin.zip",
|
|
24
24
|
destDir: "/srv/workspace/plugins/plugin",
|
|
25
25
|
kind: "zip", // optional; resolveArchiveKind() can infer
|
|
26
|
-
timeoutMs: 15_000, // hard
|
|
26
|
+
timeoutMs: 15_000, // hard budget; active destination mutation is joined
|
|
27
27
|
stripComponents: 0, // tar-style strip-leading-dirs
|
|
28
28
|
entryModes: "clamp", // default; use "preserve" for archive rwx bits
|
|
29
29
|
entryFilter: ({ path, kind, size }) => "extract",
|
|
@@ -45,7 +45,7 @@ await extractArchive({
|
|
|
45
45
|
type ExtractArchiveOptions = {
|
|
46
46
|
archivePath: string; // absolute path to the archive
|
|
47
47
|
destDir: string; // absolute destination directory; must already exist
|
|
48
|
-
timeoutMs: number; // positive wall-clock
|
|
48
|
+
timeoutMs: number; // positive wall-clock budget; <= 0/non-finite disables it
|
|
49
49
|
kind?: ArchiveKind; // "zip" | "tar" | "tar-zstd" | "tar-bzip2"
|
|
50
50
|
stripComponents?: number; // strip N leading dirs from entry paths
|
|
51
51
|
tarGzip?: boolean; // when archive is .tar.gz/.tgz
|
|
@@ -68,14 +68,43 @@ private staging tree; ZIP applies the same policy to `unixPermissions`.
|
|
|
68
68
|
Native extraction is deliberately split into two phases. Rust first reports an
|
|
69
69
|
entry manifest without creating paths. TypeScript validates paths, applies
|
|
70
70
|
`stripComponents`, filters, limits, and mode policy, then passes an explicit
|
|
71
|
-
accepted-entry plan back to Rust. Rust
|
|
72
|
-
fd-relative `mkdirBeneath`/exclusive-open writes. This keeps policy identical
|
|
71
|
+
accepted-entry plan back to Rust. Rust owns raw-stream admission, decompression,
|
|
72
|
+
and fd-relative `mkdirBeneath`/exclusive-open writes. This keeps filter policy identical
|
|
73
73
|
between native and JavaScript paths rather than reimplementing it in Rust.
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
ZIP extraction and bounded reads admit every physical central-directory record and its referenced local header before either decoder can normalize or collapse names. Raw names and valid Unicode Path names must pass traversal checks before stripping, filtering, or selecting a requested member; duplicate or colliding names reject with `entry-path`, even in unrelated or skipped members. Materially conflicting local/central or Unicode interpretations, malformed critical metadata, and ambiguous framing reject with `ArchiveFormatError`. Harmless separator and dot-component equivalence is allowed only after validation. Ordinary legacy filename decoding remains backend-selected.
|
|
76
|
+
|
|
77
|
+
`stripComponents` removes leading nonempty, non-`.` path components after
|
|
78
|
+
normalizing separators. For example, `./pkg/hello.txt` with
|
|
79
|
+
`stripComponents: 1` extracts to `hello.txt` on both backends. Entries with no
|
|
80
|
+
remaining components are skipped before the filter callback, but still count
|
|
81
|
+
toward `maxEntries` and undergo traversal validation. JavaScript TAR extraction
|
|
82
|
+
passes node-tar this accepted output path with its own stripping disabled, so
|
|
83
|
+
depth checks, collision checks, writes, and mode application agree.
|
|
84
|
+
|
|
85
|
+
An `entryFilter` sees the validated **canonical effective archive path before
|
|
86
|
+
stripping**, entry kind, and declared size. On every JavaScript and native
|
|
87
|
+
ZIP/TAR backend (including gzip and native zstd/bzip2), backslashes become `/`,
|
|
88
|
+
empty and `.` components are removed, and trailing separators are removed from
|
|
89
|
+
directory paths. For example, `./pkg//state\cache/value` is presented as
|
|
90
|
+
`pkg/state/cache/value`, even with `stripComponents: 1`. Case and Unicode
|
|
91
|
+
spelling are preserved. Local PAX `path`, GNU long-name, and supported ZIP
|
|
92
|
+
Unicode Path names use the same canonicalization.
|
|
93
|
+
|
|
94
|
+
Raw paths undergo traversal, absolute/drive-path, and NUL validation **before**
|
|
95
|
+
canonicalization; normalization cannot turn an unsafe path into an accepted
|
|
96
|
+
one. Stripping and output collision checks use this same canonical identity.
|
|
97
|
+
Filters that compare exact strings should use canonical pre-strip paths,
|
|
98
|
+
including directory names without a trailing `/`.
|
|
99
|
+
Returning `"skip"` rejects the whole archive unless `onFiltered` is
|
|
100
|
+
explicitly `"skip-entry"`. Runtime values other than `"reject-archive"` and
|
|
101
|
+
`"skip-entry"` reject before extraction starts instead of falling through to
|
|
102
|
+
skip behavior. Path traversal and archive-wide entry-count checks still apply
|
|
103
|
+
to skipped entries.
|
|
104
|
+
`maxEntryBytes` and `maxExtractedBytes` charge only entries accepted after
|
|
105
|
+
stripping and filtering. Skipping a large member does not consume these payload
|
|
106
|
+
budgets. The separate complete-stream decoded limit still applies to all TAR
|
|
107
|
+
content, including skipped or fully stripped members.
|
|
79
108
|
|
|
80
109
|
For example, a fleet restore can omit regenerated cache entries while rejecting
|
|
81
110
|
any other policy mismatch by default:
|
|
@@ -100,14 +129,33 @@ If skipping was not explicitly part of the restore contract, omit
|
|
|
100
129
|
`onFiltered`; the first `"skip"` then rejects the complete archive with
|
|
101
130
|
`ArchiveSecurityError("entry-filtered")`.
|
|
102
131
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
132
|
+
Both TAR implementations finish bounded admission before TypeScript policy
|
|
133
|
+
evaluation, so a rejected plan never starts extraction. The JavaScript path
|
|
134
|
+
owns the extraction file stream and aborts node-tar through a pipeline on
|
|
135
|
+
parser disagreement, validation, or timeout failure, destroying both ends
|
|
136
|
+
instead of leaving a paused parser to drain indefinitely.
|
|
137
|
+
|
|
138
|
+
TAR character devices, block devices, and FIFOs are presented to the filter as
|
|
139
|
+
`kind: "other"`. Accepted entries of these types reject with
|
|
140
|
+
`ArchiveSecurityError("entry-link")`; an explicit `"skip-entry"` filter can omit
|
|
141
|
+
them. GNU typeflag `D` (`GNUDumpDir`) is a directory on both backends, including
|
|
142
|
+
its filter kind, canonical path, and directory creation policy. Its declared
|
|
143
|
+
body size follows the existing TAR strip/filter payload budgets; dump contents
|
|
144
|
+
are not restored as files.
|
|
145
|
+
|
|
146
|
+
Unsupported logical TAR records, including volume headers (`V`), Solaris ACL
|
|
147
|
+
records (`A`), inodes (`I`), continuations (`M`), and unrecognized typeflags,
|
|
148
|
+
still undergo entry counting, raw/effective path validation, stripping, depth
|
|
149
|
+
and output collision checks in physical order. Each remaining record reaches
|
|
150
|
+
`entryFilter` once with its canonical pre-strip path, `kind: "other"`, and
|
|
151
|
+
declared effective size. A filter skip rejects with `"entry-filtered"` unless
|
|
152
|
+
`onFiltered: "skip-entry"` is explicit. Accepted unsupported records are safely
|
|
153
|
+
omitted and do not consume output payload budgets. This applies even when the
|
|
154
|
+
underlying TAR parser suppresses the record. GNU long names describe one such
|
|
155
|
+
record and are then cleared; local PAX on unsupported types and GNU sparse
|
|
156
|
+
`S` records retain their existing fail-closed format policy.
|
|
157
|
+
|
|
158
|
+
If `kind` is omitted, the helper calls `resolveArchiveKind(archivePath)` and throws if the extension is not recognized. Pass `kind` explicitly when the archive name doesn't carry the type (e.g. content-addressed names). Archive inputs must remain regular files from preview through descriptor admission; POSIX opens are no-follow and nonblocking, so a FIFO swap cannot stall before deadline checks resume. A positive finite `timeoutMs` is a wall-clock budget; zero, negative, `NaN`, and infinity disable the deadline. Non-mutating work rejects promptly when the budget expires. If a live destination mutation is already in flight, rejection waits only for that mutation and any rollback to finish; no later destination mutation can begin.
|
|
111
159
|
|
|
112
160
|
### Limits
|
|
113
161
|
|
|
@@ -115,8 +163,8 @@ If `kind` is omitted, the helper calls `resolveArchiveKind(archivePath)` and thr
|
|
|
115
163
|
type ArchiveExtractLimits = {
|
|
116
164
|
maxArchiveBytes?: number; // refuse if archivePath stat'd size exceeds this
|
|
117
165
|
maxEntries?: number; // refuse before extracting if entry count > this
|
|
118
|
-
maxExtractedBytes?: number; //
|
|
119
|
-
maxEntryBytes?: number; //
|
|
166
|
+
maxExtractedBytes?: number; // cap total payload bytes accepted after strip/filter
|
|
167
|
+
maxEntryBytes?: number; // cap one accepted entry after strip/filter
|
|
120
168
|
maxMetaEntryBytes?: number; // refuse one PAX/GNU metadata body above this
|
|
121
169
|
maxEntryPathComponents?: number; // bound output path depth after stripComponents
|
|
122
170
|
};
|
|
@@ -131,13 +179,24 @@ A limit violation throws `ArchiveLimitError`. Its constant and string code are:
|
|
|
131
179
|
| `ARCHIVE_SIZE_EXCEEDS_LIMIT` | `archive-size-exceeds-limit` |
|
|
132
180
|
| `ENTRY_COUNT_EXCEEDS_LIMIT` | `archive-entry-count-exceeds-limit` |
|
|
133
181
|
| `EXTRACTED_SIZE_EXCEEDS_LIMIT` | `archive-extracted-size-exceeds-limit` |
|
|
182
|
+
| `DECODED_SIZE_EXCEEDS_LIMIT` | `archive-decoded-size-exceeds-limit` |
|
|
134
183
|
| `ENTRY_EXTRACTED_SIZE_EXCEEDS_LIMIT` | `archive-entry-extracted-size-exceeds-limit` |
|
|
135
184
|
| `META_ENTRY_SIZE_EXCEEDS_LIMIT` | `archive-meta-entry-size-exceeds-limit` |
|
|
136
185
|
| `ENTRY_PATH_COMPONENTS_EXCEEDS_LIMIT` | `archive-entry-path-components-exceeds-limit` |
|
|
137
186
|
| `MANIFEST_SIZE_EXCEEDS_LIMIT` | `archive-manifest-size-exceeds-limit` |
|
|
138
187
|
|
|
139
|
-
`MANIFEST_SIZE_EXCEEDS_LIMIT` is
|
|
140
|
-
|
|
188
|
+
`MANIFEST_SIZE_EXCEEDS_LIMIT` is an active internal TAR admission limit, shared
|
|
189
|
+
by JavaScript and native extraction and bounded reads. Each logical member,
|
|
190
|
+
including ignored, filtered, and fully stripped members, charges
|
|
191
|
+
`64 + 2 * UTF-8 byte length of its effective pre-strip path` before emission or
|
|
192
|
+
retention. PAX/GNU metadata headers do not themselves charge a member cost.
|
|
193
|
+
The allowance is independent of `maxArchiveBytes`: derive a per-member path
|
|
194
|
+
allowance of `max(256, min(maxMetaEntryBytes, max(1, maxEntryPathComponents) * 256))`,
|
|
195
|
+
apply the same 64-byte overhead and doubled path cost, multiply by `maxEntries`,
|
|
196
|
+
and cap the total at 64 MiB using saturating arithmetic. Zero and very large
|
|
197
|
+
public limits remain deterministic. There is no public `maxManifestBytes`
|
|
198
|
+
option; this charged manifest budget supplements the decoded and metadata
|
|
199
|
+
limits rather than bounding the complete process heap.
|
|
141
200
|
|
|
142
201
|
Catch and branch on the code to surface a meaningful response to the caller.
|
|
143
202
|
|
|
@@ -149,22 +208,161 @@ codes remain `"destination-not-directory"`, `"destination-symlink"`, and
|
|
|
149
208
|
## What it defends against
|
|
150
209
|
|
|
151
210
|
- **Path traversal:** entries with `..`, absolute paths, NUL bytes, or Windows drive-relative segments such as `C:secret` and `nested/C:secret` are rejected (`ArchiveSecurityError`). On Windows, path segments containing `:` are also rejected as alternate data stream names before either backend writes to the filesystem.
|
|
152
|
-
- **Symlink/hardlink entries:** rejected by default. Some archives ship symlink/hardlink entries that point outside the destination once resolved; `extractArchive` does not follow them.
|
|
211
|
+
- **Symlink/hardlink entries:** rejected by default, including ZIP entries whose Unix mode says symlink while their name ends in a slash or their DOS directory bit is set. An explicit `entryFilter` with `onFiltered: "skip-entry"` can omit these entries. Some archives ship symlink/hardlink entries that point outside the destination once resolved; `extractArchive` does not follow them.
|
|
153
212
|
- **Ambiguous output names:** duplicate names and distinct names that collide after `stripComponents`, case normalization, or Unicode normalization are rejected instead of relying on backend- or volume-specific overwrite order.
|
|
154
213
|
- **TOCTOU during merge:** extraction first writes to a private temp dir, then merges into `destDir` using the same boundary checks as `root().write()`. Destination symlink swaps are checked with the selected platform mechanism; non-Linux routes retain the best-effort race window documented in the [security model](security-model.md#containment-guarantees-by-platform).
|
|
155
214
|
- **Zip bombs:** `maxExtractedBytes` and `maxEntryBytes` apply to *post-decompression* bytes, so highly-compressed payloads hit the cap before they exhaust disk.
|
|
156
215
|
- **Corrupt ZIP payloads:** streamed output must match both the central-directory CRC and declared uncompressed size before it can leave private staging.
|
|
157
|
-
- **
|
|
158
|
-
- **
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
216
|
+
- **Corrupt gzip streams:** truncated compressed bodies, missing trailers, and checksum failures reject before extraction publishes files or an entry read returns bytes, on both JavaScript and native backends.
|
|
217
|
+
- **Slow-loris archives:** `timeoutMs` is a hard wall-clock budget for non-mutating work. Extraction is aborted on overrun; if a destination mutation is already in flight, that mutation and rollback are joined before rejection so archive-controlled publication cannot continue afterward.
|
|
218
|
+
- **Metadata bombs:** a streaming pass-through reader rejects oversized PAX, GNU long-name, and GNU long-link bodies before either TAR implementation buffers them. It understands octal and base-256 fixed sizes and validates bounded local PAX bodies before using their size overrides for member framing. Original archive bytes remain unchanged.
|
|
219
|
+
|
|
220
|
+
### Raw TAR framing
|
|
221
|
+
|
|
222
|
+
Extraction and bounded reads admit the complete decoded TAR stream through the
|
|
223
|
+
raw meter before either backend's TAR parser runs. This applies to plain TAR,
|
|
224
|
+
gzip, and native-supported zstd/bzip2, without changing native-mode availability
|
|
225
|
+
or fallback policy. The existing TypeScript and Rust meters enforce the same
|
|
226
|
+
framing rules before parser normalization:
|
|
227
|
+
|
|
228
|
+
- Every nonzero header must have a valid unsigned octal checksum, delimited
|
|
229
|
+
within its field. Checksum validation precedes metadata allocation and member
|
|
230
|
+
policy. Fixed name, prefix, and linkname fields require strict UTF-8 and NUL
|
|
231
|
+
padding. Raw hardlink (`1`) and symlink (`2`) headers require a nonempty
|
|
232
|
+
linkname; every other type, including PAX/GNU metadata, requires an empty
|
|
233
|
+
linkname. This check precedes metadata handling and member/filter policy.
|
|
234
|
+
- Directory (`5`), hardlink (`1`), and symlink (`2`) raw headers must declare
|
|
235
|
+
zero body bytes, whether or not local PAX metadata is present. Valid zero-size
|
|
236
|
+
links remain subject to the existing link/filter policy.
|
|
237
|
+
- EOF requires two consecutive, complete 512-byte zero blocks at a header
|
|
238
|
+
boundary. A header after just one zero block, a missing/partial EOF marker,
|
|
239
|
+
and any nonzero bytes after EOF reject. Additional zero padding after EOF may
|
|
240
|
+
have any byte length within the decoded ceiling; zero blocks inside a declared
|
|
241
|
+
member body are payload.
|
|
242
|
+
- Headers and padded bodies must be complete. Size fields accept unsigned
|
|
243
|
+
octal with ASCII-space/NUL padding or supported positive base-256 encoding;
|
|
244
|
+
malformed numbers and non-padding bytes after a NUL reject. Raw sizes and
|
|
245
|
+
padded sizes must fit `Number.MAX_SAFE_INTEGER`, even with PAX overrides,
|
|
246
|
+
before member budgets are considered.
|
|
247
|
+
|
|
248
|
+
Framing failures use `ArchiveFormatError("archive-header-invalid")`. PAX `x`
|
|
249
|
+
and GNU long-name/long-link `L`/`K` payloads retain their existing support and
|
|
250
|
+
metadata limits; the zero-body rule is not applied to all non-regular types.
|
|
251
|
+
PAX effective sizes still determine regular-member framing. Admission preserves
|
|
252
|
+
the input bytes, and all entry/path/byte limits and extraction deadlines remain
|
|
253
|
+
in force. Native inspection now completes this admission pass before parsing,
|
|
254
|
+
requiring one additional streaming read/decompression pass.
|
|
255
|
+
JavaScript admission reports an ordered logical-member manifest from the raw
|
|
256
|
+
meter, bounded by entry-count, manifest, and decoded limits. Policy runs once
|
|
257
|
+
over that manifest; extraction checks parser-visible members against the
|
|
258
|
+
accepted decisions before writing. Original member names and USTAR prefixes
|
|
259
|
+
are validated even when overridden, and non-padding bytes after a fixed path
|
|
260
|
+
field's NUL terminator reject rather than hiding an unsafe suffix.
|
|
261
|
+
Both meters enforce the 255-byte component ceiling under NFC and NFD before
|
|
262
|
+
metadata replaces a raw path, including Hangul decomposition expansion.
|
|
263
|
+
Native extraction and entry reads also drain their metered readers through
|
|
264
|
+
physical EOF after parser traversal, before completing directory modes,
|
|
265
|
+
publishing staged files, or returning the requested bytes. Finding the requested
|
|
266
|
+
member or reaching the parser's logical EOF cannot bypass trailing validation.
|
|
267
|
+
|
|
268
|
+
The raw meter enforces `maxEntries` before consuming each logical member's body,
|
|
269
|
+
including members later skipped by filtering or stripping. PAX/GNU metadata
|
|
270
|
+
headers do not count as members; their payloads use `maxMetaEntryBytes`.
|
|
271
|
+
The meter does not receive `maxEntryBytes` or `maxExtractedBytes`: those payload
|
|
272
|
+
budgets apply only after strip/filter acceptance, using declared effective
|
|
273
|
+
sizes and excluding block padding. JavaScript's entry checker and the native
|
|
274
|
+
accepted-plan builder retain this shared policy. Every TAR admission/parser
|
|
275
|
+
pass has a separate absolute decoded ceiling:
|
|
276
|
+
`maxExtractedBytes + maxArchiveBytes`, safely clamped to
|
|
277
|
+
`Number.MAX_SAFE_INTEGER` (768 MiB with defaults). It counts every admitted
|
|
278
|
+
decoded byte: headers, bodies, metadata, all block padding, both EOF blocks,
|
|
279
|
+
and zero padding after EOF. It bounds complete decoding before parser policy,
|
|
280
|
+
including all filtered/stripped content; cumulative metadata and zero tails
|
|
281
|
+
cannot bypass this bound. Exceeding this ceiling throws
|
|
282
|
+
`ArchiveLimitError("archive-decoded-size-exceeds-limit")`.
|
|
283
|
+
|
|
284
|
+
The same TypeScript helper derives the ceiling for JavaScript and every native
|
|
285
|
+
TAR pass. Before selecting a backend, it caps internal metadata/decoded limits at
|
|
286
|
+
`Number.MAX_SAFE_INTEGER` and logical entry counts at `2^32 - 1`. Larger finite
|
|
287
|
+
options such as `Number.MAX_VALUE` remain valid; high-level payload budgets keep
|
|
288
|
+
their large values. The decoded ceiling uses clamped `maxExtractedBytes` and
|
|
289
|
+
archive overhead with safe addition. Ordinary limits, including
|
|
290
|
+
zero and the existing defaulting/rounding rules, retain their behavior.
|
|
291
|
+
There is no new public option. This is an absolute decoded admission
|
|
292
|
+
cap, not a decompression-ratio policy; bounded stream/codec read-ahead remains.
|
|
293
|
+
After this complete preflight, the JavaScript backend disables node-tar's
|
|
294
|
+
independent ratio threshold so it cannot reject data that the native backend
|
|
295
|
+
accepts within the same absolute limits.
|
|
296
|
+
|
|
297
|
+
### Bounded local PAX support
|
|
298
|
+
|
|
299
|
+
Extraction and single-entry reads accept one nonempty local POSIX `x` header
|
|
300
|
+
(USTAR or GNU header format) immediately before one regular/contiguous file,
|
|
301
|
+
directory, symlink, or hardlink. `path`, `linkpath`, and `size` override that
|
|
302
|
+
member only. Effective paths still pass traversal validation before stripping,
|
|
303
|
+
then the output paths pass depth and collision checks. The filter receives the
|
|
304
|
+
canonical effective pre-strip path, followed by link policy checks. PAX never
|
|
305
|
+
permits link creation. Effective sizes drive framing, filters, and the existing output-byte
|
|
306
|
+
budgets; `maxEntries` still counts members, not their metadata headers.
|
|
307
|
+
|
|
308
|
+
Records must have exact byte lengths, ASCII keys, a final newline, and no
|
|
309
|
+
duplicate keys, embedded newlines, or unconsumed bytes. Structural `path` and
|
|
310
|
+
`linkpath` values and ownership names must be nonempty printable ASCII. A PAX
|
|
311
|
+
member's raw name, USTAR prefix, and raw link target must also be printable
|
|
312
|
+
ASCII; raw link targets must be present only on links, even when overridden.
|
|
313
|
+
Unicode
|
|
314
|
+
PAX structural text is deliberately unsupported because the underlying parsers
|
|
315
|
+
do not agree when UTF-8 is split across input chunks. `size`, `uid`, and `gid`
|
|
316
|
+
must be canonical unsigned decimal safe integers (zero is valid; signs, leading
|
|
317
|
+
zeros, fractions, and exponents are not). Padded member sizes must also fit the
|
|
318
|
+
safe integer range. Raw and effective directory/link sizes must both be zero;
|
|
319
|
+
non-directory paths ending with a separator and `linkpath` on non-links are
|
|
320
|
+
rejected rather than allowing parser-specific type or framing changes.
|
|
321
|
+
|
|
322
|
+
The descriptive allowlist is `mtime`, `atime`, `ctime` (signed decimal seconds
|
|
323
|
+
with optional fractional digits, within JavaScript's Date range), `uid`, `gid`,
|
|
324
|
+
`uname`, and `gname`. These attributes are accepted but not restored to the
|
|
325
|
+
destination. `LIBARCHIVE.xattr.*` and `SCHILY.xattr.*` with nonempty ASCII
|
|
326
|
+
alphanumeric/dot/underscore/hyphen suffixes are also accepted as inert metadata,
|
|
327
|
+
never restored as extended attributes. Their values are byte-counted and may
|
|
328
|
+
contain NUL or non-UTF8 bytes, including macOS provenance metadata; embedded
|
|
329
|
+
newlines are rejected because they can disrupt downstream record parsing.
|
|
330
|
+
|
|
331
|
+
Global `g`, old `X`, old GNU `N`, empty/dangling/repeated local headers, mixed
|
|
332
|
+
PAX/GNU extension chains, unknown keys, charset declarations, ACL extensions,
|
|
333
|
+
and all sparse extensions (including `GNU.sparse.*`, `SCHILY.filetype`,
|
|
334
|
+
`SCHILY.realsize`, and `SCHILY.size`) fail closed with
|
|
335
|
+
`ArchiveFormatError("archive-header-invalid")`. GNU sparse extension blocks are still
|
|
336
|
+
metered in 512-byte units before rejection, preserving metadata-limit errors
|
|
337
|
+
for excessive chains. The per-body `maxMetaEntryBytes` limit bounds PAX storage
|
|
338
|
+
and duplicate-key state; one local header per member prevents local metadata
|
|
339
|
+
chains without introducing a new limit or changing defaults.
|
|
340
|
+
|
|
341
|
+
### Bounded GNU long names and links
|
|
342
|
+
|
|
343
|
+
Both raw meters buffer GNU long-name `L` and long-link `K` bodies within
|
|
344
|
+
`maxMetaEntryBytes` before either TAR parser runs. A body must contain a nonempty
|
|
345
|
+
UTF-8 name, with either no NUL or exactly one terminal NUL. Embedded NULs,
|
|
346
|
+
additional terminal NULs, bytes after a NUL, and invalid UTF-8 reject with
|
|
347
|
+
`ArchiveFormatError("archive-header-invalid")`. The meters preserve original
|
|
348
|
+
archive bytes, including the optional terminator and block padding.
|
|
349
|
+
|
|
350
|
+
One logical member may have at most one `L` and one `K`, in either order.
|
|
351
|
+
Repeated metadata of either kind, mixed PAX/GNU chains in either direction,
|
|
352
|
+
and GNU metadata without a following member reject with the same format error.
|
|
353
|
+
Pending metadata is cleared only when its described member is admitted;
|
|
354
|
+
metadata records do not count toward `maxEntries`.
|
|
355
|
+
|
|
356
|
+
An `L` name undergoes raw-path validation before parser normalization, stripping,
|
|
357
|
+
or filtering; unsafe paths reject with `ArchiveSecurityError("entry-path")`.
|
|
358
|
+
The validated name remains pending until its described header arrives. An
|
|
359
|
+
effective name ending in `/` or `\` requires raw directory type `5` or `D`;
|
|
360
|
+
other types reject with `ArchiveFormatError` before filtering, preventing the
|
|
361
|
+
parsers from disagreeing about a member's type.
|
|
362
|
+
`K` validates encoding and NUL structure without authorizing link creation.
|
|
363
|
+
Normal link/filter policy still governs the described member. Canonical
|
|
364
|
+
pre-strip filter paths, decoded-stream ceilings, and physical EOF checks apply
|
|
365
|
+
to plain/gzip TAR and native zstd/bzip2 alike.
|
|
168
366
|
|
|
169
367
|
## `resolveArchiveKind`
|
|
170
368
|
|
|
@@ -212,11 +410,24 @@ await extractArchive({
|
|
|
212
410
|
regular-file entry into a bounded `Buffer` without extracting a tree. It pins
|
|
213
411
|
and privately stages the archive input, rejects link, directory, and duplicate
|
|
214
412
|
entries, verifies ZIP CRC and declared size,
|
|
215
|
-
and throws `ArchiveLimitError` if
|
|
413
|
+
and throws `ArchiveLimitError` if the requested entry's output exceeds
|
|
414
|
+
`maxBytes`. For TAR, `maxBytes` applies only to that requested entry: a larger
|
|
415
|
+
unrequested member remains valid within the default archive admission limits.
|
|
416
|
+
TAR traversal uses default entry-count, compressed-input, and metadata limits,
|
|
417
|
+
plus the 768 MiB decoded ceiling derived from default extracted/archive byte
|
|
418
|
+
limits. It does not apply payload budgets to unrequested members. ZIP
|
|
216
419
|
inputs retain the archive subpath's 256 MiB compressed-input ceiling.
|
|
217
420
|
With a native binding it uses the same Rust decoders as extraction, including
|
|
218
421
|
zstd and bzip2 TAR. Without native it retains the JS ZIP/TAR/gzip implementation.
|
|
219
422
|
|
|
423
|
+
Requested paths and effective member names use extraction's canonical pre-strip
|
|
424
|
+
identity: backslashes become `/`, and repeated separators and `.` components
|
|
425
|
+
are removed after raw-path validation. For example, `./pkg//value` and
|
|
426
|
+
`pkg\value` both address `pkg/value`, including supported GNU/PAX and ZIP
|
|
427
|
+
Unicode Path names. Case and Unicode spelling are preserved. Requests ending
|
|
428
|
+
in `/` or `\` still reject as non-files. Canonical duplicate members reject
|
|
429
|
+
before an unrelated requested entry can be returned.
|
|
430
|
+
|
|
220
431
|
```ts
|
|
221
432
|
const rawManifest = await readArchiveEntry(uploadPath, "package/manifest.json", {
|
|
222
433
|
maxBytes: 64 * 1024,
|
|
@@ -257,10 +468,14 @@ import {
|
|
|
257
468
|
|
|
258
469
|
- `validateArchiveEntryPath(raw, opts)` — throws `ArchiveSecurityError` for `..`, absolute, NUL-containing, drive-relative, or otherwise unsafe entry paths, including alternate data stream names on Windows.
|
|
259
470
|
- `normalizeArchiveEntryPath(raw)` — converts backslashes in the entry path to forward slashes.
|
|
260
|
-
- `stripArchivePath(entryPath, n)` — strip the leading N
|
|
471
|
+
- `stripArchivePath(entryPath, n)` — normalize separators, drop empty and `.` components, then strip the leading N components, returning `null` if none remain.
|
|
261
472
|
- `resolveArchiveOutputPath({ destDir, entryPath })` — combines the entry path with the destination, after validation.
|
|
262
473
|
- `isWindowsDrivePath(value)` — detects drive-relative segments such as `C:secret` or `nested/C:secret` that should be rejected.
|
|
263
474
|
|
|
475
|
+
Validate attacker-controlled paths before calling normalization or stripping
|
|
476
|
+
helpers. After validation, `stripArchivePath(entryPath, 0)` returns the canonical
|
|
477
|
+
pre-strip identity used by extraction filters (or `null` for an empty path).
|
|
478
|
+
|
|
264
479
|
## Common patterns
|
|
265
480
|
|
|
266
481
|
### Extract an upload, surface budget violations
|
package/docs/atomic.md
CHANGED
|
@@ -48,6 +48,7 @@ type ReplaceFileAtomicOptions = {
|
|
|
48
48
|
copyFallbackRestore?: "restore-original" | "none"; // default: "none"
|
|
49
49
|
maxRestoreBytes?: number; // required with "restore-original"
|
|
50
50
|
destinationHardlinks?: "reject"; // default unset (no destination nlink policy)
|
|
51
|
+
renameIdentity?: "strict" | "verify-content-with-lock"; // default "strict"
|
|
51
52
|
syncTempFile?: boolean; // fsync(temp) before rename, or the final file after copy fallback; default false
|
|
52
53
|
syncParentDir?: boolean; // fsync(parent) after rename, POSIX only; default false
|
|
53
54
|
throwOnCleanupError?: boolean; // report temp cleanup failure; default false
|
|
@@ -58,7 +59,7 @@ type ReplaceFileAtomicOptions = {
|
|
|
58
59
|
|
|
59
60
|
### `beforeRename`
|
|
60
61
|
|
|
61
|
-
Runs after the temp file is fully written and before the rename. Use it to take a backup snapshot, capture the about-to-be-replaced contents, or notify an observer:
|
|
62
|
+
Runs after the temp file is fully written and before the rename. Use it to take a backup snapshot, capture the about-to-be-replaced contents, or notify an observer. The helper retains the staged descriptor and exact bigint identity across the hook; replacing, deleting, hardlinking, or changing the temp entry to a non-regular file is rejected before publication:
|
|
62
63
|
|
|
63
64
|
```ts
|
|
64
65
|
await replaceFileAtomic({
|
|
@@ -70,7 +71,15 @@ await replaceFileAtomic({
|
|
|
70
71
|
});
|
|
71
72
|
```
|
|
72
73
|
|
|
73
|
-
If `beforeRename` throws, the rename is skipped and the temp file is removed — the destination is unchanged.
|
|
74
|
+
If `beforeRename` throws, the rename is skipped and the owned temp file is removed — the destination is unchanged. Cleanup unlinks only the exact admitted single-link file; a substitute observed at the temp name is preserved and removed from cleanup authority. The same identity is rechecked before every rename retry, when entering copy fallback, and at the final name after rename. A post-rename verification failure reports the race without rolling back or deleting the published name.
|
|
75
|
+
|
|
76
|
+
Identity checks and pathname rename/unlink remain separate syscalls, not atomic conditional mutations. Use an approved writable parent plus cooperative locking or OS isolation when arbitrary concurrent namespace mutation is in scope.
|
|
77
|
+
|
|
78
|
+
### FUSE mounts and unstable rename identity
|
|
79
|
+
|
|
80
|
+
Strict source-to-destination identity is the default. Some FUSE mounts assign a different inode to the destination during rename even without concurrency. Set `renameIdentity: "verify-content-with-lock"` to accept that boundary only when the re-opened no-follow destination has the exact requested SHA-256 content under an exclusive hashed sidecar lock in the destination parent. The newly accepted descriptor and identity remain pinned through parent sync and final verification. The synchronous helper provides the same policy with the synchronous lock implementation.
|
|
81
|
+
|
|
82
|
+
This is the same explicit weaker contract available on `Root` writes: cooperating writers are serialized, stale locks fail closed, and mismatched content is rejected after publication without rollback. A same-authority actor that ignores the advisory lock can still substitute another file with identical bytes, so do not use this compatibility policy in directories writable by untrusted same-UID processes.
|
|
74
83
|
|
|
75
84
|
### `EPERM` and copy fallback
|
|
76
85
|
|
package/docs/config.md
CHANGED
|
@@ -78,6 +78,13 @@ configureFsSafeLocks({
|
|
|
78
78
|
});
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
+
These defaults apply to both `acquireFileLock()` / `withFileLock()` and
|
|
82
|
+
`acquireFileLockSync()` / `withFileLockSync()`. Each acquisition resolves
|
|
83
|
+
`retry`, `staleMs`, `staleRecovery`, and `timeoutMs` from the per-call option
|
|
84
|
+
first, then the process configuration, then the package default. Explicit zero
|
|
85
|
+
values are preserved. A per-call `retry` object replaces the configured object
|
|
86
|
+
as a whole; omitted retry fields use package defaults, not configured fields.
|
|
87
|
+
|
|
81
88
|
Individual lock calls can override any default. Switching the global stale
|
|
82
89
|
recovery mode does not provide the application-owned liveness proof required
|
|
83
90
|
by `shouldRemoveStaleLock`.
|
package/docs/contributing.md
CHANGED
|
@@ -46,23 +46,55 @@ pnpm check
|
|
|
46
46
|
This runs the filesystem boundary checks, build, tests, and package
|
|
47
47
|
tarball/import validation.
|
|
48
48
|
|
|
49
|
+
### Native consumer installs
|
|
50
|
+
|
|
51
|
+
After `pnpm build` and a fresh `pnpm native:build`, run `pnpm package:smoke`.
|
|
52
|
+
It packs the real root and host binding, then runs root-only npm and the
|
|
53
|
+
declared pnpm version against a disposable loopback registry. The root's exact
|
|
54
|
+
optional dependencies stay unchanged. Each consumer lives outside the workspace
|
|
55
|
+
with isolated configuration, caches, and stores; the registry never proxies to
|
|
56
|
+
the Internet. The smoke verifies root integrity, consumer-local resolution,
|
|
57
|
+
OS/CPU/libc selection, a native-required SHA-256 operation, and fresh-process
|
|
58
|
+
`auto`/`off` fallbacks and `require` failures for missing bindings and omitted
|
|
59
|
+
optionals. Omitted-optionals installs also verify that all public subpaths can
|
|
60
|
+
be imported, without implying every operation remains available.
|
|
61
|
+
|
|
62
|
+
Host-only smoke supplies the six foreign packages using their unchanged real
|
|
63
|
+
manifests and clearly marked synthetic, non-executable payloads. Every foreign
|
|
64
|
+
metadata/tarball endpoint is checked before installation, so a missing fixture
|
|
65
|
+
cannot masquerade as successful platform filtering. These temporary fixtures
|
|
66
|
+
never enter `packages/`, release artifacts, or the publish manifest. They prove
|
|
67
|
+
installer filtering, not foreign native compilation or execution. Full release
|
|
68
|
+
collection uses the actual seven collected native tarballs instead. Run it with
|
|
69
|
+
`pnpm package:collect` after assembling all seven real bindings; missing targets
|
|
70
|
+
fail collection. `pnpm package:collect --allow-host-only` exercises the same
|
|
71
|
+
lifecycle boundary locally but proves only the host. Both collection commands
|
|
72
|
+
require the pnpm lifecycle CLI path; direct `node` invocation is unsupported. Archive
|
|
73
|
+
codecs and their dependencies are packed from the installed dependency graph.
|
|
74
|
+
|
|
75
|
+
PR CI builds and executes four host targets: Linux x64 glibc, Linux x64 musl
|
|
76
|
+
(Alpine), macOS arm64, and Windows x64. The root-only smoke runs on each. The
|
|
77
|
+
seven-target source build matrix runs on release tags; packaging all seven is
|
|
78
|
+
not execution proof for every architecture. The smoke writes manager versions,
|
|
79
|
+
cases, and synthetic-fixture scope to `release-artifacts/consumer-proof.json`.
|
|
80
|
+
|
|
49
81
|
## Docs
|
|
50
82
|
|
|
51
|
-
The docs site is rendered from `docs
|
|
83
|
+
The docs site is rendered recursively from Markdown files under `docs/` by `scripts/build-docs-site.mjs`. Build locally to preview:
|
|
52
84
|
|
|
53
85
|
```bash
|
|
54
|
-
|
|
86
|
+
pnpm docs:site
|
|
55
87
|
open dist/docs-site/index.html
|
|
56
88
|
```
|
|
57
89
|
|
|
58
|
-
The build validates internal links and embedded anchors. Broken links fail the build — fix them before pushing.
|
|
90
|
+
The build validates internal links and embedded anchors. Broken links fail the build — fix them before pushing. Navigation must list every non-excluded Markdown page exactly once: missing, nonexistent, and duplicate entries fail before the build replaces existing site output. The builder and navigation tests share discovery and validation in `scripts/docs-site-navigation.mjs`.
|
|
59
91
|
|
|
60
92
|
Adding a new doc page:
|
|
61
93
|
|
|
62
94
|
1. Create `docs/<page>.md`. Use a leading `# Title` heading.
|
|
63
|
-
2. Add the page to the appropriate section in `scripts/
|
|
95
|
+
2. Add the page to the appropriate section in `scripts/docs-site-navigation.mjs` (`sections` array near the top). Nested pages use slash-separated paths relative to `docs/`, such as `guides/example.md`.
|
|
64
96
|
3. Cross-link from `docs/index.md` if it's a major surface.
|
|
65
|
-
4.
|
|
97
|
+
4. Run `pnpm test test/docs-site-navigation.test.ts` and re-run the local build.
|
|
66
98
|
|
|
67
99
|
Internal links use relative `*.md` paths — the builder rewrites them to the rendered HTML. Code fences support GitHub-flavored markdown.
|
|
68
100
|
|
|
@@ -79,8 +111,14 @@ Small, focused PRs land faster. The general shape:
|
|
|
79
111
|
|
|
80
112
|
Maintainers publish from a protected `vX.Y.Z` tag on `main` through
|
|
81
113
|
`.github/workflows/release.yml`. The workflow requires the package version and a
|
|
82
|
-
dated `CHANGELOG.md` section to match the tag
|
|
83
|
-
|
|
114
|
+
dated `CHANGELOG.md` section to match the tag. It builds and publishes all seven
|
|
115
|
+
platform packages before publishing `@openclaw/fs-safe`, verifies every registry
|
|
116
|
+
artifact and provenance statement, and then creates the GitHub release.
|
|
117
|
+
|
|
118
|
+
Each package needs its own npm trusted-publisher configuration for
|
|
119
|
+
`openclaw/fs-safe` and `release.yml`. A new platform package must be created and
|
|
120
|
+
configured on npm before the first tag that references it; npm trust is
|
|
121
|
+
package-specific and cannot be bootstrapped by the tag workflow itself.
|
|
84
122
|
|
|
85
123
|
External contributors do not need to do anything beyond getting the pull
|
|
86
124
|
request merged. Maintainers must not publish locally or add npm automation
|
package/docs/durability.md
CHANGED
|
@@ -63,6 +63,12 @@ into proof for a different directory.
|
|
|
63
63
|
|
|
64
64
|
Call `close()` in `finally`. Closing is idempotent; using a closed pin fails.
|
|
65
65
|
|
|
66
|
+
These checks intentionally reject a moved or replaced pathname. For one file's
|
|
67
|
+
abort cleanup through its original directory after a move, use the separate
|
|
68
|
+
[retained-directory staging lifecycle](staged-file.md). Its cleanup authority
|
|
69
|
+
does not weaken `pinDirectory().assertCurrent()` or `.sync()`, and namespace
|
|
70
|
+
cleanup is not proof of crash durability.
|
|
71
|
+
|
|
66
72
|
## Durable directory creation
|
|
67
73
|
|
|
68
74
|
`ensureDurableDirectory()` finds and pins the nearest existing ancestor,
|
|
@@ -84,7 +90,7 @@ target fails with `FsSafeError("path-mismatch")`.
|
|
|
84
90
|
## Exclusive file publication
|
|
85
91
|
|
|
86
92
|
`publishFileExclusive()` materializes one file without clobbering an existing
|
|
87
|
-
target. It pins the source with `O_NOFOLLOW`, optionally verifies
|
|
93
|
+
target. It pins the source with nonblocking `O_NOFOLLOW`, optionally verifies
|
|
88
94
|
`expectedSourceIdentity`, tries a hardlink first, then synchronizes the target
|
|
89
95
|
parent directory.
|
|
90
96
|
|
|
@@ -204,10 +210,17 @@ try {
|
|
|
204
210
|
The result is `{ bytes, digest }`, where `digest` is lowercase hexadecimal.
|
|
205
211
|
The handle overload never closes the caller's descriptor and uses positioned
|
|
206
212
|
reads, so it does not alter the descriptor's current offset. The path overload
|
|
207
|
-
rejects symbolic links and non-regular files,
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
213
|
+
rejects symbolic links and non-regular files, compares lossless bigint identities
|
|
214
|
+
from the pre-open pathname inspection to the opened descriptor and from that
|
|
215
|
+
descriptor to the current pathname, and closes its own handle. All identity
|
|
216
|
+
checks complete before any JavaScript or native hashing. Each inspection allows
|
|
217
|
+
one bounded retry for unknown Windows identity components, retaining known
|
|
218
|
+
components and rejecting known differences immediately. Persistent unknown
|
|
219
|
+
identity fails closed with `path-mismatch`, even for benign files: this trades
|
|
220
|
+
availability for verifiable identity. Retries inspect the same descriptor or
|
|
221
|
+
pathname without reopening the file and repeat the symlink and file-type checks.
|
|
222
|
+
POSIX opens are nonblocking, so a raced FIFO or device is rejected after
|
|
223
|
+
descriptor inspection rather than waiting for a writer.
|
|
211
224
|
|
|
212
225
|
When the optional binding is active, hashing runs as an async native task and
|
|
213
226
|
does not occupy the JavaScript event loop with digest updates. With native mode
|
package/docs/errors.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Every failure that's the library's job to surface lands as an `FsSafeError` with a closed `code` union you can branch on. Catch by code, not by message text — messages may change, codes will not.
|
|
4
4
|
|
|
5
|
+
Result-based APIs are an exception to throwing: [`pathScope().ensureDir()`](path-scope.md#ensuredir-rel-options)
|
|
6
|
+
and `ensureDirectoryWithinRoot()` return operational failures as
|
|
7
|
+
`{ ok: false, error: string, diagnostic: FsSafeError }`. The diagnostic uses
|
|
8
|
+
`helper-failed` / `operational`, retains the exact native error in `cause`, and
|
|
9
|
+
has the same message as `error`. Its display text names the native code/syscall
|
|
10
|
+
when available but omits raw paths and native messages. Policy failures keep
|
|
11
|
+
the string-only result without `diagnostic`; other `pathScope` methods do not
|
|
12
|
+
gain this field. Directory preparation can partially complete before failing.
|
|
13
|
+
|
|
5
14
|
Path and archive-entry details embedded in diagnostics escape control characters
|
|
6
15
|
as `\\uXXXX` sequences. This keeps attacker-controlled names on one log line;
|
|
7
16
|
the escaped message is for diagnosis, not for reconstructing the original path.
|
|
@@ -105,7 +114,7 @@ type FsSafeErrorCode =
|
|
|
105
114
|
| `device-path` | A read/open target is a known unsafe device or process-fd path. | `/dev/zero`, `/dev/random`, `/dev/stdin`, `/dev/fd/*`, `/proc/*/fd/*`, or a Windows reserved device name. |
|
|
106
115
|
| `hardlink` | Read or copy with `hardlinks: "reject"` saw `nlink > 1`. | File is hardlinked — possibly an alias of an out-of-tree inode. |
|
|
107
116
|
| `helper-failed` | A native mechanism or multi-step operational helper failed. | Inspect `cause` and any operation-specific `details`; retrying may be unsafe if the operation partially completed. |
|
|
108
|
-
| `helper-unavailable` | Required native binding could not be loaded. | Unsupported platform, missing/incompatible
|
|
117
|
+
| `helper-unavailable` | Required native binding could not be loaded. | Unsupported platform, omitted/missing/incompatible platform package, or `FS_SAFE_NATIVE_MODE=require`. `auto` falls back where possible; `require` fails closed. |
|
|
109
118
|
| `insecure-permissions` | A secure file or path permission check found a mode/ACL that allows broader access than requested. | File or directory is group/world writable/readable; Windows ACL grants broad read. |
|
|
110
119
|
| `invalid-path` | Input was empty, contained NUL, was an unparseable URL, or otherwise unusable. | Caller didn't validate input; input was a network path on Windows, a drive-relative segment in a portable relative path or store key, or a leading drive-relative spelling such as `C:name` used as a Root destination. Existing-object Root lookups retain legal POSIX drive-like names. |
|
|
111
120
|
| `not-empty` | `remove()` on a non-empty directory. | Use `replaceDirectoryAtomic` or remove children first. |
|
|
@@ -125,6 +134,12 @@ type FsSafeErrorCode =
|
|
|
125
134
|
| `too-large` | A read or bounded walk exceeded its configured budget. | Caller gave a too-permissive file or traversal limit. |
|
|
126
135
|
| `unsupported-platform` | Reserved compatibility code for a platform-specific operation. | No current public helper emits this `FsSafeError` code. Platform-specific APIs currently return a typed unsupported result or use `helper-unavailable`; keep the union member when exhaustively switching across supported package versions. |
|
|
127
136
|
|
|
137
|
+
Pathname `sha256File()` also reports `path-mismatch` when pre-open, descriptor,
|
|
138
|
+
or current-path identity remains unknown after one bounded Windows retry, even
|
|
139
|
+
if the file is benign. It never reopens to recover identity. Preview symlinks
|
|
140
|
+
report `symlink`, preview or descriptor non-files report `not-file`, and a
|
|
141
|
+
current-path symlink or non-file reports `path-mismatch`.
|
|
142
|
+
|
|
128
143
|
## Branching
|
|
129
144
|
|
|
130
145
|
```ts
|
package/docs/file-store.md
CHANGED
|
@@ -31,6 +31,8 @@ const cache = fileStore({
|
|
|
31
31
|
});
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
Store and per-call `maxBytes` values must be non-negative safe integers or positive `Infinity`. Zero is an active zero-byte cap; `Infinity` disables the cap. An omitted or explicitly `undefined` per-call value preserves the store-level limit. The same rule applies to buffer writes, streams, copies, async reads, and synchronous reads/writes.
|
|
35
|
+
|
|
34
36
|
Use `private: true` for credentials, auth profiles, tokens, and other private
|
|
35
37
|
state. Private mode keeps the same `FileStore` shape but routes writes through
|
|
36
38
|
the secret-file atomic path, refusing symlink parent components and re-asserting
|
package/docs/index.md
CHANGED
|
@@ -39,6 +39,7 @@ await fs.remove("notes/archive/today.txt");
|
|
|
39
39
|
|
|
40
40
|
- **First time?** [Install](install.md), then walk through the [Quickstart](quickstart.md). Five minutes from `pnpm add` to a working root.
|
|
41
41
|
- **Upgrading from 0.4?** Follow [Migrating to 0.5](migrating-to-0.5.md) in order, including the archive clamp-default audit.
|
|
42
|
+
- **Upgrading from 0.5?** Follow [Migrating to 0.6](migrating-to-0.6.md) to adopt platform-native optional packages.
|
|
42
43
|
- **Designing a workspace feature.** Read the [Security model](security-model.md) before you trust the boundary, the [native helper policy](native-helper.md) before you pick deployment defaults, and the [Errors](errors.md) reference so you know what to catch.
|
|
43
44
|
- **Replacing ad-hoc atomic writes.** Jump to [Atomic writes](atomic.md) or, for keyed JSON state, [JSON files](json.md).
|
|
44
45
|
- **Extracting an upload.** Start at [Archive extraction](archive.md) — handles ZIP and TAR with traversal, link, count, and byte limits.
|
|
@@ -51,7 +52,7 @@ await fs.remove("notes/archive/today.txt");
|
|
|
51
52
|
|---|---|
|
|
52
53
|
| [`root()`](root.md) | One boundary for read/write/move/remove and bounded recursive walking inside a trusted directory. |
|
|
53
54
|
| [`@openclaw/fs-safe/config`](config.md) | Process-global native helper and lock-option defaults. |
|
|
54
|
-
| [Native helper policy](native-helper.md) | Choose `auto`, `off`, or `require` for
|
|
55
|
+
| [Native helper policy](native-helper.md) | Choose `auto`, `off`, or `require` for platform-native primitives. |
|
|
55
56
|
| [Native architecture](native.md) | Understand the thin syscall layer, beneath model, platform mechanisms, and fallback boundary. |
|
|
56
57
|
| [`replaceFileAtomic`](atomic.md) | Sibling-temp + rename, fsync hooks, mode preservation, copy fallback. |
|
|
57
58
|
| [`@openclaw/fs-safe/durability`](durability.md) | Pinned directory identities, durable creation, exclusive publication, streaming SHA-256, provenance receipts, and sync-failure policy. |
|
|
@@ -74,6 +75,7 @@ await fs.remove("notes/archive/today.txt");
|
|
|
74
75
|
| [`@openclaw/fs-safe/advanced`](advanced.md) | Directory of lower-level composition helpers (path scopes, regular-file I/O, install paths, sibling-temp writes, …). |
|
|
75
76
|
| [`@openclaw/fs-safe/test-hooks`](test-hooks.md) | Test-only injection hooks for reproducing open/lstat races. |
|
|
76
77
|
| [Migrating to 0.5](migrating-to-0.5.md) | End-to-end checklist for Python removal and 0.4 API behavior changes. |
|
|
78
|
+
| [Migrating to 0.6](migrating-to-0.6.md) | Installer-policy checklist for the platform-native package split. |
|
|
77
79
|
|
|
78
80
|
## Status
|
|
79
81
|
|