@blamejs/core 0.12.8 → 0.12.10

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 CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.12.x
10
10
 
11
+ - v0.12.10 (2026-05-23) — **`b.archive.wrap` + `b.archive.unwrap` — recipient-encrypted archive envelopes (Flavor 1) + `b.backup` `cryptoStrategy: "recipient"` + HIPAA/PCI-DSS posture refusal.** Flavor 1 lands as the whole-archive recipient-wrap substrate. `b.archive.wrap(bytes, { recipient })` produces a sealed envelope under the framework's hybrid PQC seal (ML-KEM-1024 + P-384 ECDH + SHAKE256 + XChaCha20-Poly1305) prefixed with a 6-byte `BAWRP` archive-wrap header so format sniffers can identify wrap envelopes without trial decryption. `b.archive.unwrap(sealed, { recipient })` is the inverse with magic-check upfront so non-envelope inputs throw `archive-wrap/bad-magic` rather than a crypto-level error. Recipient strategies: static keypair (`{ publicKey, ecPublicKey }`) and peer-cert (`{ peerCertDer, peerKemPubkey }`); the tenant strategy lands in v0.12.11 alongside the backup-crypto refactor + per-tenant key resolution. `b.backup.bundleAdapterStorage({ cryptoStrategy: "recipient", recipient })` composes the wrap/unwrap layer transparently: the bytes hitting the adapter's `writeFile` are a `BAWRP`-prefixed envelope, never the raw tar / tar.gz / directory bundle. HIPAA + PCI-DSS postures refuse `cryptoStrategy: "none"` upfront with `backup/posture-requires-encryption` — the storage adapter cannot itself satisfy the encryption-at-rest requirement; the recipient envelope is the framework-side gate. Flavor 2 (per-entry ZIP wrap with the 0xBADC extra-field marker) and the backup-crypto refactor into `lib/_crypto-base.js` ship in v0.12.11. **Added:** *`b.archive.wrap(bytes, { recipient })` — recipient-encrypted archive envelope* — Composes `b.crypto.encrypt` (or `b.crypto.encryptEnvelopeAsCertPeer` for the peer-cert strategy) under the framework's hybrid PQC seal. The output is a Buffer carrying a 6-byte `BAWRP` archive-wrap header (5-byte magic + 1-byte version) followed by the base64-encoded envelope bytes. Recipient strategies: `{ publicKey, ecPublicKey }` for the static-keypair path (ML-KEM-1024 PEM + P-384 ECDH PEM); `{ peerCertDer, peerKemPubkey }` for the peer-cert path (extracts the P-384 half from the cert per `b.crypto.encryptEnvelopeAsCertPeer`). `"tenant"` returns `archive-wrap/tenant-strategy-deferred` upfront — that strategy lands in v0.12.11 with the per-tenant key resolution. · *`b.archive.unwrap(sealed, { recipient })` — inverse with upfront magic check* — Verifies the 6-byte `BAWRP` header before any cryptographic work so non-envelope inputs (raw archives, other-magic envelopes, truncated buffers) fail with `archive-wrap/bad-magic` / `archive-wrap/bad-version` rather than a downstream `crypto/*` error. Routes through `b.crypto.decrypt(envelope, recipient, { raw: true })` so binary archive payloads (gzip, ZIP, tar) round-trip losslessly — `raw: true` is the contract that preserves bytes vs the default utf-8 decoding. · *`b.backup.bundleAdapterStorage({ cryptoStrategy: "recipient", recipient })` — opt-in envelope storage* — `cryptoStrategy: "none"` (default, v0.12.7-9 behaviour) writes plaintext bundle bytes to the adapter — safe for storage layers that are themselves the protective boundary (S3 SSE, disk-encrypted hosts). `cryptoStrategy: "recipient"` requires `opts.recipient` and wraps every `writeBundle` payload through `b.archive.wrap` before `adapter.writeFile`; `readBundle` unwraps after `adapter.readFile`. The wrap layer sits OUTSIDE the gz / tar layers so the bundle on disk is opaque ciphertext under the operator-controlled recipient key. Passphrase strategy is deferred to v0.12.11 alongside the `_crypto-base.js` refactor. · *HIPAA + PCI-DSS posture refuses plaintext bundles* — `bundleAdapterStorage({ posture: "hipaa" })` (or `"pci-dss"`) refuses `cryptoStrategy: "none"` upfront with `backup/posture-requires-encryption` — adapter-storage's plaintext default cannot itself satisfy encryption-at-rest requirements. Operators under these postures pass `cryptoStrategy: "recipient"` + a recipient key. The refusal message includes the posture name + the strategy that fails so audit-trail operators see exactly which gate blocked the call. **Security:** *Wrap envelope is the framework's hybrid PQC seal — ML-KEM-1024 + P-384 ECDH + SHAKE256 + XChaCha20-Poly1305* — Defence-in-depth posture: a CRQC against ML-KEM-1024 alone still has to defeat the classical P-384 ECDH leg; a future ECDH break alone still has to defeat ML-KEM-1024. The 4-byte envelope header (magic + KEM ID + cipher ID + KDF ID) is bound as AEAD AAD so a header-substitution attack fails Poly1305 verification. `b.archive.wrap` prepends a separate 6-byte archive-wrap header BEFORE the base64 envelope so format sniffing can identify wrap output without trial decryption — non-envelope inputs are refused at byte 0-4 (magic check) instead of after fruitless decapsulation work. **Detectors:** *`backup-adapter-storage-without-posture-check` — postures that mandate encryption must propagate to `cryptoStrategy`* — When a primitive that wires `b.backup.bundleAdapterStorage` carries a `posture:` opt drawn from the HIPAA / PCI-DSS / etc. set, the same code path must propagate `cryptoStrategy: "recipient"` (or refuse before reaching writeBundle). The detector matches `bundleAdapterStorage({ ... posture: ... })` invocations in `lib/` and requires a matching `cryptoStrategy` opt; missing it surfaces during the codebase-patterns gate so a future caller can't silently drop the contract. **Migration:** *Flavor 2 — per-entry ZIP recipient wrap with 0xBADC extra-field* — Per-entry encryption inside the carrier ZIP (method=STORE with the encrypted bytes as the stored payload + a 0xBADC user-defined-range extra-field marker carrying the recipient hint). Inspect-without-decrypt is the operator value: entry list + name-safety gating happens BEFORE any key resolution. Lands in v0.12.11 alongside the backup-crypto refactor. · *`lib/_crypto-base.js` refactor — backup-crypto, Flavor 1, Flavor 2 share substrate* — The legacy per-file Argon2id + XChaCha20-Poly1305 path in `lib/backup/crypto.js` gets factored into a private `_crypto-base.js` helper so all three encryption flavors compose the same primitive set. No operator-visible API change; closes the each-feature-rolls-its-own-crypto smell. · *`cryptoStrategy: "passphrase"` + tenant strategy* — Passphrase strategy on `bundleAdapterStorage` (Argon2id-derived key + XChaCha20-Poly1305) and the `"tenant"` recipient string (composes `b.vault.derivedKey({ tenant, purpose: "archive-wrap" })`) both ship in v0.12.11. The v0.12.10 surface is the recipient substrate; v0.12.11 lights up the per-tenant + passphrase strategies that consume it.
12
+
13
+ - v0.12.9 (2026-05-23) — **`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`.** 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). **Added:** *`b.archive.gz(bytes)` — standalone gzip write builder* — 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. · *`b.archive.read.gz(adapter, opts)` — gunzip reader with `b.safeDecompress` bomb caps* — 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`). · *`b.archive.tar().toGzip(adapter, opts)` — tar.gz write convenience* — 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. · *`b.backup.bundleAdapterStorage({ format: "tar.gz" })` — compressed-on-the-wire bundles* — 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. · *`b.safeArchive.extract({ format: "tar.gz" })` — explicit tar.gz dispatch* — 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. **Fixed:** *`sha-to-tag verify` workflow fetches `origin/main` before first-parent walk* — 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. **Security:** *Bomb caps ride at the gz layer, not the tar/zip layer* — 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). **Detectors:** *`archive-gz-without-safedecompress` — direct `node:zlib` gunzip in `lib/` must compose `b.safeDecompress`* — 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.
14
+
11
15
  - v0.12.8 (2026-05-23) — **`b.archive.tar` + `b.archive.read.tar` — POSIX pax tar format end-to-end + `b.guardArchive.tarEntryPolicy` + `b.backup` tar bundle default.** 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). **Added:** *`b.archive.tar()` — POSIX pax write builder* — 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). · *`b.archive.read.tar(adapter, opts)` — sequential + random-access tar reader* — 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). · *`b.guardArchive.tarEntryPolicy(opts)` — tar-specific entry-type policy* — 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. · *`b.backup.bundleAdapterStorage({ format: "tar" })` — tar bundle becomes default* — 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. · *`b.backup.migrate(opts)` — directory → tar bundle migration* — 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. · *`b.safeArchive.extract({ format: "auto" })` recognizes tar* — 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. **Security:** *Symlink + hardlink path resolution (CVE-2026-23745 / CVE-2026-24842 node-tar class)* — 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. · *Path traversal (CVE-2024-12905 / CVE-2025-48387 tar-fs + CVE-2025-4138 / 4330 Python tarfile data filter bypass)* — 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. · *Symlink TOCTOU on extract (CVE-2025-11001 / CVE-2025-11002 7-Zip class)* — 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). **Detectors:** *`tar-extract-allow-dangerous-without-link-target-check`* — 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. · *`tar-entry-typeflag-without-policy`* — 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. · *`backup-migrate-without-source-preserve`* — 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. **References:** [POSIX.1-2001 pax extended format (IEEE 1003.1)](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/pax.html) · [CVE-2026-23745 — node-tar symlink+hardlink path resolution](https://www.sentinelone.com/vulnerability-database/cve-2026-23745/) · [CVE-2026-24842 — node-tar hardlink path resolution](https://github.com/advisories/GHSA-34x7-hfp2-rc4v) · [CVE-2025-4517 — Python tarfile PATH_MAX bypass (CVSS 9.4)](https://nvd.nist.gov/vuln/detail/CVE-2025-4517) · [CVE-2025-4138 / CVE-2025-4330 — Python tarfile data filter](https://github.com/0xDTC/CVE-2025-4138-4517-POC) · [CVE-2025-11001 / CVE-2025-11002 — 7-Zip symlink TOCTOU on extract](https://www.sentinelone.com/vulnerability-database/cve-2025-11001/) · [CVE-2024-12905 / CVE-2025-48387 — node-tar-fs path traversal](https://vulert.com/vuln-db/debian-11-node-tar-fs-193050)
12
16
 
13
17
  - v0.12.7 (2026-05-23) — **`b.archive.read` — random-access ZIP reader + adapter substrate + `b.safeArchive.extract` orchestrator.** The framework's ZIP primitive grows a read side. `b.archive.read.zip(adapter, opts)` walks the central directory, validates LFH/CD coherence (defeats the malformed-zip / Zip Slip CD-skew class), bounds decompression with operator-declared bomb caps (per-entry size, total bytes, expansion ratio, entry count), and refuses path-traversal + symlink-shaped entries before any byte hits the destination. The adapter contract (`{ size, range(offset, length) }` for random-access; `{ readable }` for the trusted-stream fallback) unifies how operators feed bytes in — local files, `b.objectStore` buckets, HTTP Range fetches, and in-memory buffers all compose the same reader. `b.safeArchive.extract({ source, destination, ... })` ships as the one-liner orchestrator that combines read + guardArchive + path-safety + bomb caps + extract for the common `untar a hostile archive into a quarantine directory` shape. `b.guardArchive` gains `inspect(adapter)` (entry-list enumeration that doesn't decompress), `zipBombPolicy(...)` and `entryTypePolicy(...)` policy-object builders so operators can declare their cap set once + reuse it. `b.guardFilename.verifyExtractionPath(name, root, opts?)` adds the dual-check (string-normalize + `fs.realpath`-agreement) that defends the CVE-2025-4517 PATH_MAX TOCTOU class — refuses paths > 4096 bytes BEFORE the kernel's realpath truncation hits, then verifies the string and fs resolution agree on the same final path. `b.backup` gains `bundleAdapterStorage(adapter, opts)` — the first non-disk transport backend; substrate for the v0.12.8 tar bundle format + v0.12.11 objectStoreStorage. Closes the no-MVP gap from v0.5.15 where `b.archive` shipped write-only and the operator-facing JSDoc explicitly punted reading + extraction to yauzl / `unzip`. **Added:** *`b.archive.read.zip(adapter, opts)` — random-access ZIP reader* — Walks the end-of-central-directory record, validates every CD entry against its LFH (offset / size / CRC / method / name agreement), and emits an iterator of `{ name, size, compressedSize, crc, method, mtime, isEncrypted, externalAttrs, extraFields }` entries. `inspect()` returns the entry list without decompressing — operators wire `b.guardArchive` against the inspect output before paying a single decompress cycle. `extract({ destination, ... })` decompresses entry-by-entry via `node:zlib` raw inflate, routes every path through `b.guardFilename.verifyExtractionPath`, and enforces zip-bomb caps as a streaming abort (the partial extract is fs.rm-ed before the error throws). Composes the existing `lib/archive.js` write-side CRC + signature constants — no duplicated wire-format knowledge. · *Adapter contract — `b.archive.adapters.{fs,objectStore,http,buffer,trustedStream}`* — One shape for source bytes: `{ size: <number>, range(offset, length): Promise<Buffer> }` for random-access, or `{ readable: Readable }` for the explicit trust-stream fallback. `fs(path)` opens a file descriptor + range-reads. `objectStore(client, key)` composes the v0.4.23 `b.objectStore` Range-GET path. `http(url, opts)` composes `b.httpClient` with `Range: bytes=N-M` headers + 206 verification. `buffer(buf)` slices a Buffer in-memory. `trustedStream(readable)` accepts a Node Readable for the rare case the operator can vouch for the source. The same contract feeds `b.safeArchive.extract` and `b.backup.bundleAdapterStorage`. · *`b.safeArchive.extract({ source, destination, ... })` — one-liner safe extraction* — Combines `b.archive.read` + `b.guardArchive` inspect + `b.guardFilename.verifyExtractionPath` + bomb caps + post-extract destination-rebase verification. Refuses the entire archive when any single entry trips a policy (atomic — no half-extracted state on the destination). Operators who want fine-grained control reach for the lower-level primitives directly; `b.safeArchive.extract` covers the 90%-case `extract this hostile-shaped archive into a quarantine directory` workflow. Returns `{ entries: [...], destinationRoot, bytesExtracted, auditTrail }` on success. · *`b.guardArchive.inspect(adapter, opts)` + `zipBombPolicy(...)` + `entryTypePolicy(...)`* — `inspect(adapter)` is the bridge between the read primitive and the existing `validateEntries` gate — runs the read primitive's inspect phase, hands the entry list to `validateEntries`, and returns the merged `{ entries, issues, decisions }` so the caller decides whether to proceed. `zipBombPolicy({ maxEntries, maxEntryDecompressedBytes, maxTotalDecompressedBytes, maxExpansionRatio })` and `entryTypePolicy({ symlinks, hardlinks, devices, fifos, sockets })` are policy-object builders so operators declare the cap set once + reuse across call sites. Each policy carries its own `audit.posture` annotation that propagates through `b.agent.postureChain`. · *`b.guardFilename.verifyExtractionPath(name, root, opts?)` — dual-check path safety* — Companion to the existing `b.guardArchive.checkExtractionPath` (string-only check the gate keeps portable). `verifyExtractionPath` couples to `fs.realpath` deliberately: refuses paths whose pre-resolve string already exceeds 4096 bytes (defends the CVE-2025-4517 PATH_MAX TOCTOU class — `os.path.realpath`-style truncation can't reach the kernel before our refuse fires), then verifies the string-normalized result and the `fs.realpath`-resolved result agree on the same final path. Disagreement throws `guard-filename/extraction-path-toctou`. The string-check stays the canonical portable gate; this primitive is the deeper fs-coupled check the framework's read primitive wires in by default. · *`b.backup.bundleAdapterStorage(adapter, opts)` — adapter-driven storage backend* — First non-disk backend for `b.backup`. Walks the bundle directory file-by-file and writes through the v0.12.7 adapter contract — `fs` adapter behaves identically to `diskStorage` (which stays for back-compat), `buffer` adapter emits the bundle into an in-memory representation, custom adapters can route to anything that satisfies the contract. Substrate for the v0.12.8 tar bundle format (which folds the directory tree into a single tar stream) and the v0.12.11 `objectStoreStorage` (which composes `b.archive.adapters.objectStore` for S3 / MinIO / Azure / GCS-backed backups). Backup manifest layout unchanged — restore code keeps working byte-for-byte against bundles produced by either backend. **Security:** *Zip Slip class (CVE-2025-3445 / 11569 / 23084 / 27210 / 11001 / 11002 / 26960 / 4517 / 4138 / 4330 + 2024 jszip / mholt/archiver / Python tarfile / node-tar / 7-zip)* — Every archive-read entry's name passes through `b.guardFilename.verifyExtractionPath` before any decompression. Path-traversal segments (`..`, leading `/` or `\`, drive-letter prefixes, null bytes, overlong UTF-8) are refused; Windows reserved names + NTFS ADS suffixes are refused; the realpath-agreement check defends the CVE-2025-4517 PATH_MAX TOCTOU class. Symlink and hardlink entries are refused unconditionally under the default `entryTypePolicy`; operators with a legitimate need opt into `allowSymlinks: true` / `allowHardlinks: true` and get the entries routed through an additional `b.guardArchive` realpath-on-target check. · *Decompression-bomb class (CVE-2025-0725, OWASP zip-bomb top-cases)* — `b.archive.read.zip.extract` enforces four caps in parallel: `maxEntries` (entry-count), `maxEntryDecompressedBytes` (per-entry size), `maxTotalDecompressedBytes` (aggregate across the archive), and `maxExpansionRatio` (compressed → decompressed ratio cap, default 100:1). Each cap aborts the extract as soon as the bound is exceeded; the destination directory is `fs.rm`-ed before the error throws so a partial extract never lingers on disk. The `b.safeDecompress` primitive (v0.11.5) is the underlying inflate gate — same defense surface, same audit-trail. · *LFH/CD skew + malformed-ZIP DoS* — The CD walk verifies every entry's local-file-header against the central-directory record (offset / size / CRC / method / name agreement). Mismatches throw `archive/cd-skew` before any byte decompresses. Defends the malformed-zip class where a hostile producer points CD entries at LFH locations that don't match the CD claim — the prior write-only path had no exposure to this class; the new read path closes it. **Detectors:** *`archive-read-without-bomb-caps`* — Flags `b.archive.read.zip(adapter)` call sites in `lib/` that don't pass an explicit `bombPolicy` or `maxTotalDecompressedBytes`. Forces the cap-declaration discipline at call sites — operators see the bomb-cap surface every time they reach for the read primitive. · *`archive-extract-without-guard`* — Flags `b.archive.read.zip(...).extract({ destination, ... })` call sites that don't compose `b.safeArchive.extract` (the orchestrator) AND don't pass an explicit `b.guardArchive.inspect` precheck. Per-file lib/ surface forces operators to either use the orchestrator OR explicitly opt into per-step composition with a written justification. · *`archive-adapter-without-error-path`* — Flags adapter implementations (any export shape matching `{ size, range }` / `{ readable }`) that don't propagate AbortSignal or refuse to throw on partial-read truncation. Forces the cancellation-discipline so a slow / hostile source can't block extraction indefinitely. · *`safe-archive-extract-bypass`* — Flags any composition in `lib/` that builds the safeArchive pipeline (`read.zip(...).extract({ destination, ... })` + bomb caps + guard) inline instead of calling `b.safeArchive.extract`. The orchestrator owns the audit-emission shape; bypassing it means audit gaps. **References:** [APPNOTE.TXT (PKWARE ZIP File Format Specification)](https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT) · [CVE-2025-3445 — mholt/archiver Zip Slip](https://github.com/advisories/GHSA-7vpp-9cxj-q8gv) · [CVE-2025-4517 — Python tarfile PATH_MAX bypass (CVSS 9.4)](https://nvd.nist.gov/vuln/detail/CVE-2025-4517) · [CVE-2025-11001 / CVE-2025-11002 — 7-Zip directory-traversal RCE](https://www.sentinelone.com/vulnerability-database/cve-2025-11001/) · [CVE-2025-11569 — cross-zip directory traversal](https://security.snyk.io/vuln/SNYK-JS-CROSSZIP-6105396) · [CVE-2026-23745 / CVE-2026-24842 — node-tar symlink + hardlink bypass](https://github.com/advisories/GHSA-34x7-hfp2-rc4v) · [OWASP Zip Slip + zip-bomb reference](https://snyk.io/research/zip-slip-vulnerability) · [USENIX WOOT'19 — A better zip bomb (Fifield)](https://www.usenix.org/conference/woot19/presentation/fifield)
@@ -0,0 +1,229 @@
1
+ "use strict";
2
+ /**
3
+ * archive-gz — gzip composition primitives. Sibling of lib/archive.js
4
+ * (ZIP write), lib/archive-read.js (ZIP read), lib/archive-tar.js (tar
5
+ * write), and lib/archive-tar-read.js (tar read). The @module block
6
+ * lives on lib/archive.js; this file declares only @primitive entries
7
+ * under the b.archive namespace (`b.archive.gz` write, `b.archive.read.gz`
8
+ * read). Bomb defenses ride with the read path: every read.gz call
9
+ * composes b.safeDecompress with the framework's default caps (1 GiB
10
+ * output / 100× ratio).
11
+ */
12
+
13
+ var zlib = require("node:zlib");
14
+ var nodeCrypto = require("node:crypto");
15
+ var C = require("./constants");
16
+ var lazyRequire = require("./lazy-require");
17
+ var safeBuffer = require("./safe-buffer");
18
+ var { defineClass } = require("./framework-error");
19
+
20
+ var ArchiveGzError = defineClass("ArchiveGzError", { alwaysPermanent: true });
21
+
22
+ var safeDecompress = lazyRequire(function () { return require("./safe-decompress"); });
23
+ var archiveAdapters = lazyRequire(function () { return require("./archive-adapters"); });
24
+ var archiveRead = lazyRequire(function () { return require("./archive-read"); });
25
+ var archiveTarRead = lazyRequire(function () { return require("./archive-tar-read"); });
26
+
27
+ // gzip magic — RFC 1952 §2.2 ("ID1=0x1f, ID2=0x8b").
28
+ var GZIP_MAGIC_0 = 0x1f; // allow:raw-byte-literal — RFC 1952 §2.2 ID1
29
+ var GZIP_MAGIC_1 = 0x8b; // allow:raw-byte-literal — RFC 1952 §2.2 ID2
30
+
31
+ var DEFAULT_MAX_OUTPUT_BYTES = C.BYTES.gib(1);
32
+ var DEFAULT_MAX_RATIO = 100;
33
+
34
+ function _isGzipMagic(buf) {
35
+ return buf.length >= 2 && buf[0] === GZIP_MAGIC_0 && buf[1] === GZIP_MAGIC_1;
36
+ }
37
+
38
+ /**
39
+ * @primitive b.archive.gz
40
+ * @signature b.archive.gz(bytes, opts?)
41
+ * @since 0.12.9
42
+ * @status stable
43
+ * @related b.archive.read.gz, b.safeDecompress
44
+ *
45
+ * Wrap a buffer in a gzip envelope. Returns a builder with the same
46
+ * write surface as the other `b.archive` builders — `toBuffer()` /
47
+ * `toAdapter(adapter)` / `digest()` — so gzip slots into the same
48
+ * downstream sinks (object-store + filesystem + http adapters).
49
+ *
50
+ * Composes `b.archive.tar().toGzip(adapter)` / `b.archive.zip().
51
+ * toGzip(adapter)` indirectly: those convenience methods call this
52
+ * primitive after materializing their archive bytes.
53
+ *
54
+ * @opts
55
+ * level: number, // 0-9, default 6 (zlib default).
56
+ *
57
+ * @example
58
+ * var compressed = b.archive.gz(Buffer.from("hello world")).toBuffer();
59
+ * // → 31-byte gzip stream
60
+ */
61
+ function gz(bytes, opts) {
62
+ opts = opts || {};
63
+ if (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array)) {
64
+ throw new ArchiveGzError("archive-gz/bad-input",
65
+ "gz: input must be a Buffer or Uint8Array");
66
+ }
67
+ var input = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
68
+ var level = opts.level;
69
+ if (level !== undefined &&
70
+ (typeof level !== "number" || level < 0 || level > 9)) {
71
+ throw new ArchiveGzError("archive-gz/bad-arg",
72
+ "gz: opts.level must be a number 0-9; got " + JSON.stringify(level));
73
+ }
74
+ var compressed = null;
75
+ function _materialize() {
76
+ if (compressed !== null) return compressed;
77
+ var zopts = {};
78
+ if (typeof level === "number") zopts.level = level;
79
+ compressed = zlib.gzipSync(input, zopts);
80
+ return compressed;
81
+ }
82
+ return {
83
+ toBuffer: function () { return _materialize(); },
84
+ toAdapter: async function (adapter) {
85
+ if (!adapter || typeof adapter.write !== "function") {
86
+ throw new ArchiveGzError("archive-gz/bad-adapter",
87
+ "gz.toAdapter: adapter must be writable (no .write method)");
88
+ }
89
+ var buf = _materialize();
90
+ await adapter.write(buf);
91
+ if (typeof adapter.close === "function") await adapter.close();
92
+ },
93
+ digest: function () {
94
+ return nodeCrypto.createHash("sha3-512")
95
+ .update(_materialize())
96
+ .digest("hex");
97
+ },
98
+ get compressedBytes() { return _materialize().length; },
99
+ };
100
+ }
101
+
102
+ /**
103
+ * @primitive b.archive.read.gz
104
+ * @signature b.archive.read.gz(adapter, opts)
105
+ * @since 0.12.9
106
+ * @status stable
107
+ * @related b.archive.gz, b.safeDecompress, b.archive.read.tar, b.archive.read.zip
108
+ *
109
+ * Read a gzip stream from an adapter, surface it as either raw bytes
110
+ * (`toBuffer()`) or as a hand-off to a downstream archive reader
111
+ * (`asTar()` / `asZip()`). Every decompression composes
112
+ * `b.safeDecompress` with framework-default caps — `maxOutputBytes`
113
+ * (1 GiB) and `maxExpansionRatio` (100×) — so a hostile `tar.gz`
114
+ * fails the gz gate before any tar parsing happens.
115
+ *
116
+ * @opts
117
+ * maxDecompressedBytes: number, // default 1 GiB
118
+ * maxExpansionRatio: number, // default 100×
119
+ * audit: object,
120
+ *
121
+ * @example
122
+ * var reader = b.archive.read.gz(b.archive.adapters.fs("./bundle.tar.gz"));
123
+ * var tarReader = reader.asTar();
124
+ * var result = await tarReader.extract({ destination: "./out" });
125
+ */
126
+ function readGz(adapter, opts) {
127
+ opts = opts || {};
128
+ if (!adapter || typeof adapter !== "object") {
129
+ throw new ArchiveGzError("archive-gz/bad-adapter",
130
+ "read.gz: adapter is required");
131
+ }
132
+ var maxOutputBytes = opts.maxDecompressedBytes !== undefined
133
+ ? opts.maxDecompressedBytes
134
+ : DEFAULT_MAX_OUTPUT_BYTES;
135
+ var maxRatio = opts.maxExpansionRatio !== undefined
136
+ ? opts.maxExpansionRatio
137
+ : DEFAULT_MAX_RATIO;
138
+ var decompressed = null;
139
+
140
+ async function _collect() {
141
+ if (adapter.kind === "random-access") {
142
+ var size = adapter.size;
143
+ if (size == null && typeof adapter.resolveSize === "function") {
144
+ size = await adapter.resolveSize();
145
+ }
146
+ if (typeof size !== "number" || size === 0) {
147
+ throw new ArchiveGzError("archive-gz/empty-input",
148
+ "read.gz: adapter reports empty payload");
149
+ }
150
+ return adapter.range(0, size);
151
+ }
152
+ if (adapter.kind === "trusted-sequential") {
153
+ var collector = safeBuffer.boundedChunkCollector({
154
+ maxBytes: maxOutputBytes,
155
+ errorClass: ArchiveGzError,
156
+ sizeCode: "archive-gz/trusted-stream-too-large",
157
+ });
158
+ for await (var chunk of adapter.readable) collector.push(chunk);
159
+ return collector.result();
160
+ }
161
+ throw new ArchiveGzError("archive-gz/bad-adapter",
162
+ "read.gz: adapter kind " + JSON.stringify(adapter.kind) + " not supported");
163
+ }
164
+
165
+ async function _materialize() {
166
+ if (decompressed !== null) return decompressed;
167
+ var compressed = await _collect();
168
+ if (!_isGzipMagic(compressed)) {
169
+ throw new ArchiveGzError("archive-gz/bad-magic",
170
+ "read.gz: input does not start with gzip magic 0x1f 0x8b");
171
+ }
172
+ decompressed = safeDecompress().safeDecompress(compressed, {
173
+ algorithm: "gzip",
174
+ maxOutputBytes: maxOutputBytes,
175
+ maxCompressedBytes: compressed.length,
176
+ maxRatio: maxRatio,
177
+ audit: opts.audit,
178
+ });
179
+ return decompressed;
180
+ }
181
+
182
+ return {
183
+ toBuffer: async function () { return _materialize(); },
184
+ asTar: function (tarOpts) {
185
+ tarOpts = tarOpts || {};
186
+ return {
187
+ inspect: async function () {
188
+ var bytes = await _materialize();
189
+ var reader = archiveTarRead().tar(
190
+ archiveAdapters().buffer(bytes), tarOpts);
191
+ return reader.inspect();
192
+ },
193
+ extract: async function (extractOpts) {
194
+ var bytes = await _materialize();
195
+ var reader = archiveTarRead().tar(
196
+ archiveAdapters().buffer(bytes), tarOpts);
197
+ return reader.extract(extractOpts);
198
+ },
199
+ };
200
+ },
201
+ asZip: function (zipOpts) {
202
+ zipOpts = zipOpts || {};
203
+ return {
204
+ inspect: async function () {
205
+ var bytes = await _materialize();
206
+ var reader = archiveRead().zip(
207
+ archiveAdapters().buffer(bytes), zipOpts);
208
+ return reader.inspect();
209
+ },
210
+ extract: async function (extractOpts) {
211
+ var bytes = await _materialize();
212
+ var reader = archiveRead().zip(
213
+ archiveAdapters().buffer(bytes), zipOpts);
214
+ return reader.extract(extractOpts);
215
+ },
216
+ };
217
+ },
218
+ };
219
+ }
220
+
221
+ module.exports = {
222
+ gz: gz,
223
+ read: { gz: readGz },
224
+ ArchiveGzError: ArchiveGzError,
225
+ // Exposed for sibling modules
226
+ _isGzipMagic: _isGzipMagic,
227
+ GZIP_MAGIC_0: GZIP_MAGIC_0,
228
+ GZIP_MAGIC_1: GZIP_MAGIC_1,
229
+ };
@@ -70,12 +70,18 @@
70
70
  var nodeStream = require("node:stream");
71
71
  var streamPromises = require("node:stream/promises");
72
72
  var C = require("./constants");
73
+ var lazyRequire = require("./lazy-require");
73
74
  var { defineClass } = require("./framework-error");
74
75
 
75
76
  var TarError = defineClass("TarError", { alwaysPermanent: true });
76
77
 
77
78
  void streamPromises; void nodeStream;
78
79
 
80
+ // Lazy because archive-gz lazy-imports archive-tar-read (sibling read
81
+ // module) — a top-of-file `require("./archive-gz")` would create a
82
+ // load-order cycle that depends on file-walk order.
83
+ var archiveGz = lazyRequire(function () { return require("./archive-gz"); });
84
+
79
85
  // ---- Wire-format constants -----------------------------------------------
80
86
 
81
87
  var BLOCK_SIZE = C.BYTES.bytes(512); // tar block size (POSIX)
@@ -498,12 +504,20 @@ function tarBuilder() {
498
504
  return nodeCrypto.createHash("sha3-512").update(toBuffer()).digest("hex");
499
505
  }
500
506
 
507
+ async function toGzip(adapter, gzOpts) {
508
+ // Convenience composition: materialize the tar then wrap through
509
+ // b.archive.gz. archive-gz is lazy-required at module top to break
510
+ // the load-order cycle with archive-tar-read.
511
+ return archiveGz().gz(toBuffer(), gzOpts || {}).toAdapter(adapter);
512
+ }
513
+
501
514
  return {
502
515
  addFile: addFile,
503
516
  addDirectory: addDirectory,
504
517
  toBuffer: toBuffer,
505
518
  toStream: toStream,
506
519
  toAdapter: toAdapter,
520
+ toGzip: toGzip,
507
521
  digest: digest,
508
522
  get entryCount() { return entries.length; },
509
523
  };
@@ -0,0 +1,237 @@
1
+ "use strict";
2
+ /**
3
+ * archive-wrap — recipient-based whole-archive encryption substrate
4
+ * for the b.archive family. Composes b.crypto.encrypt (ML-KEM-1024 +
5
+ * P-384 ECDH hybrid + SHAKE256 + XChaCha20-Poly1305 envelope) so
6
+ * archive bytes hitting an adapter can be a sealed envelope rather
7
+ * than the raw format.
8
+ *
9
+ * Operators compose explicitly for v0.12.10:
10
+ *
11
+ * var sealed = b.archive.wrap(t.toBuffer(), { recipient: pubKeys });
12
+ * await b.archive.adapters.fs(path).write(sealed);
13
+ *
14
+ * var sealed = await fs.promises.readFile(path);
15
+ * var bytes = b.archive.unwrap(sealed, { recipient: privKeys });
16
+ * var reader = b.archive.read.tar(b.archive.adapters.buffer(bytes));
17
+ *
18
+ * Builder-fluent composition (`tarBuilder.toAdapter(s3, { wrap: ... })`)
19
+ * + per-entry ZIP wrap (Flavor 2) land in v0.12.11 alongside the
20
+ * backup-crypto refactor; this patch ships the recipient substrate
21
+ * + the b.backup `cryptoStrategy: "recipient"` opt that consumes it.
22
+ */
23
+
24
+ var C = require("./constants");
25
+ var lazyRequire = require("./lazy-require");
26
+ var { defineClass } = require("./framework-error");
27
+
28
+ var ArchiveWrapError = defineClass("ArchiveWrapError", { alwaysPermanent: true });
29
+
30
+ var bCrypto = lazyRequire(function () { return require("./crypto"); });
31
+
32
+ // Envelope magic — 5-byte ASCII prefix the safe-archive sniffer
33
+ // recognises. Distinct from b.crypto.encrypt's base64 envelope so
34
+ // archive-wrap output can carry an unambiguous "this is an archive
35
+ // recipient-wrap envelope" magic before the operator-controlled
36
+ // payload.
37
+ var ARCH_WRAP_MAGIC = "BAWRP"; // allow:raw-byte-literal — 5-byte ASCII archive-wrap envelope magic
38
+ var ARCH_WRAP_VERSION = 0x01; // allow:raw-byte-literal — version byte
39
+ var ARCH_WRAP_HEADER_BYTES = C.BYTES.bytes(6); // magic(5) + version(1)
40
+
41
+ /**
42
+ * @primitive b.archive.wrap
43
+ * @signature b.archive.wrap(bytes, opts)
44
+ * @since 0.12.10
45
+ * @status stable
46
+ * @related b.archive.unwrap, b.crypto.encrypt, b.backup.bundleAdapterStorage
47
+ *
48
+ * Wrap archive bytes in a recipient-encrypted envelope. The envelope
49
+ * is the framework's standard hybrid PQC seal (ML-KEM-1024 + P-384
50
+ * ECDH hybrid + SHAKE256 KDF + XChaCha20-Poly1305 AEAD) prefixed
51
+ * with a 6-byte archive-wrap header (`BAWRP` magic + version byte)
52
+ * so format sniffers can distinguish wrap envelopes from raw
53
+ * archives without trial decryption.
54
+ *
55
+ * Recipient strategies:
56
+ * - static key — `{ recipient: { publicKey, ecPublicKey } }` (ML-KEM-1024
57
+ * pubkey PEM + P-384 ECDH pubkey PEM).
58
+ * - peer cert — `{ recipient: { peerCertDer, peerKemPubkey } }` composes
59
+ * `b.crypto.encryptEnvelopeAsCertPeer` (extracts the
60
+ * P-384 half from the cert).
61
+ * - tenant — `{ recipient: "tenant", tenantId: "alpha" }` resolves
62
+ * the tenant's KEM keypair via `b.vault.derivedKey`
63
+ * (deferred to v0.12.11 alongside the backup
64
+ * `cryptoStrategy: "recipient"` adoption).
65
+ *
66
+ * @opts
67
+ * recipient: object | string, // see strategies above; required
68
+ * tenantId: string, // required when recipient === "tenant"
69
+ *
70
+ * @example
71
+ * var pair = b.crypto.generateEncryptionKeyPair();
72
+ * var sealed = b.archive.wrap(tarBytes, { recipient: pair });
73
+ * // sealed is a Buffer carrying BAWRP+version+envelope; write to
74
+ * // any adapter sink. On read, hand to b.archive.unwrap with the
75
+ * // matching privKeys to recover tarBytes.
76
+ */
77
+ function wrap(bytes, opts) {
78
+ opts = opts || {};
79
+ if (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array)) {
80
+ throw new ArchiveWrapError("archive-wrap/bad-input",
81
+ "wrap: bytes must be a Buffer or Uint8Array");
82
+ }
83
+ if (bytes.length === 0) {
84
+ throw new ArchiveWrapError("archive-wrap/empty-input",
85
+ "wrap: bytes is empty — nothing to seal");
86
+ }
87
+ if (!opts.recipient) {
88
+ throw new ArchiveWrapError("archive-wrap/no-recipient",
89
+ "wrap: opts.recipient is required (static key object | \"tenant\" string | peer-cert object)");
90
+ }
91
+ var envelope = _encryptForRecipient(bytes, opts);
92
+ // envelope is a base64 string from b.crypto.encrypt. Buffer it and
93
+ // prepend the 6-byte archive-wrap header so safeArchive's sniffer
94
+ // can identify it without attempting decryption.
95
+ var envelopeBuf = Buffer.from(envelope, "utf-8");
96
+ var header = Buffer.alloc(ARCH_WRAP_HEADER_BYTES);
97
+ header.write(ARCH_WRAP_MAGIC, 0, 5, "ascii");
98
+ header[5] = ARCH_WRAP_VERSION;
99
+ return Buffer.concat([header, envelopeBuf]);
100
+ }
101
+
102
+ /**
103
+ * @primitive b.archive.unwrap
104
+ * @signature b.archive.unwrap(sealed, opts)
105
+ * @since 0.12.10
106
+ * @status stable
107
+ * @related b.archive.wrap, b.crypto.decrypt
108
+ *
109
+ * Recover archive bytes from a recipient-encrypted envelope produced
110
+ * by `b.archive.wrap`. Verifies the 6-byte `BAWRP` header before
111
+ * attempting decryption so non-envelope inputs (raw archive bytes,
112
+ * other-magic envelopes) fail with `archive-wrap/bad-magic` rather
113
+ * than a crypto-level error.
114
+ *
115
+ * @opts
116
+ * recipient: object, // { privateKey, ecPrivateKey } | { certPrivateKey, kemSecret }; required
117
+ *
118
+ * @example
119
+ * var bytes = b.archive.unwrap(sealed, { recipient: privPair });
120
+ * var reader = b.archive.read.tar(b.archive.adapters.buffer(bytes));
121
+ */
122
+ function unwrap(sealed, opts) {
123
+ opts = opts || {};
124
+ if (!Buffer.isBuffer(sealed) && !(sealed instanceof Uint8Array)) {
125
+ throw new ArchiveWrapError("archive-wrap/bad-input",
126
+ "unwrap: sealed must be a Buffer or Uint8Array");
127
+ }
128
+ if (sealed.length < ARCH_WRAP_HEADER_BYTES) {
129
+ throw new ArchiveWrapError("archive-wrap/bad-magic",
130
+ "unwrap: input shorter than 6-byte archive-wrap header");
131
+ }
132
+ var buf = Buffer.isBuffer(sealed) ? sealed : Buffer.from(sealed);
133
+ var magic = buf.slice(0, 5).toString("ascii");
134
+ if (magic !== ARCH_WRAP_MAGIC) {
135
+ throw new ArchiveWrapError("archive-wrap/bad-magic",
136
+ "unwrap: input does not start with archive-wrap magic " +
137
+ JSON.stringify(ARCH_WRAP_MAGIC) + "; got " + JSON.stringify(magic));
138
+ }
139
+ var version = buf[5];
140
+ if (version !== ARCH_WRAP_VERSION) {
141
+ throw new ArchiveWrapError("archive-wrap/bad-version",
142
+ "unwrap: archive-wrap version " + version + " not supported by this build");
143
+ }
144
+ if (!opts.recipient || typeof opts.recipient !== "object") {
145
+ throw new ArchiveWrapError("archive-wrap/no-recipient",
146
+ "unwrap: opts.recipient is required ({ privateKey, ecPrivateKey } " +
147
+ "for the static-key path, { certPrivateKey, kemSecret } for the peer-cert path)");
148
+ }
149
+ var envelope = buf.slice(ARCH_WRAP_HEADER_BYTES).toString("utf-8");
150
+ var plaintext;
151
+ try {
152
+ if (opts.recipient.certPrivateKey) {
153
+ // Cert-peer path: encryptEnvelopeAsCertPeer composed
154
+ // `encrypt(bytes, { publicKey, ecPublicKey })` where the
155
+ // ecPublicKey was extracted from the cert. The inverse passes
156
+ // the operator's kemSecret + certPrivateKey (P-384) through
157
+ // the same decrypt code path. raw:true preserves binary
158
+ // archive bytes losslessly.
159
+ plaintext = bCrypto().decrypt(envelope, {
160
+ privateKey: opts.recipient.kemSecret,
161
+ ecPrivateKey: opts.recipient.certPrivateKey,
162
+ }, { raw: true });
163
+ } else {
164
+ // raw:true returns the decrypted Buffer (lossless for arbitrary
165
+ // binary archive payloads — utf-8 string conversion would
166
+ // corrupt gzip / zip / tar bytes).
167
+ plaintext = bCrypto().decrypt(envelope, opts.recipient, { raw: true });
168
+ }
169
+ } catch (e) {
170
+ var err = new ArchiveWrapError("archive-wrap/decrypt-failed",
171
+ "unwrap: envelope decryption refused: " + ((e && e.message) || String(e)));
172
+ err.cause = e;
173
+ throw err;
174
+ }
175
+ return Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext);
176
+ }
177
+
178
+ function _encryptForRecipient(bytes, opts) {
179
+ var r = opts.recipient;
180
+ if (typeof r === "string") {
181
+ if (r === "tenant") {
182
+ // tenant strategy lands in v0.12.11 alongside the backup
183
+ // cryptoStrategy adoption — refuse cleanly for v0.12.10 so
184
+ // operators see the deferred-shape contract.
185
+ throw new ArchiveWrapError("archive-wrap/tenant-strategy-deferred",
186
+ "wrap: recipient: \"tenant\" lands in v0.12.11 alongside b.backup cryptoStrategy: \"recipient\" + per-tenant key resolution. For v0.12.10, pass an explicit { publicKey, ecPublicKey } recipient");
187
+ }
188
+ throw new ArchiveWrapError("archive-wrap/bad-recipient",
189
+ "wrap: recipient string " + JSON.stringify(r) + " not recognised; \"tenant\" deferred to v0.12.11");
190
+ }
191
+ if (r.peerCertDer || r.peerKemPubkey) {
192
+ if (!r.peerCertDer || !r.peerKemPubkey) {
193
+ throw new ArchiveWrapError("archive-wrap/bad-recipient",
194
+ "wrap: peer-cert strategy requires BOTH peerCertDer + peerKemPubkey");
195
+ }
196
+ return bCrypto().encryptEnvelopeAsCertPeer(bytes, {
197
+ peerCertDer: r.peerCertDer,
198
+ peerKemPubkey: r.peerKemPubkey,
199
+ });
200
+ }
201
+ if (r.publicKey) {
202
+ // Codex P2 on v0.12.10 PR #161 — b.crypto.encrypt falls back to
203
+ // ML-KEM-only when ecPublicKey is undefined (with a one-shot
204
+ // audit). For archive-wrap's recipient contract the hybrid leg
205
+ // (P-384 ECDH defence-in-depth backstop on top of ML-KEM-1024)
206
+ // is the documented behaviour; refuse upfront so partial
207
+ // recipient objects can't silently degrade the seal posture.
208
+ // Operators who genuinely want KEM-only call
209
+ // b.crypto.encryptMlkem768X25519 directly.
210
+ if (!r.ecPublicKey) {
211
+ throw new ArchiveWrapError("archive-wrap/hybrid-required",
212
+ "wrap: static-key recipient requires BOTH publicKey (ML-KEM-1024 PEM) " +
213
+ "and ecPublicKey (P-384 ECDH PEM). Partial recipients trip b.crypto.encrypt's " +
214
+ "ML-KEM-only fallback which silently degrades the hybrid contract this primitive promises.");
215
+ }
216
+ return bCrypto().encrypt(bytes, {
217
+ publicKey: r.publicKey,
218
+ ecPublicKey: r.ecPublicKey,
219
+ });
220
+ }
221
+ throw new ArchiveWrapError("archive-wrap/bad-recipient",
222
+ "wrap: recipient must be { publicKey, ecPublicKey } | { peerCertDer, peerKemPubkey } | \"tenant\"");
223
+ }
224
+
225
+ function _isWrapMagic(buf) {
226
+ return buf.length >= ARCH_WRAP_HEADER_BYTES &&
227
+ buf.slice(0, 5).toString("ascii") === ARCH_WRAP_MAGIC;
228
+ }
229
+
230
+ module.exports = {
231
+ wrap: wrap,
232
+ unwrap: unwrap,
233
+ ArchiveWrapError: ArchiveWrapError,
234
+ // Exposed for sibling modules + sniffer
235
+ _isWrapMagic: _isWrapMagic,
236
+ ARCH_WRAP_MAGIC: ARCH_WRAP_MAGIC,
237
+ };
package/lib/archive.js CHANGED
@@ -545,15 +545,24 @@ function zip() {
545
545
  var archiveRead = require("./archive-read");
546
546
  var archiveTar = require("./archive-tar");
547
547
  var archiveTarRead = require("./archive-tar-read");
548
+ var archiveGz = require("./archive-gz");
549
+ var archiveWrap = require("./archive-wrap");
548
550
 
549
551
  module.exports = {
550
- zip: zip,
551
- tar: archiveTar.tar,
552
- ArchiveError: ArchiveError,
553
- TarError: archiveTar.TarError,
552
+ zip: zip,
553
+ tar: archiveTar.tar,
554
+ gz: archiveGz.gz,
555
+ wrap: archiveWrap.wrap,
556
+ unwrap: archiveWrap.unwrap,
557
+ ArchiveError: ArchiveError,
558
+ TarError: archiveTar.TarError,
559
+ ArchiveGzError: archiveGz.ArchiveGzError,
560
+ ArchiveWrapError: archiveWrap.ArchiveWrapError,
554
561
  read: {
555
562
  zip: archiveRead.zip,
556
563
  tar: archiveTarRead.tar,
564
+ gz: archiveGz.read.gz,
565
+ fromGzip: archiveGz.read.gz,
557
566
  ArchiveReadError: archiveRead.ArchiveReadError,
558
567
  DEFAULT_BOMB_POLICY: archiveRead.DEFAULT_BOMB_POLICY,
559
568
  DEFAULT_ENTRY_TYPE_POLICY: archiveRead.DEFAULT_ENTRY_TYPE_POLICY,
@@ -1064,9 +1064,61 @@ function bundleAdapterStorage(opts) {
1064
1064
  // operator-supplied so a single backup engine can transition over
1065
1065
  // time + b.backup.migrate() handles the directory → tar conversion.
1066
1066
  var format = opts.format || "tar";
1067
- if (format !== "tar" && format !== "directory") {
1067
+ if (format !== "tar" && format !== "tar.gz" && format !== "directory") {
1068
1068
  throw new BackupError("backup/bad-format",
1069
- "bundleAdapterStorage: format must be \"tar\" (default) or \"directory\" (legacy v0.12.7)");
1069
+ "bundleAdapterStorage: format must be \"tar\" (default) | \"tar.gz\" (v0.12.9 compressed) | \"directory\" (legacy v0.12.7)");
1070
+ }
1071
+ // v0.12.10 — cryptoStrategy gates whether the bundle bytes are
1072
+ // wrapped in a recipient envelope before adapter.writeFile.
1073
+ // "none" — plaintext bundle (v0.12.7-9 behaviour). Safe
1074
+ // for adapter-encrypted storage (S3 SSE,
1075
+ // disk-encrypted hosts) where the storage layer
1076
+ // itself is the protective boundary.
1077
+ // "recipient" — composes b.archive.wrap on write +
1078
+ // b.archive.unwrap on read. Operator supplies
1079
+ // `recipient: { publicKey, ecPublicKey }` (or
1080
+ // a peer-cert recipient); the bundle bytes
1081
+ // hitting the adapter are an opaque envelope.
1082
+ // HIPAA / PCI-DSS postures (per
1083
+ // BACKUP_ENCRYPTION_REQUIRED_POSTURES) REFUSE
1084
+ // "none" + require "recipient".
1085
+ var cryptoStrategy = opts.cryptoStrategy || "none";
1086
+ if (cryptoStrategy !== "none" && cryptoStrategy !== "recipient") {
1087
+ throw new BackupError("backup/bad-crypto-strategy",
1088
+ "bundleAdapterStorage: cryptoStrategy must be \"none\" (default — adapter-encrypted storage) " +
1089
+ "or \"recipient\" (v0.12.10 — wraps bundle bytes in a hybrid PQC envelope before writeFile). " +
1090
+ "Passphrase strategy is deferred to v0.12.11.");
1091
+ }
1092
+ var recipient = opts.recipient;
1093
+ if (cryptoStrategy === "recipient" && (!recipient || typeof recipient !== "object")) {
1094
+ throw new BackupError("backup/no-recipient",
1095
+ "bundleAdapterStorage: cryptoStrategy: \"recipient\" requires opts.recipient " +
1096
+ "({ publicKey, ecPublicKey } for the hybrid PQC envelope OR { peerCertDer, peerKemPubkey } " +
1097
+ "for the peer-cert envelope)");
1098
+ }
1099
+ // Codex P1 on v0.12.10 PR #161 — the wrap layer composes only
1100
+ // with the tar / tar.gz writeBundle branches. Pairing recipient
1101
+ // strategy with format: "directory" would silently write plaintext
1102
+ // per-file payloads because the directory branch doesn't apply
1103
+ // the envelope. Refuse the combination upfront so operators see
1104
+ // the contract gap rather than discover it via disk inspection.
1105
+ // Per-file recipient encryption for directory format is a v0.12.11
1106
+ // follow-up alongside the _crypto-base.js refactor.
1107
+ if (cryptoStrategy === "recipient" && format === "directory") {
1108
+ throw new BackupError("backup/recipient-strategy-needs-bundled-format",
1109
+ "bundleAdapterStorage: cryptoStrategy: \"recipient\" requires format: \"tar\" or \"tar.gz\". " +
1110
+ "Directory format writes per-file plaintext to the adapter — the wrap layer composes only " +
1111
+ "with tar / tar.gz bundles in v0.12.10. Per-file recipient encryption is a v0.12.11 follow-up.");
1112
+ }
1113
+ var posture = opts.posture;
1114
+ if (posture && BACKUP_ENCRYPTION_REQUIRED_POSTURES.indexOf(posture) !== -1 &&
1115
+ cryptoStrategy === "none") {
1116
+ throw new BackupError("backup/posture-requires-encryption",
1117
+ "bundleAdapterStorage: posture=" + JSON.stringify(posture) +
1118
+ " requires cryptoStrategy: \"recipient\" (the adapter-storage layer cannot itself " +
1119
+ "satisfy HIPAA / PCI-DSS encryption-at-rest with cryptoStrategy: \"none\"). " +
1120
+ "The recipient+directory combination is refused separately so operators don't slip " +
1121
+ "plaintext per-file payloads past the posture gate.");
1070
1122
  }
1071
1123
  // Codex P2 on v0.12.8 PR #159 — tar mode builds the whole archive
1072
1124
  // in memory before adapter.writeFile because the v0.12.8 adapter
@@ -1111,13 +1163,16 @@ function bundleAdapterStorage(opts) {
1111
1163
  }
1112
1164
 
1113
1165
  // Tar-format bundle storage stores the whole bundle as a single
1114
- // key under `<bundleId>/bundle.tar`. The marker is named that way
1115
- // so listBundles + hasBundle can locate either format by key
1166
+ // key under `<bundleId>/bundle.tar` (or `<bundleId>/bundle.tar.gz`
1167
+ // for the v0.12.9 compressed variant). The marker is named that
1168
+ // way so listBundles + hasBundle can locate either format by key
1116
1169
  // prefix walk.
1117
1170
  var TAR_KEY_SUFFIX = "/bundle.tar";
1171
+ var TAR_GZ_KEY_SUFFIX = "/bundle.tar.gz";
1118
1172
 
1119
1173
  function _hasBundleKey(bundleId, format) {
1120
1174
  if (format === "tar") return adapter.hasKey(bundleId + TAR_KEY_SUFFIX);
1175
+ if (format === "tar.gz") return adapter.hasKey(bundleId + TAR_GZ_KEY_SUFFIX);
1121
1176
  return adapter.hasKey(bundleId + "/manifest.json");
1122
1177
  }
1123
1178
 
@@ -1134,9 +1189,12 @@ function bundleAdapterStorage(opts) {
1134
1189
  throw new BackupError("backup/bundle-exists",
1135
1190
  "writeBundle: bundle '" + bundleId + "' already exists in storage");
1136
1191
  }
1137
- if (format === "tar") {
1192
+ if (format === "tar" || format === "tar.gz") {
1138
1193
  // Pack the source-directory tree into a single tar archive +
1139
- // store under one key. Composes b.archive.tar.
1194
+ // store under one key. Composes b.archive.tar (+ b.archive.gz
1195
+ // for the tar.gz variant which adds gzip compression on the
1196
+ // wire). Bundle sizes drop ~3-5× on text-heavy backups
1197
+ // (databases, JSON exports, mail spools) under tar.gz.
1140
1198
  //
1141
1199
  // Codex P2 on v0.12.8 PR #159 — tar bytes are materialized in
1142
1200
  // memory because the v0.12.8 adapter contract is bytes-in
@@ -1165,8 +1223,14 @@ function bundleAdapterStorage(opts) {
1165
1223
  var bytes = nodeFs.readFileSync(nodePath.join(sourceDir, rel));
1166
1224
  t.addFile(rel, bytes);
1167
1225
  }
1168
- var tarBytes = t.toBuffer();
1169
- await adapter.writeFile(bundleId + TAR_KEY_SUFFIX, tarBytes);
1226
+ var keySuffix = format === "tar.gz" ? TAR_GZ_KEY_SUFFIX : TAR_KEY_SUFFIX;
1227
+ var payloadBytes = format === "tar.gz"
1228
+ ? archiveLazy().gz(t.toBuffer()).toBuffer()
1229
+ : t.toBuffer();
1230
+ if (cryptoStrategy === "recipient") {
1231
+ payloadBytes = archiveLazy().wrap(payloadBytes, { recipient: recipient });
1232
+ }
1233
+ await adapter.writeFile(bundleId + keySuffix, payloadBytes);
1170
1234
  return;
1171
1235
  }
1172
1236
  // Directory format (v0.12.7 layout).
@@ -1184,16 +1248,44 @@ function bundleAdapterStorage(opts) {
1184
1248
  "readBundle: destDir already exists: " + destDir);
1185
1249
  }
1186
1250
  // Detect which format this bundle is in — operators with mixed
1187
- // pre-v0.12.8 + post-v0.12.8 bundles can read either back.
1251
+ // pre-v0.12.8 + post-v0.12.8 (+ v0.12.9 tar.gz) bundles can read
1252
+ // every flavor back.
1188
1253
  var hasTar = await adapter.hasKey(bundleId + TAR_KEY_SUFFIX);
1254
+ var hasTarGz = await adapter.hasKey(bundleId + TAR_GZ_KEY_SUFFIX);
1189
1255
  var hasManifest = await adapter.hasKey(bundleId + "/manifest.json");
1190
- if (!hasTar && !hasManifest) {
1256
+ if (!hasTar && !hasTarGz && !hasManifest) {
1191
1257
  throw new BackupError("backup/bundle-not-found",
1192
1258
  "readBundle: '" + bundleId + "' not in storage");
1193
1259
  }
1194
1260
  atomicFile.ensureDir(destDir);
1261
+ if (hasTarGz) {
1262
+ // Codex P1/P2 on v0.12.9 PR #160 — propagate maxBundleBytes
1263
+ // to the gz restore path + disable the expansion-ratio cap.
1264
+ // archive.read.gz defaults (1 GiB output / 100× ratio) are
1265
+ // bomb-defense settings appropriate for adversarial input;
1266
+ // this is a SELF-AUTHORED bundle the writeBundle path
1267
+ // produced under maxBundleBytes — the restore contract is
1268
+ // "decompress to at most what was permitted on write." A
1269
+ // zero-filled DB file can easily hit >100× compression
1270
+ // ratio; without these opts the same primitive writes
1271
+ // bundles it can't read back.
1272
+ var gzBytes = await adapter.readFile(bundleId + TAR_GZ_KEY_SUFFIX);
1273
+ if (cryptoStrategy === "recipient") {
1274
+ gzBytes = archiveLazy().unwrap(gzBytes, { recipient: recipient });
1275
+ }
1276
+ var gzReader = archiveLazy().read.gz(archiveAdaptersLazy().buffer(gzBytes), {
1277
+ maxDecompressedBytes: maxBundleBytes,
1278
+ maxExpansionRatio: 0,
1279
+ });
1280
+ var tarReader = gzReader.asTar();
1281
+ await tarReader.extract({ destination: destDir });
1282
+ return;
1283
+ }
1195
1284
  if (hasTar) {
1196
1285
  var tarBytes = await adapter.readFile(bundleId + TAR_KEY_SUFFIX);
1286
+ if (cryptoStrategy === "recipient") {
1287
+ tarBytes = archiveLazy().unwrap(tarBytes, { recipient: recipient });
1288
+ }
1197
1289
  var reader = archiveLazy().read.tar(archiveAdaptersLazy().buffer(tarBytes));
1198
1290
  await reader.extract({ destination: destDir });
1199
1291
  return;
@@ -1217,7 +1309,13 @@ function bundleAdapterStorage(opts) {
1217
1309
  }
1218
1310
  atomicFile.ensureDir(nodePath.dirname(destPath));
1219
1311
  var bytes = await adapter.readFile(key);
1220
- nodeFs.writeFileSync(destPath, bytes);
1312
+ // Exclusive-create (wx) carries the v0.12.7 atomic-rollback
1313
+ // contract: readBundle refuses to overwrite pre-existing
1314
+ // files at destPath. Combined with the upfront destDir check
1315
+ // above (refuses if destDir already exists), the only way
1316
+ // wx fires here is a symlink swap between ensureDir and write
1317
+ // — which the framework refuses rather than following.
1318
+ nodeFs.writeFileSync(destPath, bytes, { flag: "wx", mode: 0o600 });
1221
1319
  }
1222
1320
  },
1223
1321
  async listBundles() {
@@ -1264,12 +1362,15 @@ function bundleAdapterStorage(opts) {
1264
1362
  async hasBundle(bundleId) {
1265
1363
  _ensureBundleId(bundleId);
1266
1364
  // Format-aware: check the storage layout's marker key. Tar
1267
- // bundles store under <bid>/bundle.tar; directory bundles store
1268
- // under <bid>/manifest.json. Operators with a mixed bundle set
1269
- // (some tar, some directory) get true for either.
1365
+ // bundles store under <bid>/bundle.tar; tar.gz bundles store
1366
+ // under <bid>/bundle.tar.gz; directory bundles store under
1367
+ // <bid>/manifest.json. Operators with a mixed bundle set
1368
+ // (some tar, some tar.gz, some directory) get true for any.
1270
1369
  var tarKey = bundleId + TAR_KEY_SUFFIX;
1370
+ var tarGzKey = bundleId + TAR_GZ_KEY_SUFFIX;
1271
1371
  var dirKey = bundleId + "/manifest.json";
1272
1372
  if (await adapter.hasKey(tarKey)) return true;
1373
+ if (await adapter.hasKey(tarGzKey)) return true;
1273
1374
  if (await adapter.hasKey(dirKey)) return true;
1274
1375
  return false;
1275
1376
  },
@@ -1301,7 +1402,15 @@ bundleAdapterStorage.fsAdapter = function (fsOpts) {
1301
1402
  async writeFile(key, bytes) {
1302
1403
  var path = _keyPath(key);
1303
1404
  atomicFile.ensureDir(nodePath.dirname(path));
1304
- nodeFs.writeFileSync(path, bytes);
1405
+ // mode 0o600 matches the v0.12.9 directory-format readback
1406
+ // discipline — backup payloads carry operator-owned bytes
1407
+ // (potentially PHI / PCI / GDPR-scoped); owner-only is the
1408
+ // strict posture. wx is not set here because writeFile is
1409
+ // the storage primitive (operators legitimately rewrite the
1410
+ // same key, e.g. resuming a multipart upload); upper layers
1411
+ // (writeBundle's `bundle-exists` check) enforce no-overwrite
1412
+ // at the bundle level.
1413
+ nodeFs.writeFileSync(path, bytes, { mode: 0o600 });
1305
1414
  },
1306
1415
  async readFile(key) {
1307
1416
  var path = _keyPath(key);
@@ -46,6 +46,7 @@ var SafeArchiveError = defineClass("SafeArchiveError", { alwaysPermanent: true }
46
46
  var archiveRead = lazyRequire(function () { return require("./archive-read"); });
47
47
  var archiveAdapters = lazyRequire(function () { return require("./archive-adapters"); });
48
48
  var archiveTarRead = lazyRequire(function () { return require("./archive-tar-read"); });
49
+ var archiveGz = lazyRequire(function () { return require("./archive-gz"); });
49
50
 
50
51
  // ---- Format sniffing ----------------------------------------------------
51
52
 
@@ -194,10 +195,23 @@ async function extract(opts) {
194
195
  guardProfile: opts.guardProfile,
195
196
  audit: opts.audit,
196
197
  });
198
+ } else if (format === "tar.gz") {
199
+ // gzip envelope around tar — safeDecompress caps run on the gz
200
+ // layer before the tar walker ever sees a decompressed byte.
201
+ reader = archiveGz().read.gz(source, {
202
+ maxDecompressedBytes: opts.maxDecompressedBytes,
203
+ maxExpansionRatio: opts.maxExpansionRatio,
204
+ audit: opts.audit,
205
+ }).asTar({
206
+ bombPolicy: opts.bombPolicy,
207
+ entryTypePolicy: opts.entryTypePolicy,
208
+ guardProfile: opts.guardProfile,
209
+ audit: opts.audit,
210
+ });
197
211
  } else {
198
212
  throw new SafeArchiveError("safe-archive/format-unsupported",
199
- "extract: format=" + JSON.stringify(format) + " — v0.12.8 ships ZIP + tar " +
200
- "(gz lands v0.12.9, encryptPacked-wrap lands v0.12.10)");
213
+ "extract: format=" + JSON.stringify(format) + " — v0.12.9 ships ZIP + tar + tar.gz " +
214
+ "(encryptPacked-wrap lands v0.12.10)");
201
215
  }
202
216
  var result = await reader.extract({
203
217
  destination: opts.destination,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.8",
3
+ "version": "0.12.10",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:4e14c3b7-7b58-4756-ba67-d2bcef61b25b",
5
+ "serialNumber": "urn:uuid:15ca08ce-3216-4a0c-bd31-d9c198e71e36",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-23T16:36:21.469Z",
8
+ "timestamp": "2026-05-23T19:05:59.300Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.12.8",
22
+ "bom-ref": "@blamejs/core@0.12.10",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.12.8",
25
+ "version": "0.12.10",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.12.8",
29
+ "purl": "pkg:npm/%40blamejs/core@0.12.10",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.12.8",
57
+ "ref": "@blamejs/core@0.12.10",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]