Home › Guides › Reproducible builds

Reproducible builds

Measure, canonicalise, prove. deterministic: true has 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, a zip from 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#