Archive
ZIP archive creation primitive. Operator-data-export shape ("download my data as a zip"), log bundling, plain-zip exports for end users.
Two output paths: - toBuffer() builds the whole archive in memory — good for small-to-medium exports that fit comfortably in process RSS. - toStream(writable) deflates each entry through a piped zlib transform and writes the central directory only after every entry finalizes, so multi-GB exports never need to fit in memory. If any source errors mid-pipe, the destination is destroyed with archive/aborted — consumers see a broken stream rather than a half-archive that pretends to be complete.
Compression: - deflate (default) via node:zlib's deflateRawSync — falls back to STORE when deflate didn't shrink the input. - store — no compression, for already-compressed bytes (PNG / JPEG / mp4).
Format guarantees: - Deterministic insertion order (entries appear in the order addFile is called; central directory matches). - UTF-8 file names with the APPNOTE 6.3.4 EFS bit set. - Path-traversal refused at addFile: leading /, backslashes, null bytes, and .. segments throw archive/bad-name. - No symlink emission — only regular file entries are produced. - SHA3-512 fingerprint via digest() for operator integrity logs. - ZIP64 (APPNOTE 6.3.10 §4.3.14 / §4.3.15 / §4.4.8 / §4.5.3) is emitted automatically when an archive exceeds 65535 entries or any entry's compressed/uncompressed size or local-header offset exceeds 4 GiB: the classic field carries the 0xFFFF/0xFFFFFFFF sentinel, a ZIP64 extended-information extra field supplies the 64-bit value, and the ZIP64 EOCD record + locator precede the classic EOCD. Archives below those limits stay classic byte-for-byte. b.archive.read.zip reads the produced ZIP64 form transparently.
Out of scope (v1): - ZIP-native password encryption (broken-by-design); operators wrap the produced bytes via b.crypto.encryptPacked for encryption-at-rest.
b.archive.adapters.fs(path, opts?) #
{
signal: AbortSignal, // propagates to in-flight read()s
}
Local-file random-access adapter. Opens a read-only file descriptor + fstats the size at adapter-create time so the reader's CD walk can begin with the trailer offset known up-front. Subsequent range(offset, length) calls reuse the same fd — operators extracting an archive don't pay a fresh open per range. close() is idempotent + safe to call after errors.
var adapter = b.archive.adapters.fs("/var/uploads/payload.zip");
try {
var reader = b.archive.read.zip(adapter);
var entries = await reader.inspect();
} finally {
await adapter.close();
}
b.archive.adapters.buffer(buf, opts?) #
{
signal: AbortSignal,
}
In-memory random-access adapter — slices a Buffer on range(). Useful for tests, small operator-uploaded payloads already in memory, and round-tripping b.archive.zip().toBuffer() output back through the reader without touching disk.
var produced = b.archive.zip();
produced.addFile("readme.txt", "Hello\n");
var bytes = produced.toBuffer();
var reader = b.archive.read.zip(b.archive.adapters.buffer(bytes));
var entries = await reader.inspect();
b.archive.adapters.objectStore(client, key, opts?) #
{
size: number, // override size (skips head() call)
signal: AbortSignal,
audit: b.audit, // forwarded to client.get
}
Random-access adapter backed by an operator-supplied b.objectStore client. The adapter calls client.get(key, { range: [start, end] }) for every range() request and reads the response body into a Buffer. Composes the framework's existing SSRF guard / TLS posture / audit chain — adapter behaviour follows whatever the client was configured with.
The client is expected to expose: client.head(key) → { size:
Operators using bucket implementations that don't expose .head() pass opts.size explicitly.
var client = { get: async function () { return Buffer.alloc(0); }, head: async function () { return { size: 0 }; } };
var adapter = b.archive.adapters.objectStore(client, "incoming/payload.zip");
var reader = b.archive.read.zip(adapter);
var policy = b.guardArchive.zipBombPolicy({ maxTotalDecompressedBytes: 268435456 });
void reader; void policy;
b.archive.adapters.http(url, opts?) #
{
client: b.httpClient, // override the default (must already exist)
headers: { ... },
timeoutMs: number, // per-request
signal: AbortSignal,
audit: b.audit,
}
Random-access adapter backed by HTTP Range requests. Composes the framework's b.httpClient (SSRF guard + TLS posture + audit chain + PQC-hybrid agent) so the adapter inherits the operator's network surface configuration without duplicating it here.
First call issues a HEAD to determine size + verify the server accepts Range requests (Accept-Ranges: bytes). Servers without Range support are refused with adapter/no-range — operators downloading the full byte stream first and feeding b.archive. adapters.buffer is the appropriate fallback in that case.
var adapter = b.archive.adapters.http("https://artifact-host.example.com/release.zip", {
timeoutMs: 60_000,
});
var reader = b.archive.read.zip(adapter);
var entries = await reader.inspect();
b.archive.adapters.trustedStream(readable, opts?) #
{
signal: AbortSignal,
}
Forward-scan-only adapter for trusted Readable sources. The reader walks local file headers in order; the CD/LFH skew defense and the "entries hidden from LFH but present in CD" attack class are OFF in this mode because there's no central directory to compare against. Operators reaching for this primitive are declaring they own the producer (e.g. piping their own b.archive.zip().toStream() output back into a reader 30 seconds later for round-trip verification).
Adversarial input MUST use b.archive.adapters.fs / buffer / objectStore / http — the random-access path is the only adversarial-safe one.
var produced = fs.createReadStream("./own-export.zip");
var reader = b.archive.read.zip.fromTrustedStream(produced);
var entries = [];
for await (var e of reader.entries()) entries.push(e);
b.archive.adapters.isRandomAccessAdapter(a) #
Type-predicate: returns true when a is the random-access shape ({ kind: "random-access", range, ... }) produced by fs / buffer / objectStore / http. Operators routing through b.archive.read.zip compose this to refuse trusted-stream adapters at the wrong entry point.
var ok = b.archive.adapters.isRandomAccessAdapter(adapter);
if (!ok) throw new Error("need random-access adapter");
b.archive.adapters.isTrustedStreamAdapter(a) #
Type-predicate: returns true when a is the trusted-sequential shape ({ kind: "trusted-sequential", readable, ... }) produced by trustedStream. Operators routing through b.archive.read.zip. fromTrustedStream compose this to refuse random-access adapters at the wrong entry point.
var ok = b.archive.adapters.isTrustedStreamAdapter(adapter);
if (!ok) throw new Error("need trusted-stream adapter");
b.archive.gz(bytes, opts?) #
{
level: number, // 0-9, default 6 (zlib default).
}
Wrap a buffer in a gzip envelope. Returns a builder with the same write surface as the other b.archive builders — toBuffer() / toAdapter(adapter) / digest() — so gzip slots into the same downstream sinks (object-store + filesystem + http adapters).
b.archive.tar().toGzip(adapter) composes this primitive after materializing the tar bytes (the canonical .tar.gz). There is no zip().toGzip() — a ZIP is already DEFLATE-compressed per entry, so gzip-wrapping it would compress already-compressed data for no gain; gzip the uncompressed tar stream instead.
var compressed = b.archive.gz(Buffer.from("hello world")).toBuffer();
// → 31-byte gzip stream
b.archive.read.gz(adapter, opts) #
{
maxDecompressedBytes: number, // default 1 GiB
maxExpansionRatio: number, // default 100×
audit: object,
}
Read a gzip stream from an adapter, surface it as either raw bytes (toBuffer()) or as a hand-off to a downstream archive reader (asTar() / asZip()). Every decompression composes b.safeDecompress with framework-default caps — maxOutputBytes (1 GiB) and maxExpansionRatio (100×) — so a hostile tar.gz fails the gz gate before any tar parsing happens.
var reader = b.archive.read.gz(b.archive.adapters.fs("./bundle.tar.gz"));
var tarReader = reader.asTar();
var result = await tarReader.extract({ destination: "./out" });
b.archive.read.zip(adapter, opts?) #
{
bombPolicy: { maxEntries, maxEntryDecompressedBytes,
maxTotalDecompressedBytes, maxExpansionRatio },
entryTypePolicy: { symlinks, hardlinks, devices, fifos, sockets },
guardProfile: "strict" | "balanced" | "permissive" | "hipaa" | ...,
audit: b.audit,
signal: AbortSignal,
}
Random-access ZIP reader. Walks the end-of-central-directory record, validates every CD entry against its local file header, and exposes inspect() (entry-list enumeration without decompressing) + extract(opts) (full decompression with bomb caps + path-traversal + entry-type policy).
ZIP64 (APPNOTE 6.3.10 §4.3.14 EOCD64 / §4.3.15 locator / §4.5.3 extended-information extra field) is read transparently: archives whose entry count exceeds 65535 or whose sizes/offsets exceed 4 GiB carry the ZIP64 trailer, which is resolved into the same entry shape a classic archive yields. The classic-format default entry cap is lifted to 2^20; operators raise it through bombPolicy.maxEntries.
Defends: - Zip Slip / path traversal (CVE-2025-3445 / 11569 / 23084 / 27210 / 11001 / 11002 / 26960 + 2024 jszip / mholt / Python tarfile) - LFH/CD skew (malformed-zip class) - Decompression bomb (OWASP zip-bomb top-cases) - PATH_MAX TOCTOU (CVE-2025-4517) via b.guardFilename. verifyExtractionPath - Symlink + hardlink + device entries (refused by default)
var adapter = b.archive.adapters.fs("/var/uploads/payload.zip");
var reader = b.archive.read.zip(adapter);
var entries = await reader.inspect();
// → [{ name, size, compressedSize, crc, method, mtime, ... }, ...]
var dest = b.archive.adapters.fs("/var/quarantine");
var result = await reader.extract({ destination: "/var/quarantine" });
// → { entries: [{ name, bytesWritten }, ...], bytesExtracted }
b.archive.read.zip.fromTrustedStream(adapter, opts?) #
{
bombPolicy: { maxEntries, maxEntryDecompressedBytes,
maxTotalDecompressedBytes, maxExpansionRatio },
entryTypePolicy: { ... },
guardProfile: "strict" | "balanced" | "permissive",
audit: b.audit,
}
ZIP reader for a Readable source — pass b.archive.adapters.trustedStream(readable) instead of buffering the stream yourself. The bytes are collected into a size-capped buffer (1 GiB hard ceiling, like the tar trusted-stream reader) and then read through the same bomb-cap / path-traversal / entry-policy decode as the random-access reader, so bombPolicy, guardProfile, entryTypePolicy, and audit all apply. "Trusted" means the source size is bounded by the operator — the collection ceiling is the only guard against an unbounded producer; adversarial archives are still fully bomb-capped on decode.
The collection ceiling means this is not zero-buffer streaming (the whole archive is held in memory, capped); a future bounded-memory forward-inflate walker would lift that, shared with the tar reader.
var reader = b.archive.read.zip.fromTrustedStream(b.archive.adapters.trustedStream(readable));
var entries = await reader.inspect();
void entries;
b.archive.read.tar(adapter, opts?) #
{
bombPolicy: { maxEntries, maxEntryDecompressedBytes,
maxTotalDecompressedBytes, maxExpansionRatio },
entryTypePolicy: { symlinks, hardlinks, devices, fifos, sockets },
allowDangerous: { symlinks, hardlinks },
guardProfile: "strict" | "balanced" | "permissive",
audit: b.audit,
}
POSIX pax tar reader. Walks 512-byte header blocks sequentially + extracts via the same bomb-cap / path-traversal / entry-type policy surface as the v0.12.7 ZIP reader. Random-access and trusted-stream adapters are both first-class (tar has no central directory, so sequential header-by-header is the canonical adversarial-safe path).
var reader = b.archive.read.tar(b.archive.adapters.buffer(Buffer.alloc(0)));
var entries = await reader.inspect();
void entries;
b.archive.tar() #
POSIX pax tar archive builder. Mirrors b.archive.zip()'s addFile / addDirectory / toBuffer / toStream / toAdapter / digest contract.
var t = b.archive.tar();
t.addFile("readme.txt", "Hello\n");
t.addFile("data/numbers.csv", "n,sq\n1,1\n2,4\n");
var bytes = t.toBuffer();
t.entryCount; // → 2
b.archive.wrap(bytes, opts) #
{
recipient: object | string, // see strategies above; required
tenantId: string, // required when recipient === "tenant"
}
Wrap archive bytes in a recipient-encrypted envelope. The envelope is the framework's standard hybrid PQC seal (ML-KEM-1024 + P-384 ECDH hybrid + SHAKE256 KDF + XChaCha20-Poly1305 AEAD) prefixed with a 6-byte archive-wrap header (BAWRP magic + version byte) so format sniffers can distinguish wrap envelopes from raw archives without trial decryption.
Recipient strategies: - static key — { recipient: { publicKey, ecPublicKey } } (ML-KEM-1024 pubkey PEM + P-384 ECDH pubkey PEM). - peer cert — { recipient: { peerCertDer, peerKemPubkey } } composes b.crypto.encryptEnvelopeAsCertPeer (extracts the P-384 half from the cert). - tenant — { recipient: "tenant", tenantId: "alpha" } seals under a deterministic per-tenant key derived from the vault root (b.agent.tenant.derivedKey) with XChaCha20-Poly1305, the tenant id mixed into the AEAD AAD so one tenant's envelope cannot open under another's key. No recipient key-pair to manage; unwrap re-derives from the same tenantId. Requires an initialized vault. The derived key tracks the vault root: after a vault rotation the operator must re-wrap each stored tenant blob old-root -> new-root via b.archive.rewrapTenant (the rotation pipeline does not walk operator-placed blobs).
var pair = b.crypto.generateEncryptionKeyPair();
var sealed = b.archive.wrap(tarBytes, { recipient: pair });
// sealed is a Buffer carrying BAWRP+version+envelope; write to
// any adapter sink. On read, hand to b.archive.unwrap with the
// matching privKeys to recover tarBytes.
b.archive.unwrap(sealed, opts) #
{
recipient: object | "tenant", // { privateKey, ecPrivateKey } | { certPrivateKey, kemSecret } | "tenant"
tenantId: string, // required when the envelope was sealed with recipient: "tenant"
}
Recover archive bytes from a recipient-encrypted envelope produced by b.archive.wrap. Verifies the 6-byte BAWRP header before attempting decryption so non-envelope inputs (raw archive bytes, other-magic envelopes) fail with archive-wrap/bad-magic rather than a crypto-level error.
var bytes = b.archive.unwrap(sealed, { recipient: privPair });
var reader = b.archive.read.tar(b.archive.adapters.buffer(bytes));
// tenant envelope:
var t = b.archive.unwrap(sealedForTenant, { recipient: "tenant", tenantId: "alpha" });
b.archive.rewrapTenant(opts) #
{
blob: Buffer | Uint8Array, // a tenant (BAWRP v2) envelope; required
oldRootJson: string, // b.vault.getKeysJson() of the OLD keypair; required
newRootJson: string, // b.vault.getKeysJson() of the NEW keypair; required
tenantId: string, // the tenant the blob was sealed for; required
}
Re-wrap a single recipient: "tenant" archive blob from the old vault root to the new one after a vault rotation. The tenant strategy keys each envelope off the vault root (b.agent.tenant.derivedKey); rotating the vault keypair changes that root, so envelopes sealed under the old root no longer open.
The vault rotation pipeline (b.vaultRotate.rotate) re-seals every value it can WALK — sealed DB columns and the framework's sealed key files. It cannot reach tenant archive blobs: those are opaque bytes the operator placed in files / object-storage / backups outside any store the framework indexes. The framework does not track blob locations, so the operator enumerates them and calls this primitive once per blob, supplying both the old and new keypair JSON.
The re-wrap unwraps under the old-root tenant key, then re-wraps under the new-root tenant key with the SAME tenant-bound AAD — the plaintext archive bytes are recovered in memory and immediately re-sealed; the AEAD tag on the new envelope binds the same tenantId, so cross-tenant replay is refused exactly as on a fresh b.archive.wrap.
Run this BEFORE retiring the old vault keypair: the old keypair JSON is the only material that can open the old envelopes (CWE-325 — skipping it strands the blobs; CWE-665 — re-keying under the wrong root yields an unopenable envelope). Refuses any non-tenant envelope (recipient / passphrase magic) so a key-pair or passphrase blob is never silently mis-routed through the tenant key path.
var oldRoot = oldKeys; // captured before rotation
var newKeys = b.vault.getKeysJson();
// operator enumerates blob locations (framework does not index them):
for (var loc of operatorBlobInventory) {
var rewrapped = b.archive.rewrapTenant({
blob: fs.readFileSync(loc),
oldRootJson: oldRoot,
newRootJson: newKeys,
tenantId: "alpha",
});
fs.writeFileSync(loc, rewrapped);
}
b.archive.sniffEnvelope(bytes) #
Identify the envelope shape carried by a buffer without attempting decryption. Returns one of: - "recipient" — BAWRP header (v0.12.10 hybrid PQC envelope). Operator routes through b.archive.unwrap(bytes, { recipient }). - "passphrase" — BAWPP header (v0.12.11 Argon2id + XChaCha20 envelope). Operator routes through b.archive.unwrapWithPassphrase(bytes, { passphrase }). - "none" — no archive-wrap envelope magic. The bytes are either raw archive content (gz / tar / zip) or an unrelated payload; operator routes to the appropriate b.archive.read.* primitive (or refuses entirely).
The sniff is byte 0-4 inspection ONLY — no cryptographic work, no allocation beyond a 5-byte ASCII compare. Safe to call on adversarial input.
var kind = b.archive.sniffEnvelope(payloadBytes);
switch (kind) {
case "recipient": return b.archive.unwrap(payloadBytes, { recipient });
case "passphrase": return b.archive.unwrapWithPassphrase(payloadBytes, { passphrase });
case "none": return payloadBytes;
}
b.archive.wrapWithPassphrase(bytes, opts) #
{
passphrase: Buffer | string, // required; >= minEntropyBits
minEntropyBits: number, // default 80; HIPAA recipe sets 128
}
Wrap archive bytes in a passphrase-derived envelope. The envelope wire format is the framework's standard Argon2id (RFC 9106) + XChaCha20-Poly1305 AEAD with a fresh per-envelope salt prefixed in a 7-byte BAWPP header (5-byte magic + 1-byte version + 1-byte salt length). Operators choosing the passphrase strategy (vs the recipient strategy from b.archive.wrap) reach for this primitive when they don't want to manage KEM keypairs but do want encryption-at-rest under operator-controlled material.
var sealed = await b.archive.wrapWithPassphrase(tarBytes, {
passphrase: "operator-supplied-long-passphrase",
minEntropyBits: 128,
});
b.archive.unwrapWithPassphrase(sealed, opts) #
{
passphrase: Buffer | string, // required; same passphrase used at wrap-time
}
Recover archive bytes from a passphrase-derived envelope produced by b.archive.wrapWithPassphrase. Verifies the 7-byte BAWPP header before attempting key derivation so non-envelope inputs fail with archive-wrap/bad-magic rather than burning Argon2id compute on bad bytes.
var recovered = await b.archive.unwrapWithPassphrase(sealed, {
passphrase: "operator-supplied-long-passphrase",
});
b.archive.zip() #
Create a new ZIP archive builder. The returned object exposes addFile(name, content, opts?), toBuffer(), toStream(writable?), writeTo(path), digest(), and entryCount. Entries appear in the archive's central directory in insertion order — same byte output given the same input sequence and mtimes.
content may be a Buffer, a UTF-8 string, or a Readable; only toStream() can finalize archives containing Readable sources (toBuffer() throws archive/streaming-entry).
var archive = b.archive.zip();
archive.addFile("readme.txt", "Hello\n");
archive.addFile("data/users.csv", Buffer.from("name,age\nAda,36\n"));
archive.addFile("avatars/me.png", Buffer.from([0x89, 0x50, 0x4e, 0x47]),
{ method: "store" }); // already-compressed
var zipBytes = archive.toBuffer();
archive.entryCount; // → 3
typeof archive.digest(); // → "string" (sha3-512 hex)
// Stream a multi-GB export directly to an HTTP response.
var fs = require("node:fs");
var big = b.archive.zip();
big.addFile("logs/2026-q1.ndjson", fs.createReadStream("/var/log/q1.ndjson"));
// await big.toStream(res);
Last updated 2026-08-08T16:39:49.652Z by seeder.