@openclaw/fs-safe 0.8.1 → 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 (60) hide show
  1. package/CHANGELOG.md +11 -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/docs/archive.md +66 -61
  37. package/docs/contributing.md +35 -1
  38. package/docs/install.md +1 -1
  39. package/docs/native-helper.md +5 -0
  40. package/docs/native.md +16 -10
  41. package/docs/secret-file.md +5 -0
  42. package/package.json +19 -16
  43. package/dist/archive-tar-admission.d.ts +0 -7
  44. package/dist/archive-tar-admission.d.ts.map +0 -1
  45. package/dist/archive-tar-admission.js +0 -43
  46. package/dist/archive-tar-gnu.d.ts +0 -2
  47. package/dist/archive-tar-gnu.d.ts.map +0 -1
  48. package/dist/archive-tar-gnu.js +0 -20
  49. package/dist/archive-tar-header.d.ts +0 -8
  50. package/dist/archive-tar-header.d.ts.map +0 -1
  51. package/dist/archive-tar-header.js +0 -47
  52. package/dist/archive-tar-meta.d.ts +0 -34
  53. package/dist/archive-tar-meta.d.ts.map +0 -1
  54. package/dist/archive-tar-meta.js +0 -277
  55. package/dist/archive-tar-pax.d.ts +0 -8
  56. package/dist/archive-tar-pax.d.ts.map +0 -1
  57. package/dist/archive-tar-pax.js +0 -100
  58. package/dist/archive-tar-runtime.d.ts +0 -49
  59. package/dist/archive-tar-runtime.d.ts.map +0 -1
  60. package/dist/archive-tar-runtime.js +0 -22
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.8.1",
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.1",
157
- "@openclaw/fs-safe-darwin-x64": "0.8.1",
158
- "@openclaw/fs-safe-linux-arm64-gnu": "0.8.1",
159
- "@openclaw/fs-safe-linux-arm64-musl": "0.8.1",
160
- "@openclaw/fs-safe-linux-x64-gnu": "0.8.1",
161
- "@openclaw/fs-safe-linux-x64-musl": "0.8.1",
162
- "@openclaw/fs-safe-win32-x64-msvc": "0.8.1",
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"}
@@ -1,47 +0,0 @@
1
- import { ArchiveFormatError, ArchiveSecurityError } from "./archive-errors.js";
2
- const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
3
- export function validateTarChecksum(header) {
4
- const field = header.subarray(148, 156);
5
- const end = field.indexOf(0);
6
- const digits = field.subarray(0, end < 0 ? field.length : end).toString("latin1").replace(/^ +| +$/g, "");
7
- const sum = header.reduce((total, byte, index) => total + (index >= 148 && index < 156 ? 32 : byte), 0);
8
- // Require an in-field delimiter: node-tar otherwise reads into the typeflag.
9
- if (!/^[0-7]+$/.test(digits) || ![0, 32].includes(field[7]) ||
10
- (end >= 0 && field.subarray(end).some((byte) => byte !== 0 && byte !== 32)) ||
11
- Number.parseInt(digits, 8) !== sum) {
12
- throw new ArchiveFormatError("invalid TAR header: checksum failure");
13
- }
14
- }
15
- function fixedField(field) {
16
- const zero = field.indexOf(0);
17
- if (zero >= 0 && field.subarray(zero).some((byte) => byte !== 0)) {
18
- throw new ArchiveSecurityError("entry-path", "tar entry path contains bytes after NUL");
19
- }
20
- try {
21
- return utf8.decode(field.subarray(0, zero < 0 ? field.length : zero));
22
- }
23
- catch {
24
- throw new ArchiveSecurityError("entry-path", "tar entry path is not valid UTF-8");
25
- }
26
- }
27
- export function readTarHeaderPaths(header) {
28
- // node-tar's star layout uses a 130-byte prefix followed by atime/ctime.
29
- const prefixEnd = header[475] === 0 ? 475 : 500;
30
- return {
31
- name: fixedField(header.subarray(0, 100)),
32
- prefix: header.subarray(257, 265).equals(Buffer.from("ustar\0" + "00"))
33
- ? fixedField(header.subarray(345, prefixEnd)) : "",
34
- linkname: fixedField(header.subarray(157, 257)),
35
- };
36
- }
37
- export function validateTarHeader(header) {
38
- validateTarChecksum(header);
39
- const { linkname } = readTarHeaderPaths(header);
40
- const isLink = header[156] === 0x31 || header[156] === 0x32;
41
- if (isLink && !linkname) {
42
- throw new ArchiveFormatError("invalid TAR header: linkname required on a link header");
43
- }
44
- if (!isLink && linkname) {
45
- throw new ArchiveFormatError("invalid TAR header: linkname forbidden on a non-link header");
46
- }
47
- }
@@ -1,34 +0,0 @@
1
- import { Transform } from "node:stream";
2
- import { type TarMeterLimits } from "./archive-limits.js";
3
- import type { TarEntryInfo } from "./archive-tar.js";
4
- export declare class TarMetadataMeter extends Transform {
5
- private readonly limits;
6
- private readonly onMember?;
7
- private readonly block;
8
- private blockLength;
9
- private state;
10
- private pendingPax;
11
- private readonly pendingGnu;
12
- private pendingGnuPath;
13
- private zeroBlocks;
14
- private entries;
15
- private remainingDecodedBytes;
16
- private manifestBytes;
17
- constructor(limits: TarMeterLimits, onMember?: ((entry: TarEntryInfo) => void) | undefined);
18
- private countMember;
19
- private invalid;
20
- private paddedSize;
21
- private parseSize;
22
- private finishHeader;
23
- private finishSparseHeader;
24
- private meter;
25
- _transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null, data?: Buffer) => void): void;
26
- _flush(callback: (error?: Error | null) => void): void;
27
- }
28
- export declare function preflightTarMetadata(params: {
29
- archivePath: string;
30
- limits: TarMeterLimits;
31
- signal?: AbortSignal;
32
- onMember?: (entry: TarEntryInfo) => void;
33
- }): Promise<void>;
34
- //# sourceMappingURL=archive-tar-meta.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"archive-tar-meta.d.ts","sourceRoot":"","sources":["../src/archive-tar-meta.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAY,MAAM,aAAa,CAAC;AAIlD,OAAO,EAAqE,KAAK,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAI7H,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAWrD,qBAAa,gBAAiB,SAAQ,SAAS;IAYjC,OAAO,CAAC,QAAQ,CAAC,MAAM;IAAkB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC;IAX/E,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAqB;IAC3C,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,KAAK,CAAkC;IAC/C,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAwB;IACnD,OAAO,CAAC,cAAc,CAAqB;IAC3C,OAAO,CAAC,UAAU,CAAK;IACvB,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,qBAAqB,CAAS;IACtC,OAAO,CAAC,aAAa,CAAK;IAE1B,YAA6B,MAAM,EAAE,cAAc,EAAmB,QAAQ,CAAC,GAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,aAAA,EAM7G;IAED,OAAO,CAAC,WAAW;IAOnB,OAAO,CAAC,OAAO;IAIf,OAAO,CAAC,UAAU;IAMlB,OAAO,CAAC,SAAS;IA4BjB,OAAO,CAAC,YAAY;IA6EpB,OAAO,CAAC,kBAAkB;IAgB1B,OAAO,CAAC,KAAK;IAyCJ,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,cAAc,EAAE,QAAQ,EAAE,CAAC,KAAK,CAAC,EAAE,KAAK,GAAG,IAAI,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAc3H;IAEQ,MAAM,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,EAAE,KAAK,GAAG,IAAI,KAAK,IAAI,GAAG,IAAI,CAM9D;CACF;AAaD,wBAAsB,oBAAoB,CAAC,MAAM,EAAE;IACjD,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,cAAc,CAAC;IACvB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAC;CAC1C,GAAG,OAAO,CAAC,IAAI,CAAC,CAahB"}
@@ -1,277 +0,0 @@
1
- import fs from "node:fs";
2
- import { Transform, Writable } from "node:stream";
3
- import { pipeline } from "node:stream/promises";
4
- import { createGunzip } from "node:zlib";
5
- import { ArchiveFormatError } from "./archive-errors.js";
6
- import { ARCHIVE_LIMIT_ERROR_CODE, ArchiveLimitError, tarManifestEntryCost } from "./archive-limits.js";
7
- import { parseLocalPax, paxMemberSize } from "./archive-tar-pax.js";
8
- import { validateGnuMetadata } from "./archive-tar-gnu.js";
9
- import { rawTarMember } from "./archive-tar-admission.js";
10
- import { validateTarHeader } from "./archive-tar-header.js";
11
- // Keep raw framing aligned with native/src/tar_meter.rs, before either parser.
12
- export class TarMetadataMeter extends Transform {
13
- limits;
14
- onMember;
15
- block = Buffer.alloc(512);
16
- blockLength = 0;
17
- state = { kind: "header" };
18
- pendingPax;
19
- pendingGnu = new Set();
20
- pendingGnuPath;
21
- zeroBlocks = 0;
22
- entries = 0;
23
- remainingDecodedBytes;
24
- manifestBytes = 0;
25
- constructor(limits, onMember) {
26
- super();
27
- this.limits = limits;
28
- this.onMember = onMember;
29
- if (!Number.isSafeInteger(limits.maxDecodedBytes) || limits.maxDecodedBytes < 0) {
30
- throw new RangeError("maxDecodedBytes must be a non-negative safe integer");
31
- }
32
- this.remainingDecodedBytes = limits.maxDecodedBytes;
33
- }
34
- countMember() {
35
- if (this.entries >= this.limits.maxEntries) {
36
- throw new ArchiveLimitError(ARCHIVE_LIMIT_ERROR_CODE.ENTRY_COUNT_EXCEEDS_LIMIT);
37
- }
38
- this.entries += 1;
39
- }
40
- invalid(message) {
41
- return new ArchiveFormatError(`invalid TAR header: ${message}`);
42
- }
43
- paddedSize(size) {
44
- const padded = Math.ceil(size / 512) * 512;
45
- if (!Number.isSafeInteger(padded))
46
- throw this.invalid("entry padding exceeds the safe integer range");
47
- return padded;
48
- }
49
- parseSize() {
50
- const field = this.block.subarray(124, 136);
51
- if ((field[0] ?? 0) & 0x80) {
52
- if (field[0] !== 0x80) {
53
- throw this.invalid("base-256 size is negative or malformed");
54
- }
55
- let value = 0n;
56
- for (const byte of field.subarray(1)) {
57
- value = (value << 8n) | BigInt(byte);
58
- }
59
- if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
60
- throw this.invalid("base-256 size exceeds the safe integer range");
61
- }
62
- return Number(value);
63
- }
64
- const zero = field.indexOf(0);
65
- if (zero >= 0 && field.subarray(zero).some((byte) => byte !== 0 && byte !== 0x20)) {
66
- throw this.invalid("size has non-padding bytes after NUL");
67
- }
68
- const bytes = field.subarray(0, zero < 0 ? field.length : zero);
69
- if (bytes.some((byte) => byte > 0x7f))
70
- throw this.invalid("size is not ASCII octal");
71
- const text = bytes.toString("ascii").replace(/^ +| +$/g, "");
72
- if (!text || !/^[0-7]+$/.test(text))
73
- throw this.invalid("size is not valid octal");
74
- const value = Number.parseInt(text, 8);
75
- if (!Number.isSafeInteger(value))
76
- throw this.invalid("octal size exceeds the safe integer range");
77
- return value;
78
- }
79
- finishHeader() {
80
- if (this.block.every((byte) => byte === 0)) {
81
- if (this.pendingPax)
82
- throw this.invalid("dangling PAX metadata");
83
- if (this.pendingGnu.size)
84
- throw this.invalid("dangling GNU metadata");
85
- this.blockLength = 0;
86
- this.zeroBlocks += 1;
87
- this.state = { kind: this.zeroBlocks === 2 ? "eof" : "header" };
88
- return;
89
- }
90
- if (this.zeroBlocks !== 0)
91
- throw this.invalid("nonzero header after one TAR zero block");
92
- validateTarHeader(this.block);
93
- const nameEnd = this.block.subarray(0, 100).indexOf(0);
94
- const name = this.block.subarray(0, nameEnd < 0 ? 100 : nameEnd);
95
- if (name.length === 0) {
96
- throw this.invalid("entry path is empty");
97
- }
98
- if (![0x35, 0x44].includes(this.block[156]) && name.at(-1) === 0x2f) {
99
- throw this.invalid("non-directory entry path ends with a separator");
100
- }
101
- let size = this.parseSize();
102
- let padded = this.paddedSize(size);
103
- const type = this.block[156];
104
- // Directory/link headers cannot carry bodies; PAX/GNU metadata can.
105
- if ([0x31, 0x32, 0x35].includes(type) && size !== 0) {
106
- throw this.invalid("directory or link has a nonzero body size");
107
- }
108
- if ([0x78, 0x67, 0x4c, 0x4b, 0x58, 0x4e].includes(type) && size > this.limits.maxMetaEntryBytes) {
109
- throw new ArchiveLimitError(ARCHIVE_LIMIT_ERROR_CODE.META_ENTRY_SIZE_EXCEEDS_LIMIT);
110
- }
111
- if (type === 0x67 || type === 0x58 || type === 0x4e) {
112
- throw this.invalid("global/old PAX and old GNU metadata are not supported");
113
- }
114
- if (type === 0x78 || type === 0x4c || type === 0x4b) {
115
- const metadataType = type === 0x78 ? "pax" : type === 0x4c ? "L" : "K";
116
- if (this.pendingPax || size === 0 || (metadataType === "pax" ? this.pendingGnu.size : this.pendingGnu.has(metadataType))) {
117
- throw this.invalid("empty, repeated or mixed PAX/GNU metadata");
118
- }
119
- const magic = this.block.subarray(257, 265).toString("latin1");
120
- if (magic !== "ustar\0" + "00" && magic !== "ustar \0")
121
- throw this.invalid("unrecognized PAX/GNU header format");
122
- if (metadataType !== "pax")
123
- this.pendingGnu.add(metadataType);
124
- this.state = { kind: "metadata", type: metadataType, body: Buffer.alloc(size), used: 0, padding: padded - size };
125
- this.blockLength = 0;
126
- return;
127
- }
128
- // Sparse headers retain their metadata-limit-before-format-error ordering.
129
- if (this.pendingPax && type !== 0x53) {
130
- size = paxMemberSize(this.pendingPax, type, size, this.block);
131
- }
132
- padded = this.paddedSize(size);
133
- if (type === 0x53) {
134
- if (this.block[482] !== 0 && this.block[482] !== 1) {
135
- throw this.invalid("GNU sparse extension flag is not 0 or 1");
136
- }
137
- if (this.block[482] === 0) {
138
- throw this.invalid("GNU sparse entries are not supported");
139
- }
140
- this.state = { kind: "sparse", dataRemaining: padded, metaBytes: 0 };
141
- }
142
- else {
143
- this.countMember();
144
- if (this.pendingGnuPath && /[\\/]$/.test(this.pendingGnuPath) && ![0x35, 0x44].includes(type)) {
145
- throw this.invalid("GNU effective non-directory path ends with a separator");
146
- }
147
- const entry = rawTarMember(this.block, size, this.pendingPax?.path ?? this.pendingGnuPath);
148
- const cost = tarManifestEntryCost(entry.path);
149
- if (cost > this.limits.maxManifestBytes - this.manifestBytes) {
150
- throw new ArchiveLimitError(ARCHIVE_LIMIT_ERROR_CODE.MANIFEST_SIZE_EXCEEDS_LIMIT);
151
- }
152
- this.manifestBytes += cost;
153
- this.onMember?.(entry);
154
- this.pendingPax = undefined;
155
- this.pendingGnu.clear();
156
- this.pendingGnuPath = undefined;
157
- this.state = padded === 0 ? { kind: "header" } : { kind: "data", remaining: padded };
158
- }
159
- this.blockLength = 0;
160
- }
161
- finishSparseHeader(state) {
162
- const metaBytes = state.metaBytes + 512;
163
- if (metaBytes > this.limits.maxMetaEntryBytes) {
164
- throw new ArchiveLimitError(ARCHIVE_LIMIT_ERROR_CODE.META_ENTRY_SIZE_EXCEEDS_LIMIT);
165
- }
166
- if (this.block[504] !== 0 && this.block[504] !== 1) {
167
- throw this.invalid("GNU sparse extension flag is not 0 or 1");
168
- }
169
- if (this.block[504] === 1) {
170
- this.state = { ...state, metaBytes };
171
- }
172
- else {
173
- throw this.invalid("GNU sparse entries are not supported");
174
- }
175
- this.blockLength = 0;
176
- }
177
- meter(chunk) {
178
- let offset = 0;
179
- while (offset < chunk.length) {
180
- if (this.state.kind === "eof") {
181
- if (chunk.subarray(offset).some((byte) => byte !== 0))
182
- throw this.invalid("nonzero data after TAR EOF");
183
- return;
184
- }
185
- if (this.state.kind === "metadata") {
186
- const state = this.state;
187
- const take = Math.min(state.body.length - state.used, chunk.length - offset);
188
- chunk.copy(state.body, state.used, offset, offset + take);
189
- state.used += take;
190
- offset += take;
191
- if (state.used === state.body.length) {
192
- if (state.type === "pax")
193
- this.pendingPax = parseLocalPax(state.body);
194
- else {
195
- const name = validateGnuMetadata(state.body, state.type);
196
- if (state.type === "L")
197
- this.pendingGnuPath = name;
198
- }
199
- this.state = state.padding === 0 ? { kind: "header" } : { kind: "data", remaining: state.padding };
200
- }
201
- continue;
202
- }
203
- if (this.state.kind === "data") {
204
- const take = Math.min(this.state.remaining, chunk.length - offset);
205
- offset += take;
206
- const remaining = this.state.remaining - take;
207
- this.state = remaining === 0 ? { kind: "header" } : { kind: "data", remaining };
208
- continue;
209
- }
210
- const take = Math.min(512 - this.blockLength, chunk.length - offset);
211
- chunk.copy(this.block, this.blockLength, offset, offset + take);
212
- this.blockLength += take;
213
- offset += take;
214
- if (this.blockLength === 512) {
215
- if (this.state.kind === "sparse")
216
- this.finishSparseHeader(this.state);
217
- else
218
- this.finishHeader();
219
- }
220
- }
221
- }
222
- _transform(chunk, _encoding, callback) {
223
- try {
224
- const admitted = Math.min(chunk.length, this.remainingDecodedBytes);
225
- this.meter(chunk.subarray(0, admitted));
226
- this.remainingDecodedBytes -= admitted;
227
- if (admitted < chunk.length) {
228
- // Inspect only the overflow probe, never scan an unbounded tail.
229
- if (this.state.kind === "eof" && chunk[admitted] !== 0)
230
- throw this.invalid("nonzero data after TAR EOF");
231
- throw new ArchiveLimitError(ARCHIVE_LIMIT_ERROR_CODE.DECODED_SIZE_EXCEEDS_LIMIT);
232
- }
233
- callback(null, chunk);
234
- }
235
- catch (error) {
236
- callback(error instanceof Error ? error : new Error(String(error)));
237
- }
238
- }
239
- _flush(callback) {
240
- if (this.pendingPax)
241
- callback(this.invalid("dangling PAX metadata"));
242
- else if (this.pendingGnu.size)
243
- callback(this.invalid("dangling GNU metadata"));
244
- else if (this.state.kind === "eof")
245
- callback();
246
- else if (this.state.kind === "header" && this.blockLength === 0)
247
- callback(this.invalid("missing two-block TAR EOF"));
248
- else
249
- callback(this.invalid(this.state.kind === "header" ? "truncated TAR header" : "truncated TAR entry"));
250
- }
251
- }
252
- async function isGzip(filePath) {
253
- const handle = await fs.promises.open(filePath, "r");
254
- try {
255
- const magic = Buffer.alloc(2);
256
- const { bytesRead } = await handle.read(magic, 0, 2, 0);
257
- return bytesRead === 2 && magic[0] === 0x1f && magic[1] === 0x8b;
258
- }
259
- finally {
260
- await handle.close();
261
- }
262
- }
263
- export async function preflightTarMetadata(params) {
264
- const meter = new TarMetadataMeter(params.limits, params.onMember);
265
- const sink = new Writable({ write(_chunk, _encoding, callback) { callback(); } });
266
- const gzip = await isGzip(params.archivePath);
267
- const decoder = gzip ? createGunzip() : undefined;
268
- const input = fs.createReadStream(params.archivePath);
269
- try {
270
- await (decoder
271
- ? pipeline(input, decoder, meter, sink, { signal: params.signal })
272
- : pipeline(input, meter, sink, { signal: params.signal }));
273
- }
274
- finally {
275
- input.destroy();
276
- }
277
- }
@@ -1,8 +0,0 @@
1
- export type LocalPax = {
2
- path?: string;
3
- linkpath?: string;
4
- size?: number;
5
- };
6
- export declare function parseLocalPax(body: Buffer): LocalPax;
7
- export declare function paxMemberSize(pax: LocalPax, type: number, rawSize: number, header: Buffer): number;
8
- //# sourceMappingURL=archive-tar-pax.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"archive-tar-pax.d.ts","sourceRoot":"","sources":["../src/archive-tar-pax.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,QAAQ,GAAG;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAqB3E,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,CAwCpD;AAQD,wBAAgB,aAAa,CAAC,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAclG"}