runlocally

runlocally engineering notes

Recover ZIP

How Recover ZIP is built

By Geppetto · · Open Recover ZIP →

These are the engineering notes for Recover ZIP: the technologies it is built on, what each one is, and how it is used in the tool. This salvages the files a damaged ZIP still holds in readable form; it is not a repair that rebuilds a broken archive, and it cannot bring back bytes that are physically gone.

Tech used

Two layers: the index, then a raw scan

A healthy ZIP is read from its central directory — the index at the end of the file (from the Unzip notes). When that index is intact, Layer 1 verifies each entry through it. When the index itself is unreadable, Layer 2 ignores it entirely and reconstructs the entry list by scanning the raw bytes for the per-entry local file headers that precede each payload. The engine tries Layer 1 first and falls through to Layer 2 only when the central directory can’t be parsed or lists nothing.

Layer 1: verifying with @zip.js/zip.js getData({ checkSignature: true })

Layer 1 reads the directory with the @zip.js/zip.js ZipReader and decodes each entry with getData(new Uint8ArrayWriter(), { checkSignature: true }). The checkSignature flag is the point: without it, zip.js does not verify the CRC-32 and will hand back corrupted bytes as if they were fine. With it, an entry whose decoded bytes don’t match the stored checksum throws — which is exactly the signal a recovery tool needs to sort intact entries from damaged ones. Entries that decode cleanly are marked OK; entries that throw are marked broken but still offered for download with whatever bytes were salvaged from their local header.

DEFLATE and the local file header record

DEFLATE is the compression method almost every ZIP entry uses: LZ77 back-references plus Huffman coding, the same scheme as gzip. A ZIP stores it raw — no zlib or gzip wrapper — right after the entry’s local file header. That header is a 30-byte fixed record (signature PK\x03\x04, general-purpose flag, compression method, CRC-32, compressed and uncompressed sizes, name length, extra-field length) followed by the name, the extra field, then the compressed data. Rebuilding an entry from raw bytes means finding that 30-byte record, reading the two lengths to locate where the data starts, and reading the compressed-size field (or scavenging to the next PK record when the size is carried in a trailing data descriptor).

Layer 2: scanning headers, inflating with DecompressionStream

Layer 2 walks the whole buffer looking for PK\x03\x04 signatures, rejecting stray matches inside compressed data with cheap sanity filters (an implausible name length, a NUL byte in the name, or an out-of-range compression method). Each surviving header becomes an entry. STORED entries (method 0) are copied verbatim; DEFLATE entries (method 8) are inflated with DecompressionStream('deflate-raw') — a browser-native streaming decompressor from the Compression Streams API. It is a TransformStream you pipe the compressed bytes through, and 'deflate-raw' is precisely the headerless DEFLATE a ZIP stores, so no extra WebAssembly or library ships. Reading it chunk by chunk also means a truncated stream keeps whatever inflated before the error, rather than losing the entry entirely.

CRC-32 validation

CRC-32 is the 32-bit cyclic redundancy check ZIP stores per entry (the reflected IEEE polynomial 0xEDB88320). The salvage path carries a small, dependency-free, table-driven implementation: after decoding an entry it recomputes the checksum over the output and compares it to the value in the local header. A match means the bytes came out intact; a mismatch flags the entry as damaged while still keeping the decoded bytes available.

Shell

Same static Astro + Preact island and Service-Worker PWA shell as the other tools (see the HEIC notes).

Implementation & operational notes

Everything runs on the main thread, no worker. zip.js is configured with useWebWorkers: false here: decoding is deterministic, there is no separate worker chunk to precache for offline use, and the salvage path uses the native DecompressionStream regardless. No bytes are uploaded and no extra WASM is loaded.

Salvage, not guaranteed repair. The tool recovers what is still decodable; it does not rewrite a valid archive or invent missing data. A stream truncated early yields little — you get only what survives up to the cut. Entries in codecs beyond STORED and DEFLATE (bzip2, LZMA, zstd) and encrypted entries are listed but not decoded, since neither the native DecompressionStream nor the scan path can turn them back into content.

Sizes and descriptors. When a header’s compressed-size field is zero or won’t fit (its real sizes live in a trailing data descriptor), the scanner falls back to reading up to the next PK record boundary — the classic recovery heuristic — and marks the entry truncated when its data runs to the end of the buffer.

Try it / source