@blamejs/blamejs-shop 0.0.83 → 0.0.98

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 (33) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/lib/admin.js +11 -7
  3. package/lib/customer-import.js +1 -1
  4. package/lib/email-campaigns.js +1 -1
  5. package/lib/pwa-manifest.js +1 -0
  6. package/lib/vendor/MANIFEST.json +2 -2
  7. package/lib/vendor/blamejs/.github/workflows/sha-to-tag-verify.yml +8 -0
  8. package/lib/vendor/blamejs/CHANGELOG.md +6 -0
  9. package/lib/vendor/blamejs/README.md +1 -1
  10. package/lib/vendor/blamejs/SECURITY.md +1 -0
  11. package/lib/vendor/blamejs/api-snapshot.json +167 -2
  12. package/lib/vendor/blamejs/fuzz/safe-archive.fuzz.js +37 -0
  13. package/lib/vendor/blamejs/index.js +15 -1
  14. package/lib/vendor/blamejs/lib/archive-adapters.js +629 -0
  15. package/lib/vendor/blamejs/lib/archive-gz.js +229 -0
  16. package/lib/vendor/blamejs/lib/archive-read.js +781 -0
  17. package/lib/vendor/blamejs/lib/archive-tar-read.js +418 -0
  18. package/lib/vendor/blamejs/lib/archive-tar.js +571 -0
  19. package/lib/vendor/blamejs/lib/archive.js +24 -2
  20. package/lib/vendor/blamejs/lib/audit.js +22 -7
  21. package/lib/vendor/blamejs/lib/backup/index.js +469 -0
  22. package/lib/vendor/blamejs/lib/guard-archive.js +180 -0
  23. package/lib/vendor/blamejs/lib/guard-filename.js +205 -0
  24. package/lib/vendor/blamejs/lib/safe-archive.js +309 -0
  25. package/lib/vendor/blamejs/package.json +1 -1
  26. package/lib/vendor/blamejs/release-notes/v0.12.7.json +86 -0
  27. package/lib/vendor/blamejs/release-notes/v0.12.8.json +81 -0
  28. package/lib/vendor/blamejs/release-notes/v0.12.9.json +61 -0
  29. package/lib/vendor/blamejs/test/layer-0-primitives/archive-gz.test.js +159 -0
  30. package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +247 -0
  31. package/lib/vendor/blamejs/test/layer-0-primitives/archive-tar.test.js +228 -0
  32. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +180 -0
  33. package/package.json +2 -2
@@ -0,0 +1,81 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.12.8",
4
+ "date": "2026-05-23",
5
+ "headline": "`b.archive.tar` + `b.archive.read.tar` — POSIX pax tar format end-to-end + `b.guardArchive.tarEntryPolicy` + `b.backup` tar bundle default",
6
+ "summary": "Tar lands as the second format in the archive family. `b.archive.tar()` builds POSIX pax archives (ustar magic + pax extended headers for >100-char names, >8 GiB sizes, nanosecond mtime); `b.archive.read.tar(adapter)` walks the 512-byte block sequence with the same bomb-cap + path-traversal + entry-type defenses that ZIP read shipped at v0.12.7. Tar's natively-streamable shape means `b.archive.adapters.trustedStream(readable)` is a first-class extract path here (no CD-walk required since tar has no central directory; sequential header-by-header is the canonical adversarial-safe path). `b.guardArchive.tarEntryPolicy` ships as the tar-specific entry-shape policy beyond `entryTypePolicy` — handles typeflag 0/5 (regular/directory) by default, refuses 1/2 (hardlink/symlink) unless `allowDangerous` is set with the realpath-on-link-target dual-check, and refuses 3/4/6/7 (char-device/block-device/FIFO/contiguous-file) unconditionally. `b.backup.bundleAdapterStorage({ format: \"tar\" })` becomes the default for new bundles — directory-tree format stays available via `format: \"directory\"` for back-compat with v0.12.7 bundles. `b.backup.migrate(from, to)` one-shot helper converts v0.12.7 directory bundles to v0.12.8 tar bundles transparently. `b.safeArchive.extract({ source, destination, format: \"auto\" })` now sniffs ustar magic at offset 257 inside the first 512-byte block and dispatches to the tar reader automatically. CVE coverage extends to the tar class: CVE-2026-23745 / 2026-24842 (node-tar symlink+hardlink path resolution), CVE-2025-4517 PATH_MAX TOCTOU (the v0.12.7 dual-check carries through), CVE-2025-11001/11002 (symlink TOCTOU on extract), CVE-2024-12905 / 2025-48387 (tar-fs traversal), CVE-2025-4138/4330 (Python tarfile data filter bypass).",
7
+ "sections": [
8
+ {
9
+ "heading": "Added",
10
+ "items": [
11
+ {
12
+ "title": "`b.archive.tar()` — POSIX pax write builder",
13
+ "body": "Mirrors `b.archive.zip()`'s contract: `addFile(name, content, opts?)` + `addDirectory(name, opts?)` + `toBuffer()` + `toStream(writable)` + `toAdapter(adapter)` + `digest()`. Emits ustar-magic 512-byte header blocks with the standard 11-field prefix (name / mode / uid / gid / size / mtime / chksum / typeflag / linkname / magic / version / uname / gname / devmajor / devminor / prefix). Names >100 chars + sizes >8 GiB + mtime with nanosecond precision get a pax extended header (typeflag=x) preceding the entry; the extended header records (per POSIX.1-2001 §4.18) carry the `path` / `size` / `mtime` / `atime` / `ctime` fields that overflow ustar's fixed widths. Determinism opts: `{ fixedMtime: 0, ignoreOrder: false }` for reproducible builds (matches the ZIP write side)."
14
+ },
15
+ {
16
+ "title": "`b.archive.read.tar(adapter, opts)` — sequential + random-access tar reader",
17
+ "body": "Walks 512-byte header blocks in order. `inspect()` enumerates entries without decompressing; `extract({ destination })` decompresses entry-by-entry with the same bomb-cap + path-traversal + entry-type defenses as ZIP read. Trusted-stream adapters are first-class here — tar has no central directory, so sequential header-by-header walk IS the canonical adversarial-safe path (`b.archive.adapters.trustedStream(readable)` and `b.archive.adapters.fs/buffer/objectStore/http` all flow through the same reader). Per-entry path safety routes through `b.guardFilename.verifyExtractionPath` (the v0.12.7 dual-check). Refuses to overwrite pre-existing destination files (carries the v0.12.7 atomic-rollback contract)."
18
+ },
19
+ {
20
+ "title": "`b.guardArchive.tarEntryPolicy(opts)` — tar-specific entry-type policy",
21
+ "body": "Defaults: typeflag 0 (regular file) + 5 (directory) extract; typeflag 1 (hardlink) + 2 (symlink) refused unless `allowDangerous: { symlinks: true, hardlinks: true }` is set; typeflag 3 (char-device) + 4 (block-device) + 6 (FIFO) + 7 (contiguous-file) refused unconditionally. When `allowDangerous` is set, link target is routed through `b.guardFilename.verifyExtractionPath` against the extraction root — the realpath-on-link-target check defends the CVE-2026-23745 / 24842 node-tar class where the safety check and creation logic diverged on path resolution. Pax extended-header (x) + global-header (g) entries consumed by the reader (merged into the following entry's metadata); operators never see them as standalone entries."
22
+ },
23
+ {
24
+ "title": "`b.backup.bundleAdapterStorage({ format: \"tar\" })` — tar bundle becomes default",
25
+ "body": "New bundles ship as a single tar archive instead of a directory tree. Restore via `b.archive.read.tar` (with the operator-supplied adapter routing the bytes). `format: \"directory\"` opts back into the v0.12.7 layout for operators with existing bundles. `format: \"tar\"` is the new default; `b.backup.diskStorage` stays back-compat at the legacy directory-tree format."
26
+ },
27
+ {
28
+ "title": "`b.backup.migrate(opts)` — directory → tar bundle migration",
29
+ "body": "One-shot helper that walks an operator's directory-tree-format bundle (v0.12.7 layout) and writes the same content as a tar-format bundle via the v0.12.8 bundleAdapterStorage. Idempotent: re-running on an already-migrated bundle is a no-op. Source bundle stays in place until the migrate succeeds; operators with explicit transition windows pass `{ deleteSourceOnSuccess: true }` to opt into the inline replace."
30
+ },
31
+ {
32
+ "title": "`b.safeArchive.extract({ format: \"auto\" })` recognizes tar",
33
+ "body": "Format auto-sniff now dispatches `ustar` magic at offset 257 inside the first 512-byte header block to the tar reader. ZIP magic + tar magic + GZIP magic (v0.12.9) live in the same sniff path; operators with mixed-format pipelines pass `format: \"auto\"` once + the orchestrator picks the right reader."
34
+ }
35
+ ]
36
+ },
37
+ {
38
+ "heading": "Security",
39
+ "items": [
40
+ {
41
+ "title": "Symlink + hardlink path resolution (CVE-2026-23745 / CVE-2026-24842 node-tar class)",
42
+ "body": "node-tar < 7.5.7 / ≤ 7.5.2 shipped a divergence between its hardlink safety check (which used one path resolution) and its hardlink creation logic (which used another). When `allowDangerous: { hardlinks: true }` is set, blamejs routes the link target through `b.guardFilename.verifyExtractionPath` — the SAME primitive that the eventual `link()` call resolves against — so check + create agree by construction. Symlink targets same shape."
43
+ },
44
+ {
45
+ "title": "Path traversal (CVE-2024-12905 / CVE-2025-48387 tar-fs + CVE-2025-4138 / 4330 Python tarfile data filter bypass)",
46
+ "body": "Every entry name passes through `b.guardFilename.verifyExtractionPath` — the v0.12.7 dual-check that refuses pre-resolve names > PATH_MAX (4096 bytes) AND verifies the string-normalize + `fs.realpath` resolutions agree on the same final path. Defends the CVE-2025-4517 / 4138 / 4330 class where the operator's path resolution and the kernel's diverge silently past PATH_MAX."
47
+ },
48
+ {
49
+ "title": "Symlink TOCTOU on extract (CVE-2025-11001 / CVE-2025-11002 7-Zip class)",
50
+ "body": "When `allowDangerous: { symlinks: true }` opts symlinks in, the reader resolves the link target via `verifyExtractionPath` against the extraction root BEFORE calling `fs.symlink` — so the resolved target is inside the trust boundary by construction. The v0.12.7 atomic-rollback contract carries through: any single entry failure aborts the whole extract + cleans up only newly-created files (pre-existing destination files refused at the pre-write check)."
51
+ }
52
+ ]
53
+ },
54
+ {
55
+ "heading": "Detectors",
56
+ "items": [
57
+ {
58
+ "title": "`tar-extract-allow-dangerous-without-link-target-check`",
59
+ "body": "Flags any `b.archive.read.tar(adapter).extract({ allowDangerous: ... })` call site in `lib/` that doesn't route the link target through `b.guardFilename.verifyExtractionPath` against the extraction root. Forces the dual-check discipline at every allow-dangerous opt-in — operators with hardlink / symlink extract needs see the realpath check at the call site."
60
+ },
61
+ {
62
+ "title": "`tar-entry-typeflag-without-policy`",
63
+ "body": "Flags `lib/archive-tar.js` extract code paths that switch on typeflag without composing `b.guardArchive.tarEntryPolicy` for the type-allowlist decision. Locks the shape: every typeflag dispatch goes through the policy, never inline."
64
+ },
65
+ {
66
+ "title": "`backup-migrate-without-source-preserve`",
67
+ "body": "Flags `b.backup.migrate(opts)` call sites that pass `deleteSourceOnSuccess: true` without an operator-stated justification comment. Default is preserve-source; deletes need an explicit reason."
68
+ }
69
+ ]
70
+ }
71
+ ],
72
+ "references": [
73
+ { "label": "POSIX.1-2001 pax extended format (IEEE 1003.1)", "url": "https://pubs.opengroup.org/onlinepubs/9699919799/utilities/pax.html" },
74
+ { "label": "CVE-2026-23745 — node-tar symlink+hardlink path resolution", "url": "https://www.sentinelone.com/vulnerability-database/cve-2026-23745/" },
75
+ { "label": "CVE-2026-24842 — node-tar hardlink path resolution", "url": "https://github.com/advisories/GHSA-34x7-hfp2-rc4v" },
76
+ { "label": "CVE-2025-4517 — Python tarfile PATH_MAX bypass (CVSS 9.4)", "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-4517" },
77
+ { "label": "CVE-2025-4138 / CVE-2025-4330 — Python tarfile data filter", "url": "https://github.com/0xDTC/CVE-2025-4138-4517-POC" },
78
+ { "label": "CVE-2025-11001 / CVE-2025-11002 — 7-Zip symlink TOCTOU on extract", "url": "https://www.sentinelone.com/vulnerability-database/cve-2025-11001/" },
79
+ { "label": "CVE-2024-12905 / CVE-2025-48387 — node-tar-fs path traversal", "url": "https://vulert.com/vuln-db/debian-11-node-tar-fs-193050" }
80
+ ]
81
+ }
@@ -0,0 +1,61 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.12.9",
4
+ "date": "2026-05-23",
5
+ "headline": "`b.archive.gz` + `b.archive.read.gz` — gzip composition with `b.safeDecompress` bomb caps + `b.backup` `tar.gz` bundle format + `sha-to-tag verify` fetches `origin/main`",
6
+ "summary": "gzip lands as the composition layer over the archive family. `b.archive.gz(bytes)` produces an RFC 1952 gzip stream with the same `toBuffer()` / `toAdapter(adapter)` / `digest()` shape every archive builder ships, and `b.archive.read.gz(adapter, opts)` reads it back through `b.safeDecompress` so a malicious `tar.gz` fails the gzip-layer bomb cap (1 GiB output / 100× ratio defaults) before the tar walker ever sees a decompressed byte. The reader exposes `toBuffer()` / `asTar(opts)` / `asZip(opts)` so operators can hand the decompressed bytes directly to a downstream archive reader without a round-trip through disk. `b.archive.tar().toGzip(adapter, opts)` is the write-side convenience for the most common combination. `b.backup.bundleAdapterStorage({ format: \"tar.gz\" })` adds gzip compression on the wire — bundle sizes drop ~3-5× on text-heavy backups (databases, JSON exports, mail spools); the readback path detects the format from the storage key suffix and composes `b.safeDecompress` automatically. The `sha-to-tag verify` workflow now explicitly fetches `origin/main` before walking the first-parent history, fixing a stale-ref bug that silently failed v0.12.6 through v0.12.8 tag verifications (the publish workflow itself was unaffected; the gate is independent).",
7
+ "sections": [
8
+ {
9
+ "heading": "Added",
10
+ "items": [
11
+ {
12
+ "title": "`b.archive.gz(bytes)` — standalone gzip write builder",
13
+ "body": "RFC 1952 gzip envelope with the standard archive-builder shape. `toBuffer()` returns the compressed bytes; `toAdapter(adapter)` writes through any writable adapter (fs / object-store / http) that exposes `.write(bytes)` + optional `.close()`; `digest()` returns a SHA3-512 hex hash of the compressed payload for operator integrity logs. `opts.level` accepts 0-9 (zlib default 6). Composes cleanly under `b.archive.tar().toGzip(adapter)` / `b.archive.zip()` for tar.gz / zip.gz convenience."
14
+ },
15
+ {
16
+ "title": "`b.archive.read.gz(adapter, opts)` — gunzip reader with `b.safeDecompress` bomb caps",
17
+ "body": "Every decompression routes through `b.safeDecompress({ algorithm: \"gzip\", maxOutputBytes, maxRatio })` so a hostile gzip stream fails the bomb gate before any downstream parsing happens. Defaults: `maxDecompressedBytes` = 1 GiB, `maxExpansionRatio` = 100×. The reader exposes three downstream entry points: `toBuffer()` returns the raw decompressed bytes; `asTar(opts)` returns a `b.archive.read.tar` reader over the decompressed payload; `asZip(opts)` returns a `b.archive.read.zip` reader. `fromGzip` is the documented alias the spec uses (operators may reach for either). Refuses non-gzip input upfront via the `0x1f 0x8b` magic check (`archive-gz/bad-magic`)."
18
+ },
19
+ {
20
+ "title": "`b.archive.tar().toGzip(adapter, opts)` — tar.gz write convenience",
21
+ "body": "Pipes the tar builder's `toBuffer()` through `b.archive.gz()` and writes the resulting gzip envelope to a writable adapter. Equivalent to `b.archive.gz(t.toBuffer()).toAdapter(adapter)` but lets the operator stay in the tar-builder fluent chain when composing under fs / object-store / http adapters."
22
+ },
23
+ {
24
+ "title": "`b.backup.bundleAdapterStorage({ format: \"tar.gz\" })` — compressed-on-the-wire bundles",
25
+ "body": "Adds gzip compression to the v0.12.8 tar bundle format. Bundle sizes drop ~3-5× on text-heavy backups (databases, JSON exports, mail spools); binary-heavy backups (compressed databases, encrypted archives) see ~1.0-1.1×. Read paths auto-detect via the `<bundleId>/bundle.tar.gz` storage key suffix and route through `b.safeDecompress` on readback. The v0.12.8 `maxBundleBytes` cap continues to gate against pathological projected-uncompressed sizes; `tar.gz` does not bypass it."
26
+ },
27
+ {
28
+ "title": "`b.safeArchive.extract({ format: \"tar.gz\" })` — explicit tar.gz dispatch",
29
+ "body": "Operators handed a `.tar.gz` upload pass `format: \"tar.gz\"` explicitly; the orchestrator composes `b.archive.read.gz` → `.asTar()` and feeds the standard tar bomb-policy + entry-type-policy + guardProfile through. Defer-with-condition: auto-sniff for tar.gz (peek inside the gzip envelope for ustar magic at offset 257 of the decompressed prefix) lands when operator demand surfaces; today operators with `auto` mode on a `.tar.gz` payload get `format-unsupported gzip` with the explicit-format hint in the error message."
30
+ }
31
+ ]
32
+ },
33
+ {
34
+ "heading": "Security",
35
+ "items": [
36
+ {
37
+ "title": "Bomb caps ride at the gz layer, not the tar/zip layer",
38
+ "body": "The decompression gate is enforced BEFORE the downstream archive reader sees any bytes — a hostile `tar.gz` that would decompress to 10 GiB of zero-filled tar entries fails the 1 GiB `maxDecompressedBytes` default cap during gunzip, never reaching the tar walker. Operators with legitimately large compressed archives pass `maxDecompressedBytes` higher; the framework refuses without an explicit opt-in. RFC 1952 §2.3.1 magic enforcement prevents content-type confusion (gzip-pretending-to-be-something-else inputs)."
39
+ }
40
+ ]
41
+ },
42
+ {
43
+ "heading": "Fixed",
44
+ "items": [
45
+ {
46
+ "title": "`sha-to-tag verify` workflow fetches `origin/main` before first-parent walk",
47
+ "body": "The release-tag integrity gate runs on every `v*` tag push and verifies the tag's commit SHA appears on `main`'s first-parent history. `actions/checkout` was being asked for full history of the tag ref alone — `origin/main` wasn't fetched as a side effect, so `git rev-list --first-parent origin/main | grep -qx \"$SHA\"` walked a stale (or absent) ref and falsely refused. The check now explicitly fetches `origin/main` after checkout so the walk sees the current squash-merge HEAD. Affected releases (v0.12.6 / v0.12.7 / v0.12.8) had publish workflows that completed normally — `sha-to-tag verify` is an independent gate that was silently failing alongside successful publishes; nothing about the published artifacts was wrong."
48
+ }
49
+ ]
50
+ },
51
+ {
52
+ "heading": "Detectors",
53
+ "items": [
54
+ {
55
+ "title": "`archive-gz-without-safedecompress` — direct `node:zlib` gunzip in `lib/` must compose `b.safeDecompress`",
56
+ "body": "Mirrors the v0.11.5 must-compose pattern: any `lib/` call to `zlib.gunzipSync` / `zlib.createGunzip` / `gunzip` outside `lib/archive-gz.js` (which IS the canonical gunzip site, with `b.safeDecompress` wired in) must carry an `allow:archive-gz-without-safedecompress` marker explaining why the bomb gate is bypassed. The detector locks the contract so v0.13+ work that touches a gzip-handling primitive can't quietly drop the cap."
57
+ }
58
+ ]
59
+ }
60
+ ]
61
+ }
@@ -0,0 +1,159 @@
1
+ "use strict";
2
+ /**
3
+ * Layer 0 — b.archive.gz write + b.archive.read.gz read +
4
+ * tar.gz composition + bundleAdapterStorage tar.gz format.
5
+ *
6
+ * Coverage: round-trip standalone gzip, safeDecompress bomb caps,
7
+ * magic refusal, asTar/asZip composition, backup tar.gz end-to-end.
8
+ */
9
+
10
+ var fs = require("node:fs");
11
+ var path = require("node:path");
12
+ var os = require("node:os");
13
+ var b = require("../../index");
14
+ var helpers = require("../helpers");
15
+ var check = helpers.check;
16
+
17
+ async function testGzRoundTrip() {
18
+ var src = Buffer.from("hello world ".repeat(100));
19
+ var compressed = b.archive.gz(src).toBuffer();
20
+ check("archive.gz: compressed shorter than source",
21
+ compressed.length < src.length);
22
+ check("archive.gz: gzip magic 0x1f 0x8b",
23
+ compressed[0] === 0x1f && compressed[1] === 0x8b);
24
+ var reader = b.archive.read.gz(b.archive.adapters.buffer(compressed));
25
+ var roundTrip = await reader.toBuffer();
26
+ check("archive.read.gz: round-trip preserves bytes",
27
+ roundTrip.equals(src));
28
+ }
29
+
30
+ async function testGzBadMagicRefused() {
31
+ var notGzip = Buffer.from("this is not gzipped content at all");
32
+ var refused = null;
33
+ try {
34
+ var reader = b.archive.read.gz(b.archive.adapters.buffer(notGzip));
35
+ await reader.toBuffer();
36
+ } catch (e) { refused = e; }
37
+ check("archive.read.gz: non-gzip input refused with typed error",
38
+ refused && /bad-magic/.test(refused.code || refused.message));
39
+ check("archive.read.gz: refusal is a b.archive.ArchiveGzError",
40
+ refused instanceof b.archive.ArchiveGzError);
41
+ }
42
+
43
+ async function testGzBombCapRefused() {
44
+ // Build a 64 KiB gzip stream of all-zeros (high compression ratio).
45
+ // Cap the decompressed size to 16 KiB → must refuse.
46
+ var src = Buffer.alloc(64 * 1024);
47
+ var compressed = b.archive.gz(src).toBuffer();
48
+ var reader = b.archive.read.gz(b.archive.adapters.buffer(compressed), {
49
+ maxDecompressedBytes: 16 * 1024,
50
+ });
51
+ var refused = null;
52
+ try { await reader.toBuffer(); } catch (e) { refused = e; }
53
+ check("archive.read.gz: maxDecompressedBytes cap refuses bomb",
54
+ refused !== null);
55
+ }
56
+
57
+ async function testTarToGzip() {
58
+ var t = b.archive.tar();
59
+ t.addFile("payload.txt", "hello world");
60
+ // toAdapter expects an archive-adapter shape: write(bytes) + close().
61
+ var chunks = [];
62
+ var adapter = {
63
+ write: async function (bytes) { chunks.push(bytes); },
64
+ close: async function () { /* noop */ },
65
+ };
66
+ await t.toGzip(adapter);
67
+ var assembled = Buffer.concat(chunks);
68
+ check("archive.tar().toGzip: produces gzip magic",
69
+ assembled[0] === 0x1f && assembled[1] === 0x8b);
70
+ // Round-trip through read.gz.asTar
71
+ var reader = b.archive.read.gz(b.archive.adapters.buffer(assembled));
72
+ var tarReader = reader.asTar();
73
+ var dest = fs.mkdtempSync(path.join(os.tmpdir(), "bjs-tgz-"));
74
+ try {
75
+ await tarReader.extract({ destination: dest });
76
+ check("archive.tar().toGzip → read.gz.asTar.extract: file restored",
77
+ fs.readFileSync(path.join(dest, "payload.txt"), "utf-8") === "hello world");
78
+ } finally {
79
+ try { fs.rmSync(dest, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
80
+ }
81
+ }
82
+
83
+ async function testBackupTarGzHighRatioRoundTrip() {
84
+ // Codex P1 on v0.12.9 PR #160 — a zero-filled file compresses
85
+ // at >100× ratio; the default safeDecompress ratio cap was
86
+ // refusing legitimate self-authored bundles on read.
87
+ var src = fs.mkdtempSync(path.join(os.tmpdir(), "bjs-tgzr-src-"));
88
+ var dest = fs.mkdtempSync(path.join(os.tmpdir(), "bjs-tgzr-dest-"));
89
+ var verify = path.join(os.tmpdir(), "bjs-tgzr-verify-" + Date.now());
90
+ try {
91
+ // 1 MiB of zeros — gzip should compress this to a few hundred
92
+ // bytes (ratio ~ 5000×), well past the 100× default.
93
+ fs.writeFileSync(path.join(src, "zeros.bin"), Buffer.alloc(1024 * 1024));
94
+ var storage = b.backup.bundleAdapterStorage({
95
+ adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: dest }),
96
+ format: "tar.gz",
97
+ });
98
+ var bundleId = "2026-05-23T17-30-00-000Z-11223344";
99
+ await storage.writeBundle(bundleId, src);
100
+ await storage.readBundle(bundleId, verify);
101
+ check("backup tar.gz: high-ratio bundle restores past the 100× default",
102
+ fs.readFileSync(path.join(verify, "zeros.bin")).length === 1024 * 1024);
103
+ } finally {
104
+ try { fs.rmSync(src, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
105
+ try { fs.rmSync(dest, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
106
+ try { fs.rmSync(verify, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
107
+ }
108
+ }
109
+
110
+ async function testBackupTarGzRoundTrip() {
111
+ var src = fs.mkdtempSync(path.join(os.tmpdir(), "bjs-tgz-src-"));
112
+ var dest = fs.mkdtempSync(path.join(os.tmpdir(), "bjs-tgz-dest-"));
113
+ var verify = path.join(os.tmpdir(), "bjs-tgz-verify-" + Date.now());
114
+ try {
115
+ fs.writeFileSync(path.join(src, "a.txt"), "hello world ".repeat(100));
116
+ fs.writeFileSync(path.join(src, "b.txt"), "goodbye ".repeat(50));
117
+ var storage = b.backup.bundleAdapterStorage({
118
+ adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: dest }),
119
+ format: "tar.gz",
120
+ });
121
+ var bundleId = "2026-05-23T17-00-00-000Z-aabbccdd";
122
+ await storage.writeBundle(bundleId, src);
123
+ var bundleDir = path.join(dest, bundleId);
124
+ check("backup tar.gz: bundle.tar.gz key written",
125
+ fs.existsSync(path.join(bundleDir, "bundle.tar.gz")));
126
+ var gzBytes = fs.readFileSync(path.join(bundleDir, "bundle.tar.gz"));
127
+ check("backup tar.gz: payload carries gzip magic",
128
+ gzBytes[0] === 0x1f && gzBytes[1] === 0x8b);
129
+ check("backup tar.gz: hasBundle true for tar.gz format",
130
+ await storage.hasBundle(bundleId));
131
+ await storage.readBundle(bundleId, verify);
132
+ check("backup tar.gz: a.txt round-trips",
133
+ fs.readFileSync(path.join(verify, "a.txt"), "utf-8") === "hello world ".repeat(100));
134
+ check("backup tar.gz: b.txt round-trips",
135
+ fs.readFileSync(path.join(verify, "b.txt"), "utf-8") === "goodbye ".repeat(50));
136
+ } finally {
137
+ try { fs.rmSync(src, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
138
+ try { fs.rmSync(dest, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
139
+ try { fs.rmSync(verify, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
140
+ }
141
+ }
142
+
143
+ async function run() {
144
+ await testGzRoundTrip();
145
+ await testGzBadMagicRefused();
146
+ await testGzBombCapRefused();
147
+ await testTarToGzip();
148
+ await testBackupTarGzRoundTrip();
149
+ await testBackupTarGzHighRatioRoundTrip();
150
+ }
151
+
152
+ module.exports = { run: run };
153
+
154
+ if (require.main === module) {
155
+ run().then(
156
+ function () { console.log("[archive-gz] OK — " + helpers.getChecks() + " checks passed"); },
157
+ function (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); }
158
+ );
159
+ }
@@ -0,0 +1,247 @@
1
+ "use strict";
2
+ /**
3
+ * Layer 0 — b.archive read substrate + safe-extract orchestrator +
4
+ * guard-archive policy builders + b.guardFilename.verifyExtractionPath
5
+ * + b.backup.bundleAdapterStorage.
6
+ *
7
+ * Round-trip coverage: write a ZIP via the existing write side, read
8
+ * it back via b.archive.read.zip (random-access buffer adapter), extract
9
+ * via b.safeArchive.extract into a quarantine directory, verify file
10
+ * contents. Refusal coverage: zip-slip entry name, oversize entries,
11
+ * NUL byte, PATH_MAX overflow.
12
+ */
13
+
14
+ var helpers = require("../helpers");
15
+ var check = helpers.check;
16
+ var b = helpers.b;
17
+ var os = require("node:os");
18
+ var path = require("node:path");
19
+ var fs = require("node:fs");
20
+
21
+ async function testRoundTripExtract() {
22
+ var z = b.archive.zip();
23
+ z.addFile("readme.txt", "Hello, archive-read!\n");
24
+ z.addFile("data/numbers.csv", "n,sq\n1,1\n2,4\n3,9\n");
25
+ z.addFile("docs/nested/deep.txt", Buffer.from("payload\n"));
26
+ var bytes = z.toBuffer();
27
+
28
+ // Inspect via buffer adapter — verifies adapter contract + EOCD walk +
29
+ // CD-walk + LFH/CD skew check.
30
+ var reader = b.archive.read.zip(b.archive.adapters.buffer(bytes));
31
+ var entries = await reader.inspect();
32
+ check("archive.read.zip.inspect: 3 entries", entries.length === 3);
33
+ check("archive.read.zip.inspect: name + size", entries[0].name === "readme.txt" && entries[0].size === 21);
34
+ check("archive.read.zip.inspect: nested name", entries[2].name === "docs/nested/deep.txt");
35
+
36
+ // Extract via safeArchive orchestrator.
37
+ var dest = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-archive-test-"));
38
+ try {
39
+ var result = await b.safeArchive.extract({
40
+ source: bytes,
41
+ destination: dest,
42
+ guardProfile: "balanced",
43
+ });
44
+ check("safeArchive.extract: 3 entries written", result.entries.length === 3);
45
+ check("safeArchive.extract: format=zip", result.format === "zip");
46
+ var readme = fs.readFileSync(path.join(dest, "readme.txt"), "utf8");
47
+ check("safeArchive.extract: readme contents match", readme === "Hello, archive-read!\n");
48
+ var nested = fs.readFileSync(path.join(dest, "docs/nested/deep.txt"), "utf8");
49
+ check("safeArchive.extract: nested file restored", nested === "payload\n");
50
+ } finally {
51
+ fs.rmSync(dest, { recursive: true, force: true });
52
+ }
53
+ }
54
+
55
+ function testSafeArchiveErrorClass() {
56
+ // Sanity-check that the typed-error class is exported + an instance
57
+ // can be constructed with a code + message.
58
+ var err = new b.safeArchive.SafeArchiveError("safe-archive/test", "test instance");
59
+ check("SafeArchiveError: code carried", err.code === "safe-archive/test");
60
+ check("SafeArchiveError: is Error subclass", err instanceof Error);
61
+ }
62
+
63
+ async function testSafeArchiveInspect() {
64
+ var z = b.archive.zip();
65
+ z.addFile("a.txt", "alpha");
66
+ z.addFile("b.txt", "beta");
67
+ var bytes = z.toBuffer();
68
+ var summary = await b.safeArchive.inspect({ source: bytes });
69
+ check("safeArchive.inspect: format=zip", summary.format === "zip");
70
+ check("safeArchive.inspect: 2 entries", summary.entries.length === 2);
71
+ check("safeArchive.inspect: totalUncompressedBytes", summary.totalUncompressedBytes === 9);
72
+ }
73
+
74
+ async function testZipBombPolicy() {
75
+ var policy = b.guardArchive.zipBombPolicy({
76
+ maxTotalDecompressedBytes: 8,
77
+ maxExpansionRatio: 100,
78
+ });
79
+ check("zipBombPolicy: maxTotalDecompressedBytes carries", policy.maxTotalDecompressedBytes === 8);
80
+ check("zipBombPolicy: defaults applied", policy.maxEntries === 65535);
81
+ // 9-byte archive payload exceeds the 8-byte total cap.
82
+ var z = b.archive.zip();
83
+ z.addFile("big.txt", "123456789");
84
+ var bytes = z.toBuffer();
85
+ var refused = null;
86
+ try {
87
+ var reader = b.archive.read.zip(b.archive.adapters.buffer(bytes), {
88
+ bombPolicy: policy,
89
+ });
90
+ await reader.inspect();
91
+ } catch (e) { refused = e; }
92
+ check("bombPolicy: maxTotalDecompressedBytes trips",
93
+ refused && /total-too-large|entry-too-large/.test(refused.code || refused.message));
94
+ }
95
+
96
+ function testEntryTypePolicy() {
97
+ var p = b.guardArchive.entryTypePolicy({ symlinks: true });
98
+ check("entryTypePolicy: symlinks opted in", p.symlinks === true);
99
+ check("entryTypePolicy: hardlinks default off", p.hardlinks === false);
100
+ check("entryTypePolicy: devices default off", p.devices === false);
101
+ }
102
+
103
+ function testVerifyExtractionPathHappy() {
104
+ var dest = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-verifyx-"));
105
+ try {
106
+ var resolved = b.guardFilename.verifyExtractionPath("docs/readme.txt", dest);
107
+ check("verifyExtractionPath: ok resolved", resolved.indexOf(dest) === 0);
108
+ } finally {
109
+ fs.rmSync(dest, { recursive: true, force: true });
110
+ }
111
+ }
112
+
113
+ function testVerifyExtractionPathRefusals() {
114
+ var refusals = 0;
115
+ function r(name, root) {
116
+ try { b.guardFilename.verifyExtractionPath(name, root); }
117
+ catch (_e) { refusals += 1; }
118
+ }
119
+ r("../etc/passwd", "/tmp"); // zip slip
120
+ r("/etc/passwd", "/tmp"); // absolute
121
+ r("docs/../../etc/passwd", "/tmp"); // mid-segment ..
122
+ // PATH_MAX overflow (4097 chars).
123
+ var oversize = new Array(4098).join("a");
124
+ r(oversize, "/tmp");
125
+ check("verifyExtractionPath: 4 refusals", refusals === 4);
126
+ }
127
+
128
+ async function testExtractRefusesOverwrite() {
129
+ // Codex P1 on v0.12.7 PR #158 — the catch-block cleanup deleted
130
+ // PRE-EXISTING destination files on abort because the rename-onto-
131
+ // canonical-path path overwrote them first, then `written[].path`
132
+ // got rm'd. Fix is to refuse overwrite up front; this regression
133
+ // test verifies the new refusal fires + that the pre-existing file
134
+ // is left untouched.
135
+ var z = b.archive.zip();
136
+ z.addFile("readme.txt", "from-archive\n");
137
+ var bytes = z.toBuffer();
138
+ var dest = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-overwrite-test-"));
139
+ try {
140
+ var collidePath = path.join(dest, "readme.txt");
141
+ fs.writeFileSync(collidePath, "operator's pre-existing file\n");
142
+ var refused = null;
143
+ try {
144
+ await b.safeArchive.extract({ source: bytes, destination: dest });
145
+ } catch (e) { refused = e; }
146
+ check("extract: refuses pre-existing destination file",
147
+ refused && /destination-exists/.test(refused.code || refused.message));
148
+ var stillThere = fs.readFileSync(collidePath, "utf8");
149
+ check("extract: pre-existing file untouched on refusal",
150
+ stillThere === "operator's pre-existing file\n");
151
+ } finally {
152
+ fs.rmSync(dest, { recursive: true, force: true });
153
+ }
154
+ }
155
+
156
+ async function testSafeArchiveRefusesTrustedStreamSource() {
157
+ // Codex P2 on v0.12.7 PR #158 — safeArchive.extract accepted
158
+ // trusted-stream adapters via the input-shape validator but the
159
+ // implementation called the random-access reader, which threw the
160
+ // wrong-entry-point error. Fix is to refuse trusted-stream sources
161
+ // upfront with a typed safe-archive code.
162
+ var nodeStream = require("node:stream");
163
+ var fakeReadable = new nodeStream.Readable({ read: function () {} });
164
+ var adapter = b.archive.adapters.trustedStream(fakeReadable);
165
+ var dest = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-trusted-refusal-"));
166
+ try {
167
+ var refused = null;
168
+ try {
169
+ await b.safeArchive.extract({
170
+ source: adapter,
171
+ destination: dest,
172
+ });
173
+ } catch (e) { refused = e; }
174
+ check("safeArchive.extract: trusted-stream upfront refused",
175
+ refused && /trusted-stream-unsupported/.test(refused.code || refused.message));
176
+ } finally {
177
+ fakeReadable.destroy();
178
+ fs.rmSync(dest, { recursive: true, force: true });
179
+ }
180
+ }
181
+
182
+ async function testGuardArchiveInspect() {
183
+ var z = b.archive.zip();
184
+ z.addFile("safe.txt", "safe");
185
+ var bytes = z.toBuffer();
186
+ var summary = await b.guardArchive.inspect(b.archive.adapters.buffer(bytes), {
187
+ profile: "balanced",
188
+ });
189
+ check("guardArchive.inspect: 1 entry", summary.entries.length === 1);
190
+ check("guardArchive.inspect: issues array", Array.isArray(summary.issues));
191
+ }
192
+
193
+ async function testBundleAdapterStorageRoundTrip() {
194
+ var rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-backup-adapter-root-"));
195
+ var srcDir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-backup-adapter-src-"));
196
+ var destDir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-backup-adapter-dest-"));
197
+ try {
198
+ // Populate a small bundle source.
199
+ fs.writeFileSync(path.join(srcDir, "manifest.json"), JSON.stringify({ v: 1 }));
200
+ fs.mkdirSync(path.join(srcDir, "files"));
201
+ fs.writeFileSync(path.join(srcDir, "files", "blob.bin"), Buffer.from([1, 2, 3]));
202
+
203
+ var storage = b.backup.bundleAdapterStorage({
204
+ adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: rootDir }),
205
+ });
206
+ check("bundleAdapterStorage: name=adapter", storage.name === "adapter");
207
+
208
+ var bundleId = "2026-05-23T07-00-00-000Z-deadbeef";
209
+ await storage.writeBundle(bundleId, srcDir);
210
+ check("bundleAdapterStorage: hasBundle after write", await storage.hasBundle(bundleId));
211
+ fs.rmSync(destDir, { recursive: true });
212
+ await storage.readBundle(bundleId, destDir);
213
+ var restored = fs.readFileSync(path.join(destDir, "files", "blob.bin"));
214
+ check("bundleAdapterStorage: roundtrip bytes match", restored[0] === 1 && restored.length === 3);
215
+ var list = await storage.listBundles();
216
+ check("bundleAdapterStorage: listBundles returns the bundle", list.length === 1 && list[0].bundleId === bundleId);
217
+ await storage.deleteBundle(bundleId);
218
+ check("bundleAdapterStorage: hasBundle false after delete", !(await storage.hasBundle(bundleId)));
219
+ } finally {
220
+ try { fs.rmSync(rootDir, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
221
+ try { fs.rmSync(srcDir, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
222
+ try { fs.rmSync(destDir, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
223
+ }
224
+ }
225
+
226
+ async function run() {
227
+ await testRoundTripExtract();
228
+ testSafeArchiveErrorClass();
229
+ await testSafeArchiveInspect();
230
+ await testZipBombPolicy();
231
+ testEntryTypePolicy();
232
+ testVerifyExtractionPathHappy();
233
+ testVerifyExtractionPathRefusals();
234
+ await testExtractRefusesOverwrite();
235
+ await testSafeArchiveRefusesTrustedStreamSource();
236
+ await testGuardArchiveInspect();
237
+ await testBundleAdapterStorageRoundTrip();
238
+ }
239
+
240
+ module.exports = { run: run };
241
+
242
+ if (require.main === module) {
243
+ run().then(
244
+ function () { console.log("[archive-read] OK — " + helpers.getChecks() + " checks passed"); },
245
+ function (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); }
246
+ );
247
+ }