Reproducible builds
Measure, canonicalise, prove.
deterministic: truehas made zipnative's own output reproducible since 0.2; 1.1 extends the guarantee to archives you did not build — a Gradle JAR, a Maven artefact, an Explorer ZIP, azipfrom a CI image — with a report that says what would drift, a canonical rewrite that fixes it without recompression, and a transplant primitive that assembles artefacts from existing parts in O(bytes copied).
Why archives drift#
Two builds of the same sources rarely produce the same ZIP: tools write the
wall-clock modification time into every entry, enumerate the file system
in whatever order it returns, add tool-specific extra fields (Unix times,
NTFS times, uid/gid), and link against different zlib builds. Debian's
strip-nondeterminism, Gradle's preserveFileTimestamps = false +
reproducibleFileOrder = true and Maven's project.build.outputTimestamp
each solve it for one producer. zipnative solves it once, for any producer,
and ties the result to a written byte contract.
1. Measure — analyzeDeterminism()#
import { analyzeDeterminism } from 'zipnative';
const report = analyzeDeterminism(bytes);
report.deterministic; // the verdict
report.epochTimestamps; // every entry at the DOS epoch?
report.canonicalOrder; // raw name bytes ascending?
report.utf8Flags; // bit 11 on every non-ASCII name?
report.canonicalExtras; // no extra field other than Zip64?
report.canonicalVersionMadeBy;
report.noDataDescriptors; // informational — a streamed layout never fails the verdict
for (const { name, concern } of report.offenders) console.log(name, concern);
// concern: 'timestamp' | 'order' | 'utf8-flag' | 'extra-field' | 'version-made-by' | 'data-descriptor'
// Timestamps are checked against the DOS epoch; for an archive canonicalised
// with a pinned date, pass that date (and the dosTimeMode it was written with):
analyzeDeterminism(bytes, { date: pinned, dosTimeMode: 'utc' });
The report is what the CLI's inspect --check deterministic and the MCP
server's inspect_zip re-derived from entries() in 1.0.0; it throws like
openZip() when the bytes are not a well-formed archive (a report about an
archive that cannot be opened would be meaningless) and never for
conformance concerns.
2. Canonicalise — canonicalizeZip()#
import { canonicalizeZip } from 'zipnative';
const canonical = canonicalizeZip(bytes);
analyzeDeterminism(canonical).deterministic; // true
canonicalizeZip(canonical); // byte-identical: idempotent
The canonical form, applied to every entry of any archive:
| Aspect | Rule |
|---|---|
| Entry order | sorted by raw name bytes, unsigned bytewise — the writer's default order: 'canonical' |
| Timestamps | the DOS epoch, or one pinned date (dosTimeMode applies) |
| Name encoding | every name is decoded (CP437, UTF-8, or your nameDecoder) and re-encoded as UTF-8, with flag bit 11 set on every entry — ASCII included |
| version-made-by | the constant 0x032D |
| Extra fields | dropped, except Zip64 (0x0001), which is recomputed — and except on encrypted entries, which keep theirs (the 0x9901 AES record is part of the ciphertext's envelope), so an archive holding encrypted entries never reports deterministic: true |
| Comments | dropped unless keepComments: true (archive and entry comments alike) |
| External attributes | kept — Unix modes are content — unless keepExternalAttributes: false resets them to the writer's defaults |
| Payloads | copied bit for bit: no decompression, no recompression, no dependency on the codec that produced them |
| Layout | the compact layout of saveCompact(): no data descriptors, no SFX prefix, no dead bytes |
The same transformation is available on the modifier —
createZipModifier(openZip(bytes)).saveCompact({ canonical: true }) or
{ canonical: { date, keepComments, keepExternalAttributes } } — so an
edit and a canonical rewrite are one pass.
The canonical bytes are part of the frozen determinism contract. The
golden SHA-256 hashes in
tests/parser/zip-canonical.test.ts
pin the output of canonicalizeZip over two committed foreign fixtures;
changing them is a semver-major release, exactly like the pinned encoder's
bytes.
3. Prove — the gate#
import { createHash } from 'node:crypto';
import { analyzeDeterminism, canonicalizeZip } from 'zipnative';
const artefact = canonicalizeZip(await build()); // whatever produced it (add { date } to pin one — then pass the same date below)
if (!analyzeDeterminism(artefact).deterministic) throw new Error('canonical form broken');
const digest = createHash('sha256').update(artefact).digest('hex');
if (digest !== GOLDEN_SHA256) throw new Error(`artefact drifted: ${digest}`);
A canonical artefact compares byte-for-byte across runners and across the
tools that produced it. Pair it with the writer's deterministic: true
when zipnative is the producer (the pinned encoder makes the compressed
bytes identical too); canonicalisation keeps a foreign producer's
compressed bytes, so the digest holds as long as that producer's output
does — which is what strip-nondeterminism promises as well, and why the
report lists extra-field and timestamp concerns separately from the
codec.
4. Assemble — addRaw() and addFromReader()#
Build artefacts are often assembled from parts that already exist as archives: a JAR from a class archive and a resources archive, an OOXML document from its parts, a release bundle from per-platform builds. 1.1 transplants entries without decompressing them:
import { createZip, openZip } from 'zipnative';
const out = createZip({ compression: { deterministic: true } });
const classes = openZip(classesZip);
const resources = openZip(resourcesZip);
for (const entry of classes.entries()) out.addFromReader(classes, entry);
for (const entry of resources.entries()) out.addFromReader(resources, entry, { verify: false }); // trusted source
// A payload you compressed yourself, or lifted with readEntryRaw() (the
// compressed bytes as a Uint8Array): the entry carries the metadata.
const source = classes.getEntry('META-INF/MANIFEST.MF')!;
out.addRaw('META-INF/MANIFEST.MF', classes.readEntryRaw(source), {
method: source.compressionMethod, // 8 = deflate, 0 = store
crc32: source.crc32,
uncompressedSize: source.uncompressedSize,
});
const jar = out.toBytes(); // bytes are a function of the inputs and the call order under order: 'insertion'
addFromReader() copies method, CRC, sizes, timestamp, attributes, comment
and extra fields (Zip64 excluded — recomputed), and verifies the source
entry first: a corrupt entry is refused with ZIP_CRC_MISMATCH /
ZIP_SIZE_MISMATCH instead of propagated. verify: false trusts the
source. Entries whose method has no registered codec are copied opaquely
either way — verifyZip() later reports them skipped: 'unsupported-method'.
Encrypted entries are refused, as readEntryRaw() refuses them. Worker
pools never dispatch a raw entry; the sequential and parallel writers
emit identical bytes.
The in-memory reader is the source; a ZipRangeReader exposes
readEntryRaw() as a promise, so transplanting from a remote archive is a
two-step await zip.readEntryRaw(entry) then addRaw().
Timestamps and time zones#
An explicit Date is written as a local-time DOS field by default — the
Info-ZIP and 7-Zip convention, byte-compatible with 1.0. A build that
pins a date and must reproduce across machines sets dosTimeMode: 'utc'
on the writer (and on the reader, to decode the same instant back):
const zip = createZip({ defaultDate: new Date('2026-10-07T00:00:00Z'), dosTimeMode: 'utc' });
Out-of-range dates clamp — below 1980-01-01 to the epoch, above
2107-12-31 23:59:58 to that maximum, an invalid Date to the epoch — with
the ZIP_TIMESTAMP_CLAMPED diagnostic (fatal under strict); before 1.1
they produced a wrong date silently.
See also#
- The determinism contract — the three levels of guarantee, the frozen encoder, the canonical compaction rules.
- Use cases, Case 3 — the reproducibility gate with zipnative as the producer.
- The
determinism-reportrecipe, andzipnative inspect --check deterministicon the CLI.