@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.
- package/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/dist/archive-entry.d.ts.map +1 -1
- package/dist/archive-entry.js +5 -0
- package/dist/archive-gzip-tail.d.ts +18 -0
- package/dist/archive-gzip-tail.d.ts.map +1 -0
- package/dist/archive-gzip-tail.js +74 -0
- package/dist/archive-parser.wasm +0 -0
- package/dist/archive-read.d.ts.map +1 -1
- package/dist/archive-read.js +18 -60
- package/dist/archive-tar-extract.d.ts +11 -0
- package/dist/archive-tar-extract.d.ts.map +1 -0
- package/dist/archive-tar-extract.js +49 -0
- package/dist/archive-tar-stream.d.ts +18 -0
- package/dist/archive-tar-stream.d.ts.map +1 -0
- package/dist/archive-tar-stream.js +95 -0
- package/dist/archive-tar-wasm.d.ts +18 -0
- package/dist/archive-tar-wasm.d.ts.map +1 -0
- package/dist/archive-tar-wasm.js +102 -0
- package/dist/archive-tar.d.ts +0 -1
- package/dist/archive-tar.d.ts.map +1 -1
- package/dist/archive-tar.js +0 -22
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +2 -109
- package/dist/device-path.d.ts +1 -0
- package/dist/device-path.d.ts.map +1 -1
- package/dist/device-path.js +5 -2
- package/dist/directory-mode-owner.d.ts.map +1 -1
- package/dist/directory-mode-owner.js +3 -0
- package/dist/replace-file-descriptor.d.ts.map +1 -1
- package/dist/replace-file-descriptor.js +2 -1
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +2 -0
- package/dist/secret-read-async.d.ts.map +1 -1
- package/dist/secret-read-async.js +3 -0
- package/dist/sidecar-lock-acquire.d.ts +1 -0
- package/dist/sidecar-lock-acquire.d.ts.map +1 -1
- package/dist/sidecar-lock-acquire.js +6 -0
- package/dist/sidecar-lock-types.d.ts +7 -0
- package/dist/sidecar-lock-types.d.ts.map +1 -1
- package/dist/sidecar-lock.d.ts +2 -0
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +19 -4
- package/docs/archive.md +66 -61
- package/docs/contributing.md +35 -1
- package/docs/install.md +1 -1
- package/docs/native-helper.md +5 -0
- package/docs/native.md +16 -10
- package/docs/secret-file.md +5 -0
- package/docs/sidecar-lock.md +2 -1
- package/package.json +19 -16
- package/dist/archive-tar-admission.d.ts +0 -7
- package/dist/archive-tar-admission.d.ts.map +0 -1
- package/dist/archive-tar-admission.js +0 -43
- package/dist/archive-tar-gnu.d.ts +0 -2
- package/dist/archive-tar-gnu.d.ts.map +0 -1
- package/dist/archive-tar-gnu.js +0 -20
- package/dist/archive-tar-header.d.ts +0 -8
- package/dist/archive-tar-header.d.ts.map +0 -1
- package/dist/archive-tar-header.js +0 -47
- package/dist/archive-tar-meta.d.ts +0 -34
- package/dist/archive-tar-meta.d.ts.map +0 -1
- package/dist/archive-tar-meta.js +0 -277
- package/dist/archive-tar-pax.d.ts +0 -8
- package/dist/archive-tar-pax.d.ts.map +0 -1
- package/dist/archive-tar-pax.js +0 -100
- package/dist/archive-tar-runtime.d.ts +0 -49
- package/dist/archive-tar-runtime.d.ts.map +0 -1
- 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
|
-
|
|
6
|
-
|
|
7
|
-
that
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
72
|
-
within
|
|
73
|
-
Malformed or unsupported mode
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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.
|
|
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
|
|
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
|
-
- **
|
|
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
|
|
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
|
-
|
|
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
|
|
275
|
-
framing rules
|
|
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
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
field's NUL terminator reject
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
|
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
|
-
|
|
397
|
-
`maxMetaEntryBytes
|
|
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
|
|
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.
|
package/docs/contributing.md
CHANGED
|
@@ -19,7 +19,16 @@ manager version declared in `package.json`.
|
|
|
19
19
|
pnpm build
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Runs
|
|
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`
|
|
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
|
package/docs/native-helper.md
CHANGED
|
@@ -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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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. |
|
package/docs/secret-file.md
CHANGED
|
@@ -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
|
package/docs/sidecar-lock.md
CHANGED
|
@@ -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.
|
|
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 -
|
|
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 --
|
|
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.
|
|
157
|
-
"@openclaw/fs-safe-darwin-x64": "0.8.
|
|
158
|
-
"@openclaw/fs-safe-linux-arm64-gnu": "0.8.
|
|
159
|
-
"@openclaw/fs-safe-linux-arm64-musl": "0.8.
|
|
160
|
-
"@openclaw/fs-safe-linux-x64-gnu": "0.8.
|
|
161
|
-
"@openclaw/fs-safe-linux-x64-musl": "0.8.
|
|
162
|
-
"@openclaw/fs-safe-win32-x64-msvc": "0.8.
|
|
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.
|
|
170
|
+
"@napi-rs/cli": "3.9.0",
|
|
169
171
|
"@types/node": "^26.4.1",
|
|
170
|
-
"@vitest/coverage-v8": "
|
|
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": "^
|
|
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 +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"}
|
package/dist/archive-tar-gnu.js
DELETED
|
@@ -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"}
|