# zipnative > Zero-dependency native ZIP engine in pure TypeScript: random-access reads, > secure-by-default extraction, streaming, deterministic archives (v0.2+) and > incremental modification (v0.4+). Node ≥22, browsers, Deno, Bun, Workers. Verified on: 2026-09-02 Machine-readable artefacts: docs/agent-brief.md (a compact briefing for coding agents — paste it into your context), docs/assets/api.json (the full export surface — mechanically extracted, never guessed), docs/data/errors.json (the frozen error-code and diagnostic registry — code, class, cause, remedy), docs/llms-full.txt (concatenated documentation), docs/llms-index.json (artefact/guide index with byte and approximate token sizes, so you can decide what to fetch before spending the tokens). Interactive playgrounds (toolkit, inspector, secure-extraction demo, incremental modify, forward streaming) run the real engine in the browser at docs/playgrounds/ — the published package from a version-pinned CDN, falling back to a committed copy of the library's own bundle. ## Essentials - Single entry point: `import { openZip, extractZip } from 'zipnative'` — everything public is exported there; nothing else is API. - No classes: factories return interfaces (`openZip(bytes) → ZipReader`). - Every error message starts `zipnative: ` and contains the remedy, and every thrown error carries a STABLE machine-readable `err.code` (v0.8+, frozen vocabulary — e.g. `ZIP_EOCD_NOT_FOUND`, `ZIP_PATH_TRAVERSAL`, `ZIP_LIMIT_EXCEEDED`): branch on the code, never on message text. Registry: docs/data/errors.json; guide: docs/guides/errors.md. - Extraction is safe by default: traversal, symlinks, duplicates and bombs are rejected unless explicitly configured otherwise via `limits` / `rejectTraversal` / `rejectSymlinks` / `onDuplicate`. - Encrypted entries: detected (`entry.isEncrypted`), reads throw `ZipUnsupportedError`. No encryption support in 1.x. - The engine never touches the filesystem — `extractZip` returns `{ path, data, entry }[]`; writing to disk is the caller's (or the future CLI's) job. `sanitizeEntryPath()` is exported for external sinks. ## Core writing API (v0.2) - `createZip(options?)` → `ZipWriter` with `add(name, data, opts?)`, `addDirectory(name)`, `addStream(name, source)` (a `ByteSource` — any `AsyncIterable` or a Web `ReadableStream` since 0.9; data-descriptor layout; ≤ 4 GiB), `setComment`, `toBytes()` (sync) and `stream({ chunkSize? })` (async, byte-identical to toBytes). - Reproducible archives: `createZip({ compression: { deterministic: true } })` → identical inputs give identical SHA-256 on every runtime (contract: docs/guides/determinism.md). Default timestamps are the DOS epoch — already reproducible; `defaultDate: 'now'` opts into wall-clock time. - The writer never emits traversal-capable names (validated at `add()`), entries are sorted canonically by raw name bytes (`order: 'insertion'` preserves call order — EPUB/JAR mimetype case), and Zip64 records appear exactly when a field overflows. ## Parallel creation (v0.5, subpath zipnative/worker) - `createParallelZip(options?)` — same surface as createZip but `toBytes(): Promise`; per-entry deflate runs across a worker pool (Node worker_threads / Web Workers). Byte-identical to createZip per compression tier; unconditional with `compression: { deterministic: true }`. Worker failures degrade gracefully — archives never fail for infra reasons. `workers: 0` forces main-thread mode. ## Forward streaming read (v0.5) - `iterateZipEntries(source)` — read a ZIP off an UNSEEKABLE stream (`ByteSource`: an `AsyncIterable` or, since 0.9, a Web `ReadableStream` — a `fetch` body, `File.stream()`, …): yields `{ header, data(), skip() }` per local entry; consume or skip each before advancing. Bounded memory; CRC verified; stops cleanly at the central directory. - TRUST CAVEAT for agents: forward iteration reads local headers ALONE (no central-directory cross-check) — prefer openZip() whenever the complete archive is available; it is the authoritative path. - Data-descriptor entries (flag bit 3) are readable since 0.6 for plain deflate — zipnative's own addStream() output and bsdtar-style archives included (the resumable pure-TS inflater delimits the stream and the trailing descriptor is validated against the measured CRC/sizes). Still refused: store+bit3 (not self-delimiting), encrypted+bit3, custom codecs+bit3. skip() on a bit-3 entry costs a full decompress-and-discard. ## Incremental modification API (v0.4) - `createZipModifier(openZip(bytes))` → `ZipModifier` with `addEntry`/`replaceEntry`/`removeEntry`/`renameEntry`/`setComment` and two save paths: `save()` (append-only — untouched entries never recompressed, original bytes verbatim, no-op returns the same reference) and `saveCompact()` (canonical rewrite, still no recompression, removed content truly deleted). - IMPORTANT for agents: `removeEntry` + `save()` does NOT erase content — removed/replaced payloads remain recoverable in the output (data remanence). Use `saveCompact()` when deletion matters. - Interop caveat (measured 2026-09-03): 7-Zip's CLI mis-reads append-only `save()` output — it extracts the stale replaced payload and misses appended entries (it does not honour the final central directory; unzip/bsdtar/python/jar/Expand-Archive all do). When the output must interoperate with 7-Zip, ship `saveCompact()` instead. - Renames never overwrite implicitly; duplicate-name archives are refused with a typed error; encrypted entries can be kept/renamed/copied but never read. ## Core reading API (v0.1) - `openZip(bytes, options?)` → `ZipReader` with `entries()`, `getEntry(name)`, `readEntry(entryOrName)`, `readEntryStream(entryOrName)`, `readEntryRaw(entryOrName)`, `verifyEntry(entryOrName)`. - `extractZip(bytes, options?)` → `ExtractedEntry[]` (in memory). - Options embed `ZipCommonOptions`: `strict` (throw on first diagnostic), `onDiagnostic` (handler), `limits` (Partial). - Entry-attribute helpers (v0.9): `isSymlinkEntry(entry)` and `getUnixMode(entry)` (`number | null` — null when the entry was not authored on a Unix host; 0 is a real mode, never a "no data" marker). ## One-call verification (v0.9) - `verifyZip(bytes, options?)` → `ZipVerificationReport` — the agent-facing archive inventory: `{ ok, error, entryCount, entries, diagnostics }`. NEVER throws for archive problems: a structural refusal lands in `report.error` (`{ code, message }` — the same frozen err.code vocabulary), per-entry results carry `crcMatch`/`sizeMatch`/`localHeaderMatch`, and encrypted or stream-only-codec entries are reported as `skipped` with a reason — never faked as corruption. Only caller bugs (invalid limits) throw. ## Conformance (v1.0) - Every archive zipnative writes conforms to ISO/IEC 21320-1:2015 (the ISO-standardised ZIP profile) — validated clause by clause by `npm run validate:zip`, the first open ISO 21320-1 validator (raw parser, independent of the engine), blocking in CI and before every publish, plus a six-parser differential extraction matrix (`npm run test:interop`). Guide: docs/guides/conformance.md. Note: spec-valid ≠ safe — zip-slip is ISO-conformant, which is why the extraction guards exist on top. ## Docs - README.md: positioning, quick start, comparison, limitations - SECURITY.md: threat model and every bound with CWE tags - ROADMAP.md: 0.1 read → 0.2 deterministic write → 0.4 incremental modify → 0.5 workers → 0.9 RC → 1.0 (frozen API, npm + provenance) → satellites - AGENTS.md: conventions and architecture for contributors and agents --- # zipnative **A safe, deterministic, streaming ZIP engine for modern apps — and for the agents that operate them.** [![CI](https://github.com/Nizoka/zipnative/actions/workflows/ci.yml/badge.svg)](https://github.com/Nizoka/zipnative/actions/workflows/ci.yml) [![CodeQL](https://github.com/Nizoka/zipnative/actions/workflows/codeql.yml/badge.svg)](https://github.com/Nizoka/zipnative/actions/workflows/codeql.yml) [![npm version](https://img.shields.io/npm/v/zipnative)](https://www.npmjs.com/package/zipnative) [![npm downloads](https://img.shields.io/npm/dm/zipnative)](https://www.npmjs.com/package/zipnative) [![bundle size](https://img.shields.io/bundlephobia/minzip/zipnative)](https://bundlephobia.com/package/zipnative) ![Zero runtime dependencies](https://img.shields.io/badge/dependencies-0-brightgreen) ![TypeScript strict mode](https://img.shields.io/badge/TypeScript-strict-blue) ![93.9 percent statement coverage](https://img.shields.io/badge/coverage-93.9%25-brightgreen) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) ![npm provenance](https://img.shields.io/badge/provenance-signed-blueviolet) [![website](https://img.shields.io/badge/zipnative.dev-2563EB)](https://zipnative.dev) Zero runtime dependencies. 100% TypeScript. One API across Node.js ≥ 22, browsers, Deno, Bun and Workers. Built for the archives that actually matter in 2026 — OOXML, EPUB, JAR/VSIX, and multi-gigabyte data drops that must never be buffered whole — under the same engineering doctrine as [pdfnative](https://github.com/Nizoka/pdfnative). > **Status: 1.0 — stable.** The public API surface, the 39-code error vocabulary and the `deterministic: true` output bytes are **frozen under semantic versioning** — removals and byte changes are semver-major (the full promise is in [SECURITY.md](SECURITY.md)). Built up through read (v0.1), deterministic write (v0.2), incremental modification (v0.4), workers + forward streaming (v0.5), the resumable inflater (v0.6), the interop gate (v0.7), the frozen error codes (v0.8) and one-call verification (v0.9). Documentation: [zipnative.dev](https://zipnative.dev) (site sources in [docs/](docs/), interactive [playgrounds](docs/playgrounds/) included). ## Why zipnative? Most ZIP libraries make you choose between speed, safety and capability. zipnative's positioning is different: - **Safe by default.** Extraction refuses path traversal (zip-slip), symlink entries, duplicate names and decompression bombs unless you explicitly opt out. Every parser loop runs under a named, CWE-tagged, caller-configurable bound. Ambiguous archives (conflicting end-of-central-directory records, Zip64 field spoofing, overlapping entries) are rejected, not guessed at. - **Random access.** Read one entry from a 4 GB archive without extracting — or even scanning — the rest. The central directory is parsed lazily; entry payloads are zero-copy subarrays. - **Streaming.** Iterate entries and decompress through async iterables with bounded memory. Designed for serverless and Cloudflare Workers, not just long-lived servers. - **Deterministic.** Reproducible-build mode with a [written determinism contract](docs/guides/determinism.md): same inputs, same SHA-256 on every runtime via the pinned pure-TS deflate encoder. Canonical entry ordering, pinned timestamps, no environment leakage. - **Incremental modification.** Replace, remove, add or rename entries and `save()` without recompressing the untouched 99% of the archive — the append-only overlay model proven in pdfnative's PDF incremental updates. `saveCompact()` is the true-deletion path (removed content is otherwise still recoverable — [documented loudly](SECURITY.md)). - **Agent-pilotable.** Every thrown error carries a **stable machine-readable `err.code`** from a frozen 39-code vocabulary (v0.8+ — registry in [docs/data/errors.json](docs/data/errors.json), guide in [docs/guides/errors.md](docs/guides/errors.md)): branch on the code, never on message text. Plus `verifyZip()` — one call, a machine-readable verification report that never throws for archive problems (v0.9), remedy-bearing messages, a structured diagnostics channel, executable recipes, four documented [production use cases](docs/guides/use-cases.md), `llms.txt`, and a human-in-the-loop AI governance policy. ### Comparison (honest version) | | zipnative | fflate | jszip | yauzl/yazl | adm-zip | |---|---|---|---|---|---| | Zero runtime dependencies | ✅ | ✅ | ❌ | ❌ | ❌ | | Random access (1 entry without full parse) | ✅ | ❌ | ❌ | yauzl ✅ | ❌ | | Streaming read + write | ✅ | partial (low-level) | ❌ (memory-bound) | read *or* write per lib | ❌ | | Safe-extract defaults (slip/bomb/ambiguity) | ✅ | DIY | DIY | DIY | historical CVEs | | Deterministic output (documented contract) | ✅ | DIY | ❌ | ❌ | ❌ | | Modify in place, no recompression | ✅ | ❌ | rewrite-all | ❌ | partial | | Browser + Node + Deno + Bun + Workers | ✅ | ✅ | ✅ | Node-only | Node-only | | Raw deflate throughput | good (platform zlib) | **best** | slow | good | poor | fflate keeps the raw-deflate-speed crown and we do not chase it: zipnative uses the platform's native codecs (`node:zlib`, `CompressionStream`) behind a pluggable seam, and wins on *scenarios* — random access, bounded-memory streaming, in-place updates — not drag races. ## Installation ```bash npm install zipnative ``` ## Quick start ```ts import { openZip, extractZip } from 'zipnative'; // Open an archive — lazy: only the central directory is located, nothing decompressed. const zip = openZip(bytes); console.log(zip.entryCount); for (const entry of zip.entries()) { console.log(entry.name, entry.uncompressedSize); } // Random access: decompress exactly one entry, CRC-verified. const manifest = zip.readEntry('manifest.json'); // Stream a large entry with bounded memory. for await (const chunk of zip.readEntryStream('video.mp4')) { // ... } // Secure extraction (in memory — filesystem sinks belong to zipnative-cli). const files = extractZip(bytes, { limits: { maxEntries: 10_000, maxTotalUncompressedSize: 1024 * 1024 * 1024 }, // rejectTraversal: true and rejectSymlinks: true are the DEFAULTS. }); ``` Creating archives (v0.2): ```ts import { createZip } from 'zipnative'; const zip = createZip({ // Pin the pure-TS encoder: identical inputs → identical SHA-256, // on every runtime. See docs/guides/determinism.md. compression: { deterministic: true }, }); zip.add('manifest.json', JSON.stringify(manifest)); zip.add('assets/logo.png', logoBytes, { compression: { method: 'store' } }); zip.addDirectory('assets'); const bytes = zip.toBytes(); // sync, buffered // — or, with bounded memory (serverless/Workers), byte-identical output: for await (const chunk of zip.stream({ chunkSize: 64 * 1024 })) { // send chunk... } // Large content from an async source (data-descriptor layout): zip.addStream('video.bin', chunkSource); ``` Modifying an existing archive (v0.4): ```ts import { createZipModifier, openZip } from 'zipnative'; const modifier = createZipModifier(openZip(bytes)); modifier.replaceEntry('word/document.xml', newDocumentXml); modifier.addEntry('docProps/custom.xml', customProps); modifier.removeEntry('word/obsolete.xml'); // Append-only: untouched entries are never recompressed; the original // bytes are preserved verbatim (removed content stays recoverable!). const updated = modifier.save(); // True deletion + compact canonical layout, still no recompression: const compacted = modifier.saveCompact(); ``` Parallel creation across worker threads (v0.5, `zipnative/worker`): ```ts import { createParallelZip } from 'zipnative/worker'; const zip = createParallelZip(); // pool sized from your cores, capped at 8 zip.add('a.bin', bigBufferA); // entries deflate concurrently zip.add('b.bin', bigBufferB); const bytes = await zip.toBytes(); // async — the one signature difference // Byte-identical to createZip() for the same inputs (per compression // tier; unconditional with compression: { deterministic: true }). // Worker failures degrade gracefully — the archive never fails for // infrastructure reasons. ``` Reading an unseekable stream (v0.5 — pipes, uploads, serverless bodies; a web `ReadableStream` or any `AsyncIterable` is accepted since v0.9): ```ts import { iterateZipEntries } from 'zipnative'; for await (const entry of iterateZipEntries(request.body)) { console.log(entry.header.name, entry.header.uncompressedSize); if (wanted(entry.header.name)) { for await (const chunk of entry.data()) { /* bounded memory */ } } else if (entry.header.compressedSize > 0) { await entry.skip(); } } // TRUST CAVEAT: forward iteration reads local headers alone — no central // directory cross-check. Use openZip() whenever the full archive is // available; it is the authoritative path. ``` Verifying an archive in one call (v0.9): ```ts import { verifyZip } from 'zipnative'; const report = verifyZip(bytes); // never throws for archive problems if (!report.ok) { // Structural refusal (report.error.code) or a failed entry — // machine-readable either way, built on the frozen err.code vocabulary. console.log(report.error?.code, report.entries.filter((e) => !e.crcMatch)); } // Encrypted / stream-only-codec entries are reported as skipped with a // reason — never faked as corruption. ``` **Bundler notes for `zipnative/worker`**: the worker script is resolved as `new URL('./zip-worker.js', import.meta.url)`, which Vite and webpack 5 detect and bundle automatically. If your bundler cannot (or your CSP restricts worker sources), pass `workerUrl` explicitly — e.g. `createParallelZip({ workerUrl: new URL('zip-worker.js', yourAssetBase) })` — pointing at a copy of the script served from your origin (locate it with `import.meta.resolve('zipnative/worker/zip-worker.js')` — a dedicated subpath export since 0.8). On runtimes without workers the same code runs entirely on the calling thread. Everything public is exported from the two entry points — `zipnative` and `zipnative/worker`; if it is not exported there, it is private. ## Security model zipnative treats every archive as untrusted input. The guards, their defaults and their CWE mappings are documented in [SECURITY.md](SECURITY.md). Highlights: - decompression output capped per entry and in total, with a compression-ratio bound (CWE-400/409); - path traversal rejected — `..` segments, absolute paths, drive letters, backslashes, NUL bytes, NTFS alternate data streams (CWE-22); - symlink entries rejected by default (CWE-59); - overlapping entries and central-directory/local-header disagreement rejected (parser-differential smuggling); - Zip64 sentinel spoofing cross-checked; ambiguous EOCD placement refused; - the engine never opens a socket, never touches the filesystem, and never evals. ## Conformance ZIP has no veraPDF — JHOVE never shipped a ZIP module, and no ISO/IEC 21320-1 validator existed. So zipnative ships both halves of the answer: **the first open clause-by-clause ISO/IEC 21320-1:2015 conformance validator** (`npm run validate:zip` — an independent raw parser, never the engine's own, checking the ISO-standardised ZIP profile the Library of Congress recognises), and an **Archivematica-grade differential extraction matrix** (`npm run test:interop` — six independent parsers extract and byte-compare zipnative's archives on Linux and Windows). Both gates are blocking in CI and re-run before every npm publish. Every archive zipnative writes conforms to the ISO profile; the full story — including why spec-valid ≠ safe — is in the [conformance guide](docs/guides/conformance.md). ## Known limitations - `iterateZipEntries` reads data-descriptor entries (flag bit 3) for plain deflate since v0.6 — including zipnative's own `addStream()` output and bsdtar-style archives. Still refused: store+bit3 (not self-delimiting), encrypted+bit3, and custom-codec+bit3; `skip()` on a bit-3 entry costs a full decompress-and-discard. - Codec injection (`setDeflateImpl`, `registerCodec`) on the main entry does not propagate to the `zipnative/worker` bundle (separate module state); parallel/sequential byte-identity is promised for the built-in tiers. - `save()` keeps every original byte: removed/replaced content remains recoverable in the output (use `saveCompact()` for true deletion); `saveCompact()` drops SFX prefixes; archives with duplicate entry names cannot be modified incrementally. - `addStream` entries beyond 4 GiB are rejected with a typed error — buffer via `add()`; the per-entry Zip64-streaming opt-in is designed (0.9 decision record in [ROADMAP.md](ROADMAP.md)) and lands post-1.0. Buffered entries, entry counts and archive offsets are fully Zip64. - Since v0.8.1, default extraction also refuses entries whose names are **Windows reserved device names** (`CON`, `NUL`, `COM1`…`LPT9` — CWE-67) or that collapse to nothing (`.`, `./`). Archives authored on POSIX systems containing files like `aux.h` therefore throw by default on **every** platform; pass `rejectTraversal: false` to skip such entries instead. - Without `CompressionStream` on the runtime (or when `deterministic: true` is requested), stream-entry compression buffers the entry before compressing — a documented memory caveat. - Number fields above `Number.MAX_SAFE_INTEGER` (≈ 9 PB) are rejected; the public API uses `number`, not `bigint`. - Default deflate output is byte-stable per environment but not across zlib builds; pin `compression: { deterministic: true }` for cross-runtime identity — the full contract lives in [docs/guides/determinism.md](docs/guides/determinism.md). ## What zipnative will NOT do - **No encryption, read or write, in 1.x.** ZipCrypto is cryptographically broken (Biham–Kocher); writing it would be harm dressed as a feature. AES (AE-2) may come in a later major behind an injected crypto provider. Encrypted entries are *detected* (`entry.isEncrypted`) and reads fail with a typed `ZipUnsupportedError`. - **No other archive formats.** No 7z, RAR, tar, gzip; no zstd/bzip2/LZMA codecs built in (the codec registry is the extension point). - **No multi-disk/spanned archives** — detected and refused cleanly. - **No filesystem I/O in the engine.** Extraction returns data plus sanitized paths; writing files to disk is the CLI's job. - **No archive repair/salvage** (rebuilding a central directory from local headers) — v1 errors cleanly instead of guessing. - **No network access, ever.** ## Ecosystem | Package | Purpose | Status | |---|---|---| | `zipnative` | core engine (this repo) | active | | `zipnative-cli` | command-line tool, agent-grade JSON contract | planned | | `zipnative-mcp` | MCP server for AI agents | planned | The core stays dependency-free by exiling every dependency-bearing integration to a satellite repo — the pdfnative ecosystem pattern. ## Development ```bash npm ci npm run typecheck:all # src + tests + scripts npm run lint npm run test:coverage npm run build npm run test:interop # validate generated archives with unzip/7z/Expand-Archive/jar ``` Conventions live in [AGENTS.md](AGENTS.md) and `.github/instructions/`. Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). ## Origin zipnative is the second library in the *native* family, applying the architecture proven by [pdfnative](https://github.com/Nizoka/pdfnative): zero dependencies, closure factories instead of classes, append-only incremental modification, a shared segment generator guaranteeing buffered and streaming output are byte-identical, determinism as a product feature, and CWE-tagged bounds on every untrusted-input loop. ## License [MIT](LICENSE) --- # Conformance and validation > ZIP has no veraPDF — so zipnative ships the closest thing: the first open > clause-by-clause ISO/IEC 21320-1:2015 validator, layered over a > six-parser differential extraction matrix. This page is the authority > story: what "valid" means for a ZIP archive, who checks it, and what a > pass actually proves. ## Why there was no veraPDF for ZIP PDF/A has veraPDF: an authoritative validator that checks a closed ISO constraint list. ZIP has nothing comparable, for two verifiable reasons: - **JHOVE has no ZIP module.** The digital-preservation validator's module list (v1.34, 2025) covers AIFF, GIF, HTML, JPEG, PDF, TIFF, WAVE, XML and friends — not ZIP. A ZIP fed to JHOVE falls through to BYTESTREAM, which reports *every* byte sequence as well-formed: a gate that cannot fail. - **`zip -T` is an alias.** Info-ZIP's own manual documents `-T` as running `unzip -tqq` on the archive — it adds no validation power over `unzip -t`, which itself only re-reads entries and checks CRCs. What the archival community actually does is telling: Archivematica, the reference preservation workflow, validates ZIP packages by **independent extraction plus fixity** — exactly the differential matrix zipnative has run since v0.2.0. And the ISO-standardised ZIP profile, **ISO/IEC 21320-1:2015 (Document Container File)** — recognised by the Library of Congress (fdd000361) and the basis of what OOXML and EPUB constrain — had **no open-source validator at all**. So zipnative built one. ## Level 0 — the ISO/IEC 21320-1:2015 validator `npm run validate:zip` ([scripts/validate-zip.ts](https://github.com/Nizoka/zipnative/blob/main/scripts/validate-zip.ts)) checks every generated sample archive against the standard's closed constraint list, veraPDF-style: each failure is tagged with the clause that rejects it (`ISO21320-1/APPNOTE-4.4.5`, `WF/LFH-NAME-MISMATCH`, …). **Independent by construction**: the validator raw-parses the bytes with its own EOCD/central-directory/local-header reader and never imports zipnative's engine — a validator that shared the engine's parser would attest the engine with the engine. Two families of checks: 1. **APPNOTE well-formedness** — the cross-checks lenient extractors skip: central directory ↔ local header agreement (method, name, sizes, CRC), exact offsets, entry counts, overlapping entries, data-descriptor validation against the authoritative central-directory values. 2. **The ISO profile** — the normative annotations ISO/IEC 21320-1 applies to PKWARE APPNOTE 6.3.3: | Clause | Constraint | |---|---| | 4.3.3 / 4.4.1.5 | No multi-volume, split or spanned archives | | 4.3.6 / 4.3.8 | No encryption of file data | | 4.3.9.6 / 4.3.10 | No central-directory encryption, no archive decryption header | | 4.3.13 | No digital-signature record | | 4.4.3 | Version needed to extract ≤ 45; ZIP64 version 1 may be used, version 2 shall not | | 4.4.4 | General-purpose bits 0, 4–10 and 12–15 shall not be set (bit 3, data descriptors, **is** permitted); non-ASCII names/comments require bit 11, and bit 11 requires valid UTF-8 | | 4.4.5 | Compression method 0 (stored) or 8 (deflated) only | | Note 1 | Volume labels, Deflate64, DCL Implode and patched data are excluded | The rows group the standard's clauses; the emitted tags name the closest structural clause (entry encryption fires as `APPNOTE-4.3.8`, archive-decryption structures as `APPNOTE-4.3.10`, masked headers under the forbidden bits of `APPNOTE-4.4.4`). The standard's remaining Table-1 rows disregard whole APPNOTE sections (manifest files, the encryption chapters) and need no byte-level check. **Every archive zipnative writes conforms to this profile** — validated over the full sample corpus (29 conformant archives), enforced by blocking gates on Linux and Windows CI and re-run before every npm publish. The expectations are two-sided: the corpus also carries **4 deliberately non-conformant archives** (from the [refusals corpus](security.html) and the forward-trust sample) that MUST fail with their declared clause — proof the gate can reject. A coverage canary pins both counts against `docs/assets/ecosystem.json`, so a sample can neither appear nor vanish silently. ### Conformant does not mean safe Three of the hostile refusal archives — zip-slip, a Windows device name, and duplicate paths — are **perfectly ISO-conformant**: the standard constrains the container's structure, not the meaning of entry names. That is precisely why zipnative's [secure-by-default extraction guards](security.html) exist on top of conformance, and why the validator prints `conformant but refused by zipnative` for them instead of pretending the profile catches what it does not. ## Level 1 — foreign integrity pass The same run re-tests every conformant sample with the independent integrity checkers available on the machine (`unzip -t`, `7z t`, `python -m zipfile -t`, `tar -tf`, `jar tf`). Absent tools are reported as SKIP — never simulated. The Linux CI runner has all of them; the Windows runner contributes 7-Zip, bsdtar, Python and jar. Exit codes are read per each tool's own contract: Info-ZIP unzip's documented exit 1 ("warning errors … but processing completed successfully anyway" — fires on an empty zipfile and on SFX prefixes) counts as a pass; codes where the tools put real format and CRC errors fail. Tool limitations are carried as documented per-file exclusions rather than loosened exit codes: bsdtar on Windows mangles non-ASCII names, and 7-Zip's CLI (23.01 and 26.02 alike) refuses archives it must open with an offset — SFX prefixes — in `t` and `x` both; unzip, bsdtar, Python and jar extract that same archive byte-identically in the level-2 matrix, so the content stays proven. One exclusion is a finding in its own right: on an **append-only updated archive** (the incremental `save()` layout, where the superseded central directory legitimately remains inside the file), 7-Zip extracts the **stale** replaced payload and misses the appended entry — it does not honour the authoritative final central directory the way the five other tools and zipnative's strict reader do. That is the multi-reader parser-differential this project's security model warns about, observed on a mainstream tool; consumers of incremental output that must interoperate with 7-Zip should ship `saveCompact()` output instead. ## Level 2 — the differential extraction matrix `npm run test:interop` ([scripts/run-interop.ts](https://github.com/Nizoka/zipnative/blob/main/scripts/run-interop.ts)) is the empirical layer, blocking since v0.2.0: an 11-case archive matrix built through the public API is extracted and **byte-compared** by every available mainstream extractor (PowerShell `Expand-Archive`, bsdtar, Info-ZIP unzip, 7-Zip, Python `zipfile`, `jar`) on Linux and Windows — and in the other direction, zipnative reads foreign producers' archives byte-for-byte. This is the same validation posture Archivematica applies to ZIP packages: independent extraction and fixity, across six parser implementations. ## Running it locally ```bash npm run test:generate # writes the 33-sample corpus to test-output/ npm run validate:zip # levels 0 + 1 (ISO profile + foreign integrity) npm run test:interop # level 2 (differential extraction matrix) ``` `validate:zip` needs no external installation — level 0 is pure byte parsing; level 1 uses whatever integrity tools your machine has and skips the rest visibly. ## Where it blocks - `.github/workflows/conformance.yml` — both gates, on Linux **and** Windows, on every engine-touching push and pull request. - `.github/workflows/publish.yml` — both gates re-run between the GitHub Release and `npm publish`: a release can never publish samples the reference validator rejects. --- # The zipnative determinism contract > Reproducible output is a product feature with a written contract: what is > guaranteed always, what is guaranteed per environment, and what > `compression: { deterministic: true }` pins bit-for-bit on every runtime. This document is the contract; it was written down *before* anyone depended on it, and the golden tests ([tests/core/zip-determinism.test.ts](https://github.com/Nizoka/zipnative/blob/main/tests/core/zip-determinism.test.ts), [tests/codecs/deflate-pure.test.ts](https://github.com/Nizoka/zipnative/blob/main/tests/codecs/deflate-pure.test.ts)) enforce it byte-for-byte. ## The three levels of guarantee 1. **Structurally deterministic — always.** Entry order, header layout, flags, attributes, timestamps and Zip64 decisions never depend on the clock, randomness, locale or environment. Two `createZip` runs with identical inputs produce structurally identical archives everywhere. 2. **Bitwise deterministic per environment — the default.** Deflate compresses through the best available tier (`node:zlib`, or the pure-TS encoder elsewhere), so bytes are stable for a given runtime + zlib build, but may differ across environments. 3. **Bitwise deterministic everywhere — `compression: { deterministic: true }`.** The pure-TS encoder is pinned: `SHA256(A) === SHA256(B)` for identical inputs on every runtime (Node, browsers, Deno, Bun, Workers). This is the mode for content addressing, reproducible builds, caching and signatures. ## Canonicalization rules (level 1, always) | Aspect | Rule | |---|---| | Entry order | sorted by raw UTF-8 name bytes, unsigned bytewise (`order: 'insertion'` preserves call order — still deterministic given identical calls) | | Timestamps | DOS epoch `1980-01-01 00:00:00` unless a `Date` is given; `'now'` emits `ZIP_TIMESTAMP_NOT_PINNED` | | Name encoding | always UTF-8 with flag bit 11, including pure-ASCII names | | version-made-by | constant `0x032D` (Unix, spec 4.5) | | versions-needed | 20, or 45 exactly when the entry uses Zip64 | | External attributes | files `0o100644 << 16`, directories `(0o40755 << 16) \| 0x10` | | Internal attributes | 0 | | Extra fields | none, except Zip64 (0x0001) exactly when a field overflows, carrying exactly the overflowed fields in spec order; caller-supplied `extraFields` are embedded verbatim (their determinism is the caller's) and bounded — `maxExtraFieldBytes` plus a hard 65535 structural cap (the u16 header field), enforced at plan time since 0.8.1 | | Method selection | empty content is stored; deflate falls back to store when it does not shrink the payload (a pure function of the content) | | Zip64 records | emitted exactly when a classic field overflows; classic EOCD sentinels only the overflowed fields | | Data descriptors | never on buffered entries; always on `addStream` entries | ## The frozen encoder contract (level 3) Under `deterministic: true`, deflate output bytes are produced by [src/codecs/deflate-pure.ts](https://github.com/Nizoka/zipnative/blob/main/src/codecs/deflate-pure.ts) and every constant in that file is frozen public API: - hash function `imul(3-byte window, 0x9E3779B1) >>> 17` over a 32 KiB window with head/prev chains; - zlib's level configuration table (good/lazy/nice/chain for levels 1–9); - unified one-step-lazy matching, deferred match wins ties, `TOO_FAR = 4096`; - 65 534-symbol blocks, hash history never reset across blocks; - Huffman construction: two-queue merge over leaves sorted (frequency ascending, symbol ascending), leaf preferred on equal cost, zlib overflow fix at 15 bits (7 for the code-length tree), reassignment in the same sorted order; - block choice at exact bit cost with the tie order stored ≤ fixed ≤ dynamic. **Changing any of these changes emitted bytes and is a semver-major release.** The golden SHA-256 tables in the test suite are the tripwire. ## Documented determinism losses | Situation | Effect | Signal | |---|---|---| | Default codec tier (no `deterministic: true`) | bytes vary across zlib builds | `ZIP_NONDETERMINISTIC_CODEC` (info; emitted when an explicit date is pinned but the codec is not) | | `defaultDate: 'now'` | bytes vary per run | `ZIP_TIMESTAMP_NOT_PINNED` (info) | | `addStream` vs `add` of identical content | different layout (data descriptor) | documented here; use `add()` when bytes must match | | Injected codec (`setDeflateImpl`) | caller-defined bytes | never used for `deterministic: true` | ## Verifying reproducibility ```ts import { createZip } from 'zipnative'; const build = () => { const zip = createZip({ compression: { deterministic: true } }); zip.add('data.json', payload); // identical inputs... return zip.toBytes(); }; // ...identical bytes, on any runtime: sha256(build()) === sha256(build()); ``` --- # Errors and error codes > Every error zipnative throws carries a stable, machine-readable `code` — > branch on it, never on message text. The vocabulary below is frozen from > 0.8.0: removing or renaming a code is semver-major; additions are > semver-minor. ## The contract Every thrown error is a `ZipError` (or one of its five subclasses), and every one carries three stable signals: - **`err.code`** — a `ZipErrorCode` literal from the closed union in `src/types/zip-errors.ts`. This is the machine key: it survives message rewording and localization of your own wrappers. - **`err.name` / `instanceof`** — the class, for coarse routing (`ZipSecurityError` means an active-attack shape; `ZipLimitError` means a configurable bound fired). - **`err.message`** — always starts with `zipnative: ` and names the remedy. For humans and logs, never for branching. The machine-readable registry lives at [`docs/data/errors.json`](../data/errors.json) — one entry per code with its class, cause and remedy. The `error-parity` rule of `verify:docs` keeps registry, source unions and this guide in bidirectional sync. ```ts import { openZip, ZipError } from 'zipnative'; try { const reader = openZip(bytes); } catch (err) { if (err instanceof ZipError) { switch (err.code) { case 'ZIP_EOCD_NOT_FOUND': return notAZip(); case 'ZIP_LIMIT_EXCEEDED': return tooBig(err); case 'ZIP_PATH_TRAVERSAL': return quarantine(err); default: return fail(err.code, err.message); } } throw err; } ``` ## When to branch on `instanceof` vs `code` Use `instanceof` when the *policy* is per-class: everything `ZipSecurityError` goes to quarantine, everything `ZipLimitError` retries with raised limits on trusted input. Use `code` when the *cause* matters: `ZIP_CRC_MISMATCH` (corrupt payload) deserves a different message than `ZIP_DESCRIPTOR_MISMATCH` (hostile stream shape), though both are `ZipDataError`. ## The vocabulary ### `ZipError` (base — usage and invariant faults) | Code | Raised when | |---|---| | `ZIP_INVALID_OPTION` | An option value fails validation (compression level, chunk size, argument shape). | | `ZIP_INPUT_TOO_LARGE` | The pure-TS deflate encoder received more than 2 GiB in one call. | | `ZIP_ENTRY_NOT_FOUND` | A named entry is absent where one is required. Names are case-sensitive. | | `ZIP_ENTRY_EXISTS` | A named entry is present where absence is required (add over existing; rename onto existing). | | `ZIP_API_MISUSE` | A usage contract was violated: `toBytes()` with `addStream()` entries, drain-order violations, single-shot reuse. | | `ZIP_STRICT_DIAGNOSTIC` | `strict: true` escalated a conformance diagnostic; the message embeds the diagnostic code. | | `ZIP_INTERNAL` | An internal invariant broke — a zipnative bug; report it with a reproduction. | ### `ZipFormatError` (structurally invalid archives) | Code | Raised when | |---|---| | `ZIP_EOCD_NOT_FOUND` | No self-consistent end-of-central-directory record: not a ZIP, truncated, or hostile trailing bytes (zipnative refuses to guess). | | `ZIP_EOCD_INCONSISTENT` | The EOCD contradicts the layout: entry counts disagree, or the central directory overlaps the record. | | `ZIP_ZIP64_LOCATOR_MISSING` | Zip64 sentinels are set but the locator record is absent. | | `ZIP_ZIP64_EOCD_MISPLACED` | The zip64 EOCD is not where the locator points. | | `ZIP_CD_INCONSISTENT` | The central-directory walk contradicts its declared counts or size. | | `ZIP_RECORD_TRUNCATED` | A record or an entry's payload overruns the available bytes. | | `ZIP_SIGNATURE_MISMATCH` | An expected PK signature is absent at a declared position. | | `ZIP_STREAM_TRUNCATED` | A forward byte stream ended mid-record or mid-entry. | | `ZIP_VALUE_UNREPRESENTABLE` | A 64-bit field exceeds `Number.MAX_SAFE_INTEGER`. | | `ZIP_INVALID_ENTRY_NAME` | A writer-side name violates the rules (empty, NUL, backslash, absolute, `..`). | | `ZIP_DUPLICATE_ENTRY_NAME` | Duplicate names where uniqueness is required (writer `add()`, modifier source archives). | | `ZIP_DEFLATE_TRUNCATED` | A deflate stream ends mid-block. | | `ZIP_DEFLATE_CORRUPT` | A deflate stream is structurally invalid (Huffman codes, symbols, back-references, block types). | ### `ZipSecurityError` (active-attack shapes — CWE-tagged) | Code | CWE | Raised when | |---|---|---| | `ZIP_ENTRY_OVERLAP` | CWE-405 | Two entries share bytes. Always rejected; no opt-out. | | `ZIP_CD_LFH_MISMATCH` | CWE-436 | Local header contradicts the central directory on the method. | | `ZIP_ZIP64_CONTRADICTION` | CWE-1288 | A zip64 value contradicts a non-sentinel classic field. | | `ZIP_PATH_TRAVERSAL` | CWE-22 / CWE-67 | An entry name escapes the extraction root (zip-slip) or is a Windows reserved device name (`CON`, `NUL`, `COM1`…). | | `ZIP_SYMLINK_REJECTED` | CWE-59 | A symlink entry under `rejectSymlinks` (the default). | | `ZIP_EXTRACT_DUPLICATE_PATH` | CWE-694 | Duplicate output paths under `onDuplicate: 'error'` (the default). | ### `ZipDataError` (content integrity) | Code | Raised when | |---|---| | `ZIP_CRC_MISMATCH` | Decompressed bytes fail the declared CRC-32; `expectedCrc`/`actualCrc` carry both values. | | `ZIP_SIZE_MISMATCH` | Sizes contradict: declared vs measured, or local vs central metadata. | | `ZIP_INFLATE_OUTPUT_OVERFLOW` | Inflate produced more than the declared or permitted output. | | `ZIP_DESCRIPTOR_MISMATCH` | No data-descriptor form matches the measured CRC and sizes of a bit-3 entry. | | `ZIP_DECOMPRESSION_FAILED` | The active codec failed mid-decompression on a corrupt payload. | ### `ZipLimitError` (configurable security bounds) Carries `limit` (the `ZipLimits` key), `configured` and `observed`. | Code | Raised when | |---|---| | `ZIP_LIMIT_EXCEEDED` | A configured bound was exceeded — raise `limits.` explicitly if the archive is trusted. | | `ZIP_LIMIT_INVALID` | The limits override itself is invalid (unknown key, non-positive value); `configured`/`observed` are `NaN`. | ### `ZipUnsupportedError` (deliberate refusals) Carries `feature` from the closed `ZipUnsupportedFeature` vocabulary: `'zipcrypto'`, `'strong-encryption'`, `'multi-disk'`, `'zip64-streaming'`, `'cd-less-descriptor'`, or `` `method:${n}` ``. | Code | Raised when | |---|---| | `ZIP_UNSUPPORTED_ENCRYPTION` | An entry is encrypted — unsupported in 1.x by policy; check `entry.isEncrypted` to route around it. | | `ZIP_UNSUPPORTED_METHOD` | A compression method has no registered codec — `registerCodec()` one. | | `ZIP_UNSUPPORTED_MULTI_DISK` | The archive is multi-disk/spanned — an explicit anti-goal. | | `ZIP_UNSUPPORTED_ZIP64_STREAMING` | An `addStream()` entry exceeds 4 GiB — buffer via `add()` or split. | | `ZIP_UNSUPPORTED_CD_LESS_DESCRIPTOR` | Forward reading met a bit-3 entry it cannot delimit (store/encrypted/custom codec) — use `openZip()`. | | `ZIP_UNSUPPORTED_CODEC_MODE` | A registered codec supports only the other access mode — the message names the compliant call. | ## Diagnostics are not errors Non-fatal conformance concerns (odd-but-tolerated shapes, determinism losses) never throw by default — they flow through the diagnostics channel with their own closed 11-code vocabulary (`ZipDiagnosticCode`), documented alongside the errors in [`docs/data/errors.json`](../data/errors.json). `strict: true` escalates the first diagnostic into a thrown `ZipError` with code `ZIP_STRICT_DIAGNOSTIC`; `onDiagnostic` receives every diagnostic un-deduplicated. ## See also - [Security model](security.html) — the threat table behind the `ZipSecurityError` and `ZipLimitError` families. - [The determinism contract](determinism.html) — the diagnostics that flag reproducibility losses. --- # Quickstart > Install zipnative and cover the four core workflows — reading with random > access, secure extraction, deterministic creation, and incremental > modification — in about five minutes. ## Install ```bash npm install zipnative ``` Node ≥ 22, browsers, Deno, Bun and Workers share one API. Zero runtime dependencies — what you install is what runs. ## Read with random access ```ts import { openZip } from 'zipnative'; const zip = openZip(bytes); // lazy: only the trailer is located console.log(zip.entryCount); for (const entry of zip.entries()) { console.log(entry.name, entry.uncompressedSize); } const manifest = zip.readEntry('manifest.json'); // one entry, CRC-verified for await (const chunk of zip.readEntryStream('video.bin')) { // bounded memory for large entries } ``` ## Extract securely ```ts import { extractZip } from 'zipnative'; const files = extractZip(bytes); // Traversal, symlinks, duplicate names and decompression bombs are // rejected BY DEFAULT — opting out is always explicit. for (const file of files) { console.log(file.path, file.data.length); // path is sanitized, relative } ``` The engine never touches a filesystem; join `file.path` under your own root (see the [security guide](security.html)). ## Create — reproducibly ```ts import { createZip } from 'zipnative'; const zip = createZip({ compression: { deterministic: true } }); zip.add('data.json', JSON.stringify(payload)); zip.add('raw.bin', bytes, { compression: { method: 'store' } }); const archive = zip.toBytes(); // sync // or, with bounded memory and byte-identical output: for await (const chunk of zip.stream()) { /* send */ } ``` Identical inputs give identical SHA-256 on every runtime — the [determinism contract](determinism.html). ## Modify without recompressing ```ts import { createZipModifier, openZip } from 'zipnative'; const mod = createZipModifier(openZip(bytes)); mod.replaceEntry('config.json', '{"version":2}'); mod.removeEntry('obsolete.log'); const updated = mod.save(); // append-only: untouched entries untouched const compact = mod.saveCompact(); // true deletion, still no recompression ``` Note: `save()` keeps every original byte — removed content remains recoverable; `saveCompact()` is the deletion path. ## Verify in one call ```ts import { verifyZip } from 'zipnative'; const report = verifyZip(bytes); // never throws for archive problems console.log(report.ok, report.entryCount); // Structural refusals land in report.error ({ code, message }); // per-entry results carry crcMatch / sizeMatch / localHeaderMatch; // encrypted and stream-only-codec entries are reported as skipped // with a reason — never faked as corruption. ``` ## Going further - Parallel creation across worker threads: `import { createParallelZip } from 'zipnative/worker'`. - Reading unseekable streams: `iterateZipEntries(source)` — local headers only, so prefer `openZip()` whenever the whole archive is available. - Every thrown error carries a stable machine-readable `err.code` (39 frozen codes) — branch on it, never on message text: see [Errors and error codes](errors.html). The message itself still starts with `zipnative:` and names the remedy. - Four production architectures, with diagrams and honest limits: [Use cases](use-cases.html). Try the engine live in your browser: [Playgrounds](../playgrounds/). --- # Security model > zipnative treats every archive as untrusted input: the default code path > is the safe one, every parser loop runs under a named CWE-tagged bound, > and ambiguity is refused rather than guessed at. ## Why ZIP needs this ZIP is an attacker-friendly format: parsed from the end, tolerant of leading and trailing garbage, metadata duplicated between the central directory and local headers, and 16/32/64-bit size fields mixed freely. Most historical archive CVEs are parser differentials or resource exhaustion — both are addressed structurally here. ## The guards | Threat | Defence | CWE | |---|---|---| | Zip-slip traversal (`../`, absolute, drive letters, NTFS streams) and Windows reserved device names (`CON`, `NUL`, `COM1`…) | `rejectTraversal: true` by default; `sanitizeEntryPath()` for external sinks | CWE-22 / CWE-67 | | Decompression bombs | per-entry and total output caps, ratio bound, entry-count cap — enforced *during* inflation | CWE-400/409 | | Symlink entries | `rejectSymlinks: true` by default | CWE-59 | | Overlapping entries | always-on region-boundary checks | CWE-405 | | Central-vs-local header differentials | the central directory is authoritative; method/size divergence is fatal | CWE-436 | | Ambiguous EOCD / trailing garbage | only a self-consistent record closest to EOF is accepted | — | | Zip64 sentinel spoofing | cross-checks against every non-sentinel classic field | CWE-1288 | | Duplicate names | `onDuplicate: 'error'` by default | CWE-694 | Every bound lives on `ZipLimits`, is documented, and is caller-configurable — raising one is an explicit decision, never a silent default. ## The forward reader's trust caveat `iterateZipEntries()` reads local headers **alone** — there is no central directory to cross-check names, sizes or methods, so a hostile archive can present different content there than `openZip()` authoritatively reports. Use it only for streams you cannot seek, and never feed its names to a filesystem without `sanitizeEntryPath()`. ## What the engine never does No filesystem access, no sockets, no `eval`, no runtime dependencies — the supply chain is one repository, watched by CodeQL, OpenSSF Scorecard and an adversarial fuzzing suite on Linux and Windows. ## Reporting Privately, via GitHub Security Advisories — see [SECURITY.md](https://github.com/Nizoka/zipnative/blob/main/SECURITY.md). --- # Use cases > **Four production architectures, each built from parts zipnative > already ships** — one-entry reads that never touch the payload, > extraction behind always-on guards, byte-reproducible release > artifacts, and forward streaming with no Node in sight. Every arrow in > the diagrams below is a public, shipped API — nothing here is > aspirational. The [quickstart](quickstart.html) tells you *what* each call does; this guide shows *how they compose*. Each case names its exact building blocks, shows the load-bearing code, and states its limits. ## Case 1 — The manifest peek Most pipelines that "check an archive" decompress all of it to read one file: the `[Content_Types].xml` of a DOCX, the `extension.vsixmanifest` of a VSIX, the `manifest.json` of a build output. At ten thousand entries that is gigabytes of inflate work to answer a two-kilobyte question. `openZip()` inverts the cost model: the central directory is parsed lazily as the index it already is, payloads stay zero-copy subarray views, and **only the one entry you read is ever decompressed — CRC-verified, with every security check still on**. ![Architecture: a stored artifact archive of ten thousand entries flows into zipnative's openZip, which walks only the central directory lazily with zero-copy views, then readEntry pulls the single manifest entry, CRC-verified. The output is the two-kilobyte answer used to classify, index or route the archive. A dashed loop notes that everything else in the archive is never decompressed. A green band states the cost is proportional to the central directory plus the one entry, with security checks always on; a closing band states the honest limit — openZip needs the archive bytes in memory, remote range transport is the caller's concern.](../assets/use-case-manifest-peek.svg) ```ts import { openZip, ZipError } from 'zipnative'; const zip = openZip(bytes); // lazy: only the trailer is parsed const entry = zip.getEntry('[Content_Types].xml'); if (entry === null) return reject('not an OOXML package'); const manifest = zip.readEntry(entry); // ONE entry inflated, CRC-verified route(classify(manifest)); // the other 9 999 stay compressed ``` What you gain, concretely: - **Latency** — classifying is O(central directory) + O(one entry), not O(archive). On the 10 000-entry benchmark corpus, inventory plus one read is the scenario zipnative wins outright. - **Memory** — payloads are `subarray` views of the input; nothing is copied until you decompress it. - **Safety** — the peek runs behind the same overlap detection, CD/local-header cross-validation and CWE-tagged limits as a full extraction. A hostile archive fails with a stable `err.code`, not with a wrong answer. Honest limits: `openZip()` needs the archive bytes in memory — fetching a remote tail with HTTP range requests is your transport's job (the engine never opens a socket), and archives above `Number.MAX_SAFE_INTEGER` offsets are refused rather than approximated. ## Case 2 — Untrusted-upload intake Accepting user archives is the classic parser-attack surface: zip-slip paths, decompression bombs, symlink redirects, overlapping entries, Zip64 field spoofing. The usual mitigation is a checklist the intake service must remember to implement. zipnative inverts the default: **`extractZip()` refuses all of it out of the box, and every refusal throws a typed error whose stable [`err.code`](errors.html) routes the archive to quarantine without string matching**. ![Architecture: a user upload, hostile until proven otherwise, flows into zipnative's extractZip with its defaults on — zip-slip, decompression bombs, symlinks, overlapping entries and Zip64 spoofing are all checked. Two branches leave the guard box: the green path, when every guard passes, yields clean path-and-data pairs joined under the caller's own root; the red path, when any guard trips, throws a typed error whose stable err.code routes the archive to quarantine with an audit log. An indigo card notes the CWE-tagged ZipLimits bounds are raised only explicitly. The closing band states the honest limit — extraction is in-memory; writing to disk is the sink's job.](../assets/use-case-untrusted-intake.svg) ```ts import { extractZip, ZipError } from 'zipnative'; try { const files = extractZip(upload, { limits: { maxTotalUncompressedSize: 512 * 1024 * 1024 }, // explicit, audited }); for (const f of files) store(join(ROOT, f.path), f.data); // path is sanitized } catch (err) { if (err instanceof ZipError) { quarantine(upload, err.code); // ZIP_PATH_TRAVERSAL, ZIP_LIMIT_EXCEEDED… audit.log({ code: err.code, message: err.message }); return; } throw err; } ``` The dispatch above is the whole intake policy: security refusals carry codes like `ZIP_PATH_TRAVERSAL` or `ZIP_ENTRY_OVERLAP` (`ZipSecurityError`), resource refusals carry `ZIP_LIMIT_EXCEEDED` with the offending `limit` key and both values — so the audit log is machine-readable for free. Honest limits: extraction is in-memory — the engine never touches a filesystem, so writing to disk (and choosing the root) is your sink's job; `sanitizeEntryPath()` is exported for external sinks that need the same path rules. Encrypted entries are detected, never decrypted. ## Case 3 — The reproducibility gate A release archive that cannot be rebuilt byte-for-byte cannot be audited: nobody can prove the published artifact matches the reviewed commit. Zip tools leak timestamps, entry order and encoder versions into their output, so "rebuild and compare" normally fails for boring reasons. With `deterministic: true`, zipnative's output bytes depend on the input files and nothing else — **so one committed SHA-256 turns the release artifact into an asserted artifact, on any CI runner**. ![Architecture: the source tree at a commit flows into zipnative's createZip with deterministic true — pinned pure-TS encoder, epoch timestamps, canonical entry order — producing a release archive whose bytes depend only on its inputs. The archive's SHA-256 is computed and compared against the golden checksum committed in the repository. Two branches leave the comparison: the green path, on a match, publishes the artifact; the red path, on drift, fails the build — an unreviewed content change or a broken determinism contract has been caught. A green band states the guarantee: identical inputs give identical SHA-256 on every runtime, and changing the emitted bytes is a semver-major event. The closing band states the honest limit — the guarantee is scoped to deterministic true; the default tier is stable per environment only.](../assets/use-case-repro-gate.svg) ```ts import { createZip } from 'zipnative'; import { createHash } from 'node:crypto'; const zip = createZip({ compression: { deterministic: true } }); for (const [name, data] of releaseFiles) zip.add(name, data); const archive = zip.toBytes(); // same inputs → same bytes, anywhere const digest = createHash('sha256').update(archive).digest('hex'); if (digest !== GOLDEN_SHA256) { throw new Error(`release.zip drifted: ${digest} != ${GOLDEN_SHA256}`); } // promote the golden in-PR when intended ``` Changing the golden is a reviewed diff like any other; an *unintended* change — a file that slipped into the artifact, an encoder change — fails the build on every platform, because the pinned pure-TS encoder produces identical bytes on Linux, Windows and macOS runners alike. The contract is [written down](determinism.html) and golden-tested in zipnative's own suite; changing the emitted bytes is semver-major. Honest limits: the byte guarantee is scoped to `deterministic: true` — the default tier uses the platform's zlib and is byte-stable per environment only. `defaultDate: 'now'` opts out of reproducibility and says so with a diagnostic. ## Case 4 — Streaming intake at the edge Edge runtimes have no filesystem, tight memory, and no Node APIs — and uploads arrive as unseekable body streams, which rules out every ZIP library that wants a file or a full buffer. `iterateZipEntries()` reads the stream *forward*, entry by entry, in bounded memory — **and because the resumable pure-TS inflater reports exactly where each compressed stream ends, even data-descriptor archives (the shape streaming producers emit) delimit correctly without a central directory**. ![Architecture: a client uploads an archive as an unseekable body stream to an edge worker with no Node APIs. Inside the worker, zipnative's iterateZipEntries consumes the stream forward entry by entry — bounded memory, CRC verified, data-descriptor entries supported — while skip discards entries the route does not need without buffering them. Selected entries stream onward to object storage or a processing queue. A green band states the portability guarantee: the same API runs on Workers, Deno, Bun, browsers and Node, with zero dependencies and Fetch-native types. The closing band states the honest limit — forward reading trusts local headers alone; openZip on the complete archive remains the authoritative path.](../assets/use-case-edge-stream.svg) ```ts import { iterateZipEntries } from 'zipnative'; export default { async fetch(request: Request): Promise { let kept = 0; for await (const entry of iterateZipEntries(bodyChunks(request))) { if (!wanted(entry.header.name)) { await entry.skip(); continue; } await bucket.put(entry.header.name, collect(entry.data())); // CRC-verified kept++; } return Response.json({ kept }); // the archive was never held whole }, }; ``` The same code runs unchanged on Cloudflare Workers, Deno Deploy, Bun and Node ≥ 22 — zero dependencies means there is nothing to polyfill, and capability detection (not platform builds) picks the fastest available inflate tier. Honest limits: forward reading trusts local headers alone — there is no central directory to cross-check, so `openZip()` on the complete archive remains the authoritative path, and the [security guide](security.html) spells out the trust caveat. store+bit3 and encrypted+bit3 entries are structurally undelimitable and are refused with `ZIP_UNSUPPORTED_CD_LESS_DESCRIPTOR`. ## Picking parts, not a platform Each case is assembled from surfaces that also work alone, so none of them locks you in: the manifest peek (Case 1) is just `openZip` + `readEntry`; the reproducibility gate (Case 3) adds nothing but a hash of `createZip`'s output; the edge intake (Case 4) is one async iterator over the same entries the buffered reader would yield. Start with the case closest to your bottleneck and borrow pieces from the others. ## See also - [Quickstart](quickstart.html) — the four core workflows behind Cases 1–3, runnable in five minutes. - [Security model](security.html) — the threat table and CWE-tagged bounds Case 2 leans on, and Case 4's trust caveat in full. - [The determinism contract](determinism.html) — the three guarantee levels behind Case 3's one-hash assertion. - [Errors and error codes](errors.html) — the frozen `err.code` vocabulary Case 2 dispatches on. - [api.json](../assets/api.json) — the mechanically extracted export surface every case is built from.