@openclaw/fs-safe 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/dist/archive-entry.d.ts.map +1 -1
  4. package/dist/archive-entry.js +5 -0
  5. package/dist/archive-gzip-tail.d.ts +18 -0
  6. package/dist/archive-gzip-tail.d.ts.map +1 -0
  7. package/dist/archive-gzip-tail.js +74 -0
  8. package/dist/archive-parser.wasm +0 -0
  9. package/dist/archive-read.d.ts.map +1 -1
  10. package/dist/archive-read.js +18 -60
  11. package/dist/archive-tar-extract.d.ts +11 -0
  12. package/dist/archive-tar-extract.d.ts.map +1 -0
  13. package/dist/archive-tar-extract.js +49 -0
  14. package/dist/archive-tar-stream.d.ts +18 -0
  15. package/dist/archive-tar-stream.d.ts.map +1 -0
  16. package/dist/archive-tar-stream.js +95 -0
  17. package/dist/archive-tar-wasm.d.ts +18 -0
  18. package/dist/archive-tar-wasm.d.ts.map +1 -0
  19. package/dist/archive-tar-wasm.js +102 -0
  20. package/dist/archive-tar.d.ts +0 -1
  21. package/dist/archive-tar.d.ts.map +1 -1
  22. package/dist/archive-tar.js +0 -22
  23. package/dist/archive.d.ts.map +1 -1
  24. package/dist/archive.js +2 -109
  25. package/dist/device-path.d.ts +1 -0
  26. package/dist/device-path.d.ts.map +1 -1
  27. package/dist/device-path.js +5 -2
  28. package/dist/directory-mode-owner.d.ts.map +1 -1
  29. package/dist/directory-mode-owner.js +3 -0
  30. package/dist/replace-file-descriptor.d.ts.map +1 -1
  31. package/dist/replace-file-descriptor.js +2 -1
  32. package/dist/secret-file.d.ts.map +1 -1
  33. package/dist/secret-file.js +2 -0
  34. package/dist/secret-read-async.d.ts.map +1 -1
  35. package/dist/secret-read-async.js +3 -0
  36. package/dist/sidecar-lock-acquire.d.ts +1 -0
  37. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  38. package/dist/sidecar-lock-acquire.js +6 -0
  39. package/dist/sidecar-lock-types.d.ts +7 -0
  40. package/dist/sidecar-lock-types.d.ts.map +1 -1
  41. package/dist/sidecar-lock.d.ts +2 -0
  42. package/dist/sidecar-lock.d.ts.map +1 -1
  43. package/dist/sidecar-lock.js +19 -4
  44. package/docs/archive.md +66 -61
  45. package/docs/contributing.md +35 -1
  46. package/docs/install.md +1 -1
  47. package/docs/native-helper.md +5 -0
  48. package/docs/native.md +16 -10
  49. package/docs/secret-file.md +5 -0
  50. package/docs/sidecar-lock.md +2 -1
  51. package/package.json +19 -16
  52. package/dist/archive-tar-admission.d.ts +0 -7
  53. package/dist/archive-tar-admission.d.ts.map +0 -1
  54. package/dist/archive-tar-admission.js +0 -43
  55. package/dist/archive-tar-gnu.d.ts +0 -2
  56. package/dist/archive-tar-gnu.d.ts.map +0 -1
  57. package/dist/archive-tar-gnu.js +0 -20
  58. package/dist/archive-tar-header.d.ts +0 -8
  59. package/dist/archive-tar-header.d.ts.map +0 -1
  60. package/dist/archive-tar-header.js +0 -47
  61. package/dist/archive-tar-meta.d.ts +0 -34
  62. package/dist/archive-tar-meta.d.ts.map +0 -1
  63. package/dist/archive-tar-meta.js +0 -277
  64. package/dist/archive-tar-pax.d.ts +0 -8
  65. package/dist/archive-tar-pax.d.ts.map +0 -1
  66. package/dist/archive-tar-pax.js +0 -100
  67. package/dist/archive-tar-runtime.d.ts +0 -49
  68. package/dist/archive-tar-runtime.d.ts.map +0 -1
  69. package/dist/archive-tar-runtime.js +0 -22
package/docs/archive.md CHANGED
@@ -2,15 +2,12 @@
2
2
 
3
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
- The guarded JavaScript fallback uses optional runtime dependencies: `jszip` for
6
- ZIP and `tar` for TAR/gzip. The native path does not use those packages. Installs
7
- that omit optional dependencies can still import this subpath and use the
8
- native path or pure path/limit helpers.
9
-
10
- Some package managers and CI installs skip optional dependencies
11
- (`--no-optional`, `--omit=optional`, or equivalent). If an archive helper throws
12
- that an optional archive dependency is not installed, install `jszip` and/or
13
- `tar` explicitly in the consuming package.
5
+ TAR admission uses one Rust core compiled into both the native binding and a
6
+ bundled, import-free WebAssembly module. The guarded JavaScript fallback uses
7
+ that module for TAR/gzip and optional `jszip` for ZIP. TAR needs no optional
8
+ parser dependency, runtime download, install script, or consumer Rust toolchain.
9
+ Installs omitting optional dependencies can import every public subpath and use
10
+ TAR/gzip in `auto` or `off`; ZIP fallback still requires `jszip`.
14
11
 
15
12
  ```ts
16
13
  import { extractArchive, resolveArchiveKind } from "@openclaw/fs-safe/archive";
@@ -68,12 +65,12 @@ directories; ZIP UNIX creator records with zero attributes are explicit zero,
68
65
  while non-UNIX ZIP records use the absent-metadata defaults.
69
66
 
70
67
  TAR mode fields containing only NUL/ASCII-space padding use absent defaults.
71
- Native extraction also recognizes GNU binary modes, including signed values,
72
- within node-tar's JavaScript safe-integer range before masking permission bits.
73
- Malformed or unsupported mode representations retain existing decoder behavior:
74
- native falls back to zero, while JavaScript may default, parse an octal prefix,
75
- or reject. These representations are not newly admitted or standardized by the
76
- mode repair; raw framing and other numeric-field checks remain unchanged.
68
+ Both backends recognize GNU binary modes, including signed values,
69
+ within JavaScript's safe-integer range before masking permission bits.
70
+ Malformed or unsupported mode fields consistently fall back to zero, matching
71
+ the former native behavior. This replaces JavaScript's decoder-dependent octal
72
+ prefix parsing, defaulting, or rejection for malformed fields. Ordinary octal,
73
+ absent, explicit zero, and supported GNU binary fields retain their behavior.
77
74
 
78
75
  Final modes remain separate from private working staging permissions: files
79
76
  stay `0o600` and directories `0o700` until publication. Files receive their final
@@ -112,8 +109,8 @@ normalizing separators. For example, `./pkg/hello.txt` with
112
109
  `stripComponents: 1` extracts to `hello.txt` on both backends. Entries with no
113
110
  remaining components are skipped before the filter callback, but still count
114
111
  toward `maxEntries` and undergo traversal validation. JavaScript TAR extraction
115
- passes node-tar this accepted output path with its own stripping disabled, so
116
- depth checks, collision checks, writes, and mode application agree.
112
+ copies the admitted payload range to this accepted output path, so depth checks,
113
+ collision checks, writes, and mode application agree.
117
114
 
118
115
  An `entryFilter` sees the validated **canonical effective archive path before
119
116
  stripping**, entry kind, and declared size. On every JavaScript and native
@@ -127,6 +124,12 @@ Unicode Path names use the same canonicalization.
127
124
  Raw paths undergo traversal, absolute/drive-path, and NUL validation **before**
128
125
  canonicalization; normalization cannot turn an unsafe path into an accepted
129
126
  one. Stripping and output collision checks use this same canonical identity.
127
+ On Windows, archive admission also rejects reserved device segments such as
128
+ `NUL`, `CON.txt`, and `nul .txt` with `ArchiveSecurityError("entry-path")`,
129
+ before extraction or bounded member reads. Ignored trailing spaces in the stem
130
+ before an extension do not bypass this check. Ordinary members with these names
131
+ remain valid on POSIX; this is a host-specific device guard, not a portable-name
132
+ restriction.
130
133
  Filters that compare exact strings should use canonical pre-strip paths,
131
134
  including directory names without a trailing `/`.
132
135
  Returning `"skip"` rejects the whole archive unless `onFiltered` is
@@ -162,11 +165,10 @@ If skipping was not explicitly part of the restore contract, omit
162
165
  `onFiltered`; the first `"skip"` then rejects the complete archive with
163
166
  `ArchiveSecurityError("entry-filtered")`.
164
167
 
165
- Both TAR implementations finish bounded admission before TypeScript policy
166
- evaluation, so a rejected plan never starts extraction. The JavaScript path
167
- owns the extraction file stream and aborts node-tar through a pipeline on
168
- parser disagreement, validation, or timeout failure, destroying both ends
169
- instead of leaving a paused parser to drain indefinitely.
168
+ Both TAR routes finish bounded admission before TypeScript policy evaluation,
169
+ so a rejected plan never starts extraction. The JavaScript path owns its input,
170
+ decoder, and WASM parser streams, joining their teardown on validation, write,
171
+ or timeout failure.
170
172
 
171
173
  TAR character devices, block devices, and FIFOs are presented to the filter as
172
174
  `kind: "other"`. Accepted entries of these types reject with
@@ -183,8 +185,7 @@ and output collision checks in physical order. Each remaining record reaches
183
185
  `entryFilter` once with its canonical pre-strip path, `kind: "other"`, and
184
186
  declared effective size. A filter skip rejects with `"entry-filtered"` unless
185
187
  `onFiltered: "skip-entry"` is explicit. Accepted unsupported records are safely
186
- omitted and do not consume output payload budgets. This applies even when the
187
- underlying TAR parser suppresses the record. GNU long names describe one such
188
+ omitted and do not consume output payload budgets. The shared core admits these records explicitly. GNU long names describe one such
188
189
  record and are then cleared; local PAX on unsupported types and GNU sparse
189
190
  `S` records retain their existing fail-closed format policy.
190
191
 
@@ -219,7 +220,7 @@ type ArchiveExtractLimits = {
219
220
  };
220
221
  ```
221
222
 
222
- Defaults exist for each (`DEFAULT_MAX_ARCHIVE_BYTES_ZIP`, `DEFAULT_MAX_ENTRIES`, `DEFAULT_MAX_EXTRACTED_BYTES`, `DEFAULT_MAX_ENTRY_BYTES`, `DEFAULT_MAX_META_ENTRY_BYTES`, `DEFAULT_MAX_ENTRY_PATH_COMPONENTS`). An explicit zero remains zero rather than selecting the default. `maxEntries` counts every archive entry, including entries removed by `stripComponents` or an explicit filter. The path-component default is 256. It is evaluated after `stripComponents` and before TypeScript accepts an entry for either JavaScript or native extraction, so rejected entries cannot cause implicit parent-directory creation. The 1 MiB metadata default matches node-tar's `maxMetaEntrySize`; fs-safe passes the same resolved value to node-tar and the native TAR meter.
223
+ Defaults exist for each (`DEFAULT_MAX_ARCHIVE_BYTES_ZIP`, `DEFAULT_MAX_ENTRIES`, `DEFAULT_MAX_EXTRACTED_BYTES`, `DEFAULT_MAX_ENTRY_BYTES`, `DEFAULT_MAX_META_ENTRY_BYTES`, `DEFAULT_MAX_ENTRY_PATH_COMPONENTS`). An explicit zero remains zero rather than selecting the default. `maxEntries` counts every archive entry, including entries removed by `stripComponents` or an explicit filter. The path-component default is 256. It is evaluated after `stripComponents` and before TypeScript accepts an entry for either JavaScript or native extraction, so rejected entries cannot cause implicit parent-directory creation. The same resolved 1 MiB metadata default applies to the native and WASM core.
223
224
 
224
225
  A limit violation throws `ArchiveLimitError`. Its constant and string code are:
225
226
 
@@ -262,17 +263,17 @@ codes remain `"destination-not-directory"`, `"destination-symlink"`, and
262
263
  - **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).
263
264
  - **Zip bombs:** `maxExtractedBytes` and `maxEntryBytes` apply to *post-decompression* bytes, so highly-compressed payloads hit the cap before they exhaust disk.
264
265
  - **Corrupt ZIP payloads:** streamed output must match both the central-directory CRC and declared uncompressed size before it can leave private staging.
265
- - **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.
266
+ - **Gzip container integrity:** every concatenated gzip member must have a complete valid header, body, CRC32, and ISIZE trailer. A completed member may be followed by all-zero compressed-container padding (including system-tar stdout padding), bounded by the original archive-byte limit. The padding must remain zero through physical EOF; nonzero bytes or another member after padding reject. Truncation and corruption reject before publication or selected bytes return on both backends. Compressed padding is separate from decoded TAR EOF and does not bypass its checks.
266
267
  - **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.
267
- - **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.
268
+ - **Metadata bombs:** a streaming pass-through reader rejects oversized PAX, GNU long-name, and GNU long-link bodies before buffering their bodies. 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.
268
269
 
269
270
  ### Raw TAR framing
270
271
 
271
272
  Extraction and bounded reads admit the complete decoded TAR stream through the
272
- raw meter before either backend's TAR parser runs. This applies to plain TAR,
273
+ shared Rust core. This applies to plain TAR,
273
274
  gzip, and native-supported zstd/bzip2, without changing native-mode availability
274
- or fallback policy. The existing TypeScript and Rust meters enforce the same
275
- framing rules before parser normalization:
275
+ or fallback policy. The native and WASM builds enforce the same
276
+ framing rules:
276
277
 
277
278
  - Every nonzero header must have a valid unsigned octal checksum, delimited
278
279
  within its field. Checksum validation precedes metadata allocation and member
@@ -301,22 +302,28 @@ Missing linknames on links and nonempty linknames on non-links still use the
301
302
  format error. PAX `x` and GNU long-name/long-link `L`/`K` payloads retain their
302
303
  existing support and metadata limits; the zero-body rule is not applied to all
303
304
  non-regular types.
304
- PAX effective sizes still determine regular-member framing. Admission preserves
305
- the input bytes, and all entry/path/byte limits and extraction deadlines remain
306
- in force. Native inspection now completes this admission pass before parsing,
307
- requiring one additional streaming read/decompression pass.
308
- JavaScript admission reports an ordered logical-member manifest from the raw
309
- meter, bounded by entry-count, manifest, and decoded limits. Policy runs once
310
- over that manifest; extraction checks parser-visible members against the
311
- accepted decisions before writing. Original member names and USTAR prefixes
312
- are validated even when overridden, and non-padding bytes after a fixed path
313
- field's NUL terminator reject rather than hiding an unsafe suffix.
314
- Both meters enforce the 255-byte component ceiling under NFC and NFD before
315
- metadata replaces a raw path, including Hangul decomposition expansion.
316
- Native extraction and entry reads also drain their metered readers through
317
- physical EOF after parser traversal, before completing directory modes,
318
- publishing staged files, or returning the requested bytes. Finding the requested
319
- member or reaching the parser's logical EOF cannot bypass trailing validation.
305
+ PAX effective sizes determine regular-member framing. Admission preserves input
306
+ bytes and emits an ordered manifest with exact effective names, types, modes,
307
+ sizes, and decoded payload offsets. TypeScript owns filtering, stripping,
308
+ collisions, permissions, and accepted-output limits. Executors replay admitted
309
+ ranges from the immutable staged input; no second TAR parser interprets PAX,
310
+ GNU names, or payload lengths. Native writes remain descriptor-relative;
311
+ JavaScript writes use the shared guarded private staging and pinned-write helpers.
312
+
313
+ Original member names and USTAR prefixes are validated even when overridden.
314
+ Non-padding bytes after a fixed path field's NUL terminator reject. The core
315
+ enforces the 255-byte component ceiling under NFC and NFD, including Hangul
316
+ expansion. Every replay drains and validates physical EOF before publication or
317
+ returning selected bytes. Unrequested, filtered, and stripped members cannot
318
+ bypass validation. Decompression remains streaming; no complete decoded archive
319
+ is retained in memory or written to a decoded spool.
320
+
321
+ The WASM transport has a fixed 64 KiB input buffer, one pending member event,
322
+ and a 256 MiB maximum linear memory per isolated parser instance. Metadata is
323
+ bounded before allocation; allocation failure rejects. Stream backpressure
324
+ bounds queued chunks, and completion/error destroys the instance's parser
325
+ state. The manifest retains the existing charged budget below; linear memory
326
+ is an additional execution resource bound, not a new public limit option.
320
327
 
321
328
  The raw meter enforces `maxEntries` before consuming each logical member's body,
322
329
  including members later skipped by filtering or stripping. PAX/GNU metadata
@@ -343,9 +350,7 @@ archive overhead with safe addition. Ordinary limits, including
343
350
  zero and the existing defaulting/rounding rules, retain their behavior.
344
351
  There is no new public option. This is an absolute decoded admission
345
352
  cap, not a decompression-ratio policy; bounded stream/codec read-ahead remains.
346
- After this complete preflight, the JavaScript backend disables node-tar's
347
- independent ratio threshold so it cannot reject data that the native backend
348
- accepts within the same absolute limits.
353
+ There is no independent TAR parser decompression-ratio threshold.
349
354
 
350
355
  ### Bounded local PAX support
351
356
 
@@ -359,13 +364,14 @@ permits link creation. Effective sizes drive framing, filters, and the existing
359
364
  budgets; `maxEntries` still counts members, not their metadata headers.
360
365
 
361
366
  Records must have exact byte lengths, ASCII keys, a final newline, and no
362
- duplicate keys, embedded newlines, or unconsumed bytes. Structural `path` and
363
- `linkpath` values and ownership names must be nonempty printable ASCII. A PAX
364
- member's raw name, USTAR prefix, and raw link target must also be printable
365
- ASCII; raw link targets must be present only on links, even when overridden.
366
- Unicode
367
- PAX structural text is deliberately unsupported because the underlying parsers
368
- do not agree when UTF-8 is split across input chunks. `size`, `uid`, and `gid`
367
+ duplicate keys or unconsumed bytes. `path` and `linkpath` must be nonempty strict
368
+ UTF-8 without NUL. Unicode, a leading BOM, numeric-looking names, and embedded
369
+ newlines preserve their exact spelling; newlines inside a byte-counted value
370
+ are data. Windows filesystem filename restrictions still apply during creation.
371
+ Ownership names retain the existing nonempty printable-ASCII contract. Raw name,
372
+ USTAR prefix, and link fields still require strict UTF-8 and NUL padding even
373
+ when metadata overrides them. Raw link targets must be present only on links.
374
+ `size`, `uid`, and `gid`
369
375
  must be canonical unsigned decimal safe integers (zero is valid; signs, leading
370
376
  zeros, fractions, and exponents are not). Padded member sizes must also fit the
371
377
  safe integer range. Raw and effective directory/link sizes must both be zero;
@@ -378,8 +384,7 @@ with optional fractional digits, within JavaScript's Date range), `uid`, `gid`,
378
384
  destination. `LIBARCHIVE.xattr.*` and `SCHILY.xattr.*` with nonempty ASCII
379
385
  alphanumeric/dot/underscore/hyphen suffixes are also accepted as inert metadata,
380
386
  never restored as extended attributes. Their values are byte-counted and may
381
- contain NUL or non-UTF8 bytes, including macOS provenance metadata; embedded
382
- newlines are rejected because they can disrupt downstream record parsing.
387
+ contain NUL, non-UTF8 bytes, or newlines, including macOS provenance metadata.
383
388
 
384
389
  Global `g`, old `X`, old GNU `N`, empty/dangling/repeated local headers, mixed
385
390
  PAX/GNU extension chains, unknown keys, charset declarations, ACL extensions,
@@ -393,11 +398,11 @@ chains without introducing a new limit or changing defaults.
393
398
 
394
399
  ### Bounded GNU long names and links
395
400
 
396
- Both raw meters buffer GNU long-name `L` and long-link `K` bodies within
397
- `maxMetaEntryBytes` before either TAR parser runs. A body must contain a nonempty
401
+ The shared core buffers GNU long-name `L` and long-link `K` bodies within
402
+ `maxMetaEntryBytes`. A body must contain a nonempty
398
403
  UTF-8 name, with either no NUL or exactly one terminal NUL. Embedded NULs,
399
404
  additional terminal NULs, bytes after a NUL, and invalid UTF-8 reject with
400
- `ArchiveFormatError("archive-header-invalid")`. The meters preserve original
405
+ `ArchiveFormatError("archive-header-invalid")`. The core preserves original
401
406
  archive bytes, including the optional terminator and block padding.
402
407
 
403
408
  One logical member may have at most one `L` and one `K`, in either order.
@@ -19,7 +19,16 @@ manager version declared in `package.json`.
19
19
  pnpm build
20
20
  ```
21
21
 
22
- Runs `tsc -p tsconfig.json`. Output lands in `dist/`. The package's `prepack` hook re-runs the build before publishing — manual `pnpm build` is only required when you want to inspect the output or run a freshly-built copy locally.
22
+ Runs TypeScript compilation and builds the portable Rust TAR parser for
23
+ `wasm32-unknown-unknown`. Contributors need Rust (the native crate's declared
24
+ minimum or newer) and `rustup target add wasm32-unknown-unknown`; Alpine's
25
+ packaged toolchain uses `rust-wasm`. `pnpm archive:wasm` rebuilds just the parser.
26
+ The import-free asset lands at `dist/archive-parser.wasm`; source tests and
27
+ compiled consumers both resolve that generated artifact. Run `pnpm build`
28
+ before source tests in a fresh checkout. Do not commit `dist/` or built WASM.
29
+ Consumers receive the asset in the npm package and need no compiler.
30
+
31
+ Output lands in `dist/`. The package's `prepack` hook re-runs the build before publishing — manual `pnpm build` is only required when you want to inspect the output or run a freshly-built copy locally.
23
32
 
24
33
  ## Test
25
34
 
@@ -59,8 +68,33 @@ pnpm check
59
68
  This runs the filesystem boundary checks, build, tests, and package
60
69
  tarball/import validation.
61
70
 
71
+ ### Real TAR producers
72
+
73
+ After installing the freshly packed root (and optionally its freshly built host
74
+ binding) in a disposable consumer, run:
75
+
76
+ ```bash
77
+ pnpm archive:producer-smoke ./consumer off
78
+ pnpm archive:producer-smoke ./consumer require
79
+ ```
80
+
81
+ This uses a child bound to canonical cwd/device/inode running `/usr/bin/tar -czf - .`
82
+ with unchanged stdout, and npm tar, on synthetic Unicode/newline/long-name files,
83
+ then the installed package API for exact payload hashes and bounded reads.
84
+ It also rejects a valid PAX override attached to an invalid raw UTF-8 field.
85
+ The `require` command must resolve the freshly packed native binding; the
86
+ `off` command uses the installed WASM asset. No live user files are read.
87
+
62
88
  ### Native consumer installs
63
89
 
90
+ The CI Node 24 and native jobs also run `node scripts/device-path-proof.mjs off`
91
+ and `node scripts/device-path-proof.mjs require` against the built package.
92
+ This extracts real ZIP files and checks bounded member reads, preserving reserved
93
+ device-like names on POSIX while rejecting them and ignored-space aliases on
94
+ Windows. It also verifies ordinary secret reads and typed device-path rejection
95
+ without replacing filesystem functions. Run after `pnpm build`, and build the
96
+ host binding with `pnpm native:build` before the `require` case.
97
+
64
98
  After `pnpm build` and a fresh `pnpm native:build`, run `pnpm package:smoke`.
65
99
  It packs the real root and host binding, then runs root-only npm and the
66
100
  declared pnpm version against a disposable loopback registry. The root's exact
package/docs/install.md CHANGED
@@ -85,7 +85,7 @@ Use the main entry for the common surface, or the focused subpaths when you want
85
85
 
86
86
  ## Runtime dependencies
87
87
 
88
- `@openclaw/fs-safe` lists `jszip` and `tar` as optional dependencies for JavaScript ZIP/TAR [archive extraction](archive.md). They are loaded lazily; the JavaScript archive fallback requires the corresponding codec and reports a missing-optional-dependency error without it. Public subpaths remain safe to import with all optional dependencies omitted, but imports do not prove native availability.
88
+ `@openclaw/fs-safe` bundles an import-free WASM build of its Rust TAR parser for guarded JavaScript TAR/gzip [archive extraction](archive.md), including installs with optional dependencies omitted. ZIP fallback uses lazily loaded optional `jszip` and reports a missing-dependency error without it. Public subpaths remain safe to import with all optional dependencies omitted, but imports do not prove native availability.
89
89
 
90
90
  There are no peer dependencies. Exact-version optional packages carry the seven
91
91
  native targets and npm-compatible OS, CPU, and Linux libc filters install only
@@ -29,6 +29,11 @@ The equivalent environment variables are `FS_SAFE_NATIVE_MODE` and `OPENCLAW_FS_
29
29
  | `off` | Do not load a native package. Use the guarded JavaScript path deterministically. |
30
30
  | `require` | Throw `FsSafeError("helper-unavailable")` instead of falling back when an operation needs the native binding and it cannot load. |
31
31
 
32
+ TAR/gzip in the guarded JavaScript path uses a bundled, import-free WASM build
33
+ of the same Rust parser used by native. `off` still disables native filesystem
34
+ code; it does not disable this portable parser. ZIP fallback still requires
35
+ optional `jszip`, and zstd/bzip2 remain native-only.
36
+
32
37
  Configure the mode once during startup. Loading is lazy and cached; changing from `auto` to `require` after a failed load changes failure policy but does not repeatedly probe the binary.
33
38
 
34
39
  [`tempWorkspace()` and its scoped/sync variants](temp.md#private-temp-workspaces)
package/docs/native.md CHANGED
@@ -64,13 +64,15 @@ returns a bounded manifest. TypeScript applies the shared path, filter, strip,
64
64
  mode, and byte policies and returns an index-bound extraction plan. Rust then
65
65
  creates only those planned entries beneath a private staging descriptor.
66
66
 
67
- A raw meter sits between decompression and the TAR crate, with matching
68
- TypeScript admission before node-tar. It parses 512-byte headers and bounded
69
- local PAX `x` metadata, using supported effective sizes to locate the following
70
- member body. GNU long-name/link `L`/`K` payloads remain
71
- supported. `maxMetaEntryBytes` bounds each metadata body before allocation;
72
- unsupported global/old metadata and sparse forms fail closed rather than being
73
- interpreted as ordinary members. See [bounded local PAX support](archive.md#bounded-local-pax-support).
67
+ The `fs-safe-archive-core` Rust workspace crate owns TAR framing, paths, types,
68
+ mode decoding, GNU metadata, and byte-counted local PAX records. The native
69
+ binding and bundled WASM module compile the same source. No `tar::Archive` or
70
+ Node TAR parser reinterprets admitted identities or sizes. Executors replay
71
+ admitted payload ranges after complete bounded admission; native writes retain
72
+ the platform's descriptor-relative primitives, while fallback writes retain
73
+ the guarded Node staging/publication boundary. ZIP behavior is unchanged.
74
+ `maxMetaEntryBytes` bounds bodies before allocation; unsupported global/old
75
+ metadata and sparse forms fail closed. See [bounded local PAX support](archive.md#bounded-local-pax-support).
74
76
 
75
77
  Every raw pass receives only TypeScript's resolved `maxEntries`,
76
78
  `maxMetaEntryBytes`, and `maxDecodedBytes`. Shared resolution caps metadata and
@@ -88,11 +90,15 @@ integer maximum. Every native pass receives that same cap and charges headers,
88
90
  metadata, bodies, padding, EOF blocks, and trailing zeros. It rejects overflow
89
91
  with `archive-decoded-size-exceeds-limit`; no ratio policy is implied.
90
92
  Extraction and entry reads drain the metered reader through physical EOF after
91
- TAR iteration. Trailing framing or decoded-limit failures propagate before
93
+ admitted-range replay. Trailing framing or decoded-limit failures propagate before
92
94
  directory modes are finalized, staging is published, or selected bytes return.
95
+ Native gzip uses the existing flate2 member decoder with an explicit bounded
96
+ member/padding transition; JavaScript retains Node gunzip and validates its
97
+ unconsumed compressed suffix. Only all-zero physical padding after a complete
98
+ validated trailer is accepted, still within the original archive-byte budget.
93
99
  Native reads stop at framing boundaries so a rejected header does not request
94
100
  its body from the decoder; codec buffering can still read ahead internally.
95
- Inspection finishes the complete bounded framing pass before parsing. Directory
101
+ Inspection finishes the complete bounded framing pass before returning its manifest. Directory
96
102
  and link bodies, missing two-block EOF, and nonzero trailers reject on both
97
103
  backends, as detailed in [raw TAR framing](archive.md#raw-tar-framing). Raw and
98
104
  padded sizes above JavaScript's safe-integer maximum reject as invalid framing
@@ -155,7 +161,7 @@ remain TypeScript-owned. What changes is the syscall strength or availability:
155
161
  | Capability | Native path | Guarded JavaScript path |
156
162
  |---|---|---|
157
163
  | Root-relative opens/mutations | Descriptor-relative beneath operations. Pinned writes create parents and publish both replacement and no-replace targets relative to open directory descriptors. Linux reports `kernel-atomic`; macOS and Windows report `best-effort`. macOS uses `O_RESOLVE_BENEATH` when available plus an `F_GETPATH` detector, while Windows rejects reparse traversal in the object-manager call. | Reports `best-effort`: component-wise alias checks, no-follow opens where Node exposes them, private temp/rename, and post-operation identity verification. A same-privilege peer can replace a writable parent after a guard assertion but before Node resolves the pathname mutation; the mutation may land outside the intended root before the post-check detects it. |
158
- | ZIP/TAR/gzip | Rust streaming decode and fd-relative output creation. | JSZip/node-tar into a private stage, then the same guarded merge policy. |
164
+ | ZIP/TAR/gzip | Rust streaming decode and fd-relative output creation. | Optional JSZip or bundled WASM TAR into guarded private staging, then the same guarded merge policy. |
159
165
  | Zstd/bzip2 TAR | Supported. | Unsupported; typed `helper-unavailable`. |
160
166
  | Publication copy | Clone, Linux `copy_file_range`, async native SHA-256. | Exclusive `wx` byte loop and Node SHA-256 with the same content/identity fences. |
161
167
  | `rename-noreplace` | Atomic platform no-replace rename. | Unsupported; no emulation by check-then-rename. |
@@ -91,6 +91,11 @@ credential must also fail on broad permissions or unexpected ownership.
91
91
  the same pinned-handle validation, byte cap, trimming, error codes, and strict
92
92
  versus missing-is-undefined naming semantics.
93
93
 
94
+ Both readers reject known unsafe device and process-fd paths with `device-path`
95
+ before inspection, and check the resolved target before opening it. This includes
96
+ Windows reserved device names and ignored-space aliases such as `nul .txt`.
97
+ Optional readers propagate this error instead of treating the secret as missing.
98
+
94
99
  Both sync and async readers compare lossless bigint identities from the preview,
95
100
  opened descriptor, resolved target, and current input path before reading. POSIX
96
101
  opens are nonblocking, so a raced FIFO is rejected by descriptor type instead of
@@ -21,7 +21,7 @@ try {
21
21
 
22
22
  The lock file sits next to the protected resource. If a process crashes mid-lock, the next acquirer notices the held entry, inspects its payload (PID, host, acquired-at timestamp), and decides — via `shouldReclaim` (defaulting to "is the lock older than `staleMs`?") — whether it should keep waiting or fail.
23
23
 
24
- On natural event-loop shutdown, a globally deduplicated `process.on("beforeExit")` handler attempts asynchronous cleanup of held Root-backed locks through their retained Root capability and ownership receipt. The synchronous `process.on("exit")` handler provides last-chance cleanup for raw locks and reclaim guards. Changed sidecars and failed Root cleanup remain in place; cleanup does not keep retrying during shutdown unless another acquisition re-arms it.
24
+ On natural event-loop shutdown, a globally deduplicated `process.on("beforeExit")` handler attempts asynchronous cleanup of held Root-backed locks through their retained Root capability and ownership receipt. The synchronous `process.on("exit")` handler provides last-chance cleanup for raw locks and reclaim guards. Changed sidecars and failed Root cleanup remain in place; cleanup does not keep retrying during shutdown unless another acquisition re-arms it. Locks acquired with `retainOnExit: true` are exempt from both handlers: their sidecar stays in place after exit and is governed only by the caller's own stale policy. Because exit handlers are globally deduplicated across package copies, `retainOnExit` fails closed with `helper-unavailable` if an older copy that cannot honor it registered the handlers first.
25
25
 
26
26
  Always release locks in a `finally` block. Application-managed graceful shutdown can await `release()` or `manager.drain()` before terminating. Explicit `process.exit()`, uncaught failures, crashes, default signal handling, and fatal termination (including `SIGKILL`) do not reliably run asynchronous Root cleanup and may leave sidecars. Recover only after an application-owned liveness policy proves the holder cannot still be writing.
27
27
 
@@ -82,6 +82,7 @@ type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
82
82
  metadata?: Record<string, unknown>; // attached to heldEntries() output for diagnostics
83
83
  parsePayload?: (raw: string) => unknown;
84
84
  lockRoot?: Root;
85
+ retainOnExit?: boolean; // keep the sidecar across process exit (default false)
85
86
  onCompromised?: (info: { lockPath: string; normalizedTargetPath: string }) => void;
86
87
  compromiseCheckIntervalMs?: number;
87
88
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "keywords": [
6
6
  "filesystem",
@@ -24,6 +24,7 @@
24
24
  "provenance": true
25
25
  },
26
26
  "files": [
27
+ "dist/archive-parser.wasm",
27
28
  "dist/**/*.js",
28
29
  "dist/**/*.d.ts",
29
30
  "dist/**/*.d.ts.map",
@@ -126,7 +127,7 @@
126
127
  "scripts": {
127
128
  "benchmark": "node scripts/benchmark.mjs",
128
129
  "benchmark:publish": "pnpm build && node scripts/bench-publish.mjs",
129
- "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
130
+ "build": "node scripts/prepack-build.mjs",
130
131
  "lint:file-size": "node scripts/check-file-size.mjs",
131
132
  "lint:fs-boundary": "node scripts/check-fs-boundary-primitives.mjs",
132
133
  "prepack": "node scripts/prepack-build.mjs",
@@ -139,7 +140,7 @@
139
140
  "docs:check": "node scripts/check-doc-examples.mjs",
140
141
  "docs:site": "node scripts/build-docs-site.mjs",
141
142
  "native:build": "pnpm --filter @openclaw/fs-safe-native-build build",
142
- "native:test": "cargo test --manifest-path native/Cargo.toml",
143
+ "native:test": "cargo test --workspace",
143
144
  "pack:check": "pnpm build && node scripts/check-pack.mjs",
144
145
  "public-api:update": "pnpm build && node scripts/check-pack.mjs --update-public-api",
145
146
  "package:collect": "node scripts/check-release-packages.mjs --output release-artifacts",
@@ -150,32 +151,34 @@
150
151
  "crabbox:hydrate": "crabbox actions hydrate",
151
152
  "crabbox:run": "crabbox run",
152
153
  "crabbox:stop": "crabbox stop",
153
- "crabbox:warmup": "crabbox warmup"
154
+ "crabbox:warmup": "crabbox warmup",
155
+ "archive:wasm": "node scripts/build-archive-wasm.mjs",
156
+ "archive:producer-smoke": "node scripts/archive-producer-smoke.mjs"
154
157
  },
155
158
  "optionalDependencies": {
156
- "@openclaw/fs-safe-darwin-arm64": "0.8.0",
157
- "@openclaw/fs-safe-darwin-x64": "0.8.0",
158
- "@openclaw/fs-safe-linux-arm64-gnu": "0.8.0",
159
- "@openclaw/fs-safe-linux-arm64-musl": "0.8.0",
160
- "@openclaw/fs-safe-linux-x64-gnu": "0.8.0",
161
- "@openclaw/fs-safe-linux-x64-musl": "0.8.0",
162
- "@openclaw/fs-safe-win32-x64-msvc": "0.8.0",
163
- "jszip": "^3.10.1",
164
- "tar": "7.5.22"
159
+ "@openclaw/fs-safe-darwin-arm64": "0.8.2",
160
+ "@openclaw/fs-safe-darwin-x64": "0.8.2",
161
+ "@openclaw/fs-safe-linux-arm64-gnu": "0.8.2",
162
+ "@openclaw/fs-safe-linux-arm64-musl": "0.8.2",
163
+ "@openclaw/fs-safe-linux-x64-gnu": "0.8.2",
164
+ "@openclaw/fs-safe-linux-x64-musl": "0.8.2",
165
+ "@openclaw/fs-safe-win32-x64-msvc": "0.8.2",
166
+ "jszip": "^3.10.1"
165
167
  },
166
168
  "devDependencies": {
167
169
  "@emnapi/runtime": "2.0.0-alpha.4",
168
- "@napi-rs/cli": "3.8.6",
170
+ "@napi-rs/cli": "3.9.0",
169
171
  "@types/node": "^26.4.1",
170
- "@vitest/coverage-v8": "4.1.11",
172
+ "@vitest/coverage-v8": "5.0.0",
171
173
  "fast-check": "^4.9.0",
172
174
  "istanbul-lib-coverage": "3.2.2",
173
175
  "istanbul-lib-report": "3.0.1",
174
176
  "istanbul-reports": "3.2.0",
175
177
  "sigstore": "5.0.0",
178
+ "tar": "7.5.22",
176
179
  "typescript": "^7.0.2",
177
180
  "vite": "8.2.2",
178
- "vitest": "^4.1.11"
181
+ "vitest": "^5.0.0"
179
182
  },
180
183
  "engines": {
181
184
  "node": ">=22"
@@ -1,7 +0,0 @@
1
- import type { TarEntryInfo } from "./archive-tar.js";
2
- export declare function rawTarMember(header: Buffer, size: number, effectivePath?: string): TarEntryInfo;
3
- export declare function createTarAdmissionPlan(manifest: readonly TarEntryInfo[], check: (entry: TarEntryInfo) => boolean, strip: number): {
4
- consume(entry: TarEntryInfo): string | null;
5
- finish(): void;
6
- };
7
- //# sourceMappingURL=archive-tar-admission.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"archive-tar-admission.d.ts","sourceRoot":"","sources":["../src/archive-tar-admission.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAYrD,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,aAAa,CAAC,EAAE,MAAM,GAAG,YAAY,CAS/F;AAED,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,SAAS,YAAY,EAAE,EACjC,KAAK,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,OAAO,EACvC,KAAK,EAAE,MAAM,GACZ;IAAE,OAAO,CAAC,KAAK,EAAE,YAAY,GAAG,MAAM,GAAG,IAAI,CAAC;IAAC,MAAM,IAAI,IAAI,CAAA;CAAE,CAmBjE"}
@@ -1,43 +0,0 @@
1
- import { ArchiveFormatError } from "./archive-errors.js";
2
- import { stripArchivePath, validateArchiveEntryPath } from "./archive-entry.js";
3
- import { readTarHeaderPaths } from "./archive-tar-header.js";
4
- // node-tar's normalFsTypes/ReadEntry switch. All other non-metadata types
5
- // bypass both its filter and entry event; they still belong to our manifest.
6
- // Header.decode normalizes NUL to "0" before choosing the emitted File type.
7
- const visibleTypes = new Map([
8
- [0, "File"], [0x30, "File"], [0x31, "Link"], [0x32, "SymbolicLink"],
9
- [0x33, "CharacterDevice"], [0x34, "BlockDevice"], [0x35, "Directory"],
10
- [0x36, "FIFO"], [0x37, "ContiguousFile"], [0x44, "GNUDumpDir"],
11
- ]);
12
- export function rawTarMember(header, size, effectivePath) {
13
- const { name, prefix } = readTarHeaderPaths(header);
14
- validateArchiveEntryPath(name);
15
- validateArchiveEntryPath(prefix);
16
- const rawPath = prefix ? `${prefix}/${name}` : name;
17
- validateArchiveEntryPath(rawPath);
18
- const path = effectivePath ?? rawPath;
19
- validateArchiveEntryPath(path);
20
- return { path, size, type: visibleTypes.get(header[156]) ?? "Unsupported" };
21
- }
22
- export function createTarAdmissionPlan(manifest, check, strip) {
23
- const visible = [];
24
- for (const entry of manifest) {
25
- const accepted = check(entry);
26
- if (entry.type !== "Unsupported") {
27
- visible.push({ entry, output: accepted ? stripArchivePath(entry.path, strip) : null });
28
- }
29
- }
30
- let index = 0;
31
- const mismatch = () => new ArchiveFormatError("invalid TAR header: parser disagrees with raw admission");
32
- return {
33
- consume(actual) {
34
- const expected = visible[index++];
35
- if (!expected || expected.entry.type !== actual.type || expected.entry.size !== actual.size ||
36
- stripArchivePath(expected.entry.path, 0) !== stripArchivePath(actual.path, 0))
37
- throw mismatch();
38
- return expected.output;
39
- },
40
- finish() { if (index !== visible.length)
41
- throw mismatch(); },
42
- };
43
- }
@@ -1,2 +0,0 @@
1
- export declare function validateGnuMetadata(body: Buffer, type: "L" | "K"): string;
2
- //# sourceMappingURL=archive-tar-gnu.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"archive-tar-gnu.d.ts","sourceRoot":"","sources":["../src/archive-tar-gnu.ts"],"names":[],"mappings":"AAKA,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,GAAG,GAAG,GAAG,MAAM,CAazE"}
@@ -1,20 +0,0 @@
1
- import { ArchiveFormatError } from "./archive-errors.js";
2
- import { validateArchiveEntryPath } from "./archive-entry.js";
3
- // Keep the byte grammar aligned with native/src/tar_meter.rs. Parsers differ
4
- // on embedded NULs and malformed UTF-8, so validate before either sees a name.
5
- export function validateGnuMetadata(body, type) {
6
- const value = body.at(-1) === 0 ? body.subarray(0, -1) : body;
7
- if (!value.length || value.includes(0)) {
8
- throw new ArchiveFormatError("invalid GNU metadata: empty name or embedded NUL");
9
- }
10
- let name;
11
- try {
12
- name = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(value);
13
- }
14
- catch {
15
- throw new ArchiveFormatError("invalid GNU metadata: name is not valid UTF-8");
16
- }
17
- if (type === "L")
18
- validateArchiveEntryPath(name);
19
- return name;
20
- }
@@ -1,8 +0,0 @@
1
- export declare function validateTarChecksum(header: Buffer): void;
2
- export declare function readTarHeaderPaths(header: Buffer): {
3
- name: string;
4
- prefix: string;
5
- linkname: string;
6
- };
7
- export declare function validateTarHeader(header: Buffer): void;
8
- //# sourceMappingURL=archive-tar-header.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"archive-tar-header.d.ts","sourceRoot":"","sources":["../src/archive-tar-header.ts"],"names":[],"mappings":"AAGA,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAWxD;AAcD,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CASrG;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAUtD"}