From 018b0f5d74f23990ddf1ae2544bdad4586125df6 Mon Sep 17 00:00:00 2001 From: xCyanGrizzly Date: Thu, 23 Jul 2026 11:20:16 +0200 Subject: [PATCH] feat(worker): parse ZIP central directory from a tail buffer Co-Authored-By: Claude Opus 4.8 (1M context) --- worker/src/archive/central-directory.test.ts | 69 +++++++++++++++ worker/src/archive/central-directory.ts | 90 ++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 worker/src/archive/central-directory.test.ts create mode 100644 worker/src/archive/central-directory.ts diff --git a/worker/src/archive/central-directory.test.ts b/worker/src/archive/central-directory.test.ts new file mode 100644 index 0000000..0cc48b8 --- /dev/null +++ b/worker/src/archive/central-directory.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { parseZipCentralDirectoryFromTail } from "./central-directory.js"; +import { crc32 } from "zlib"; // Node 20+ exposes zlib.crc32 + +// Build a minimal STORE (no compression) ZIP in-memory with the given files. +function buildStoreZip(files: { name: string; data: Buffer }[]): Buffer { + const chunks: Buffer[] = []; + const central: Buffer[] = []; + let offset = 0; + for (const f of files) { + const crc = crc32(f.data) >>> 0; + const nameBuf = Buffer.from(f.name, "utf8"); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); // version needed + local.writeUInt16LE(0, 6); // flags + local.writeUInt16LE(0, 8); // method = store + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(f.data.length, 18); // compressed + local.writeUInt32LE(f.data.length, 22); // uncompressed + local.writeUInt16LE(nameBuf.length, 26); + local.writeUInt16LE(0, 28); // extra len + const localHeader = Buffer.concat([local, nameBuf, f.data]); + chunks.push(localHeader); + + const cd = Buffer.alloc(46); + cd.writeUInt32LE(0x02014b50, 0); + cd.writeUInt16LE(20, 4); cd.writeUInt16LE(20, 6); + cd.writeUInt16LE(0, 8); cd.writeUInt16LE(0, 10); + cd.writeUInt32LE(crc, 16); + cd.writeUInt32LE(f.data.length, 20); + cd.writeUInt32LE(f.data.length, 24); + cd.writeUInt16LE(nameBuf.length, 28); + cd.writeUInt32LE(offset, 42); // local header offset + central.push(Buffer.concat([cd, nameBuf])); + offset += localHeader.length; + } + const cdBuf = Buffer.concat(central); + const cdOffset = offset; + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(files.length, 8); + eocd.writeUInt16LE(files.length, 10); + eocd.writeUInt32LE(cdBuf.length, 12); + eocd.writeUInt32LE(cdOffset, 16); + return Buffer.concat([...chunks, cdBuf, eocd]); +} + +describe("parseZipCentralDirectoryFromTail", () => { + it("lists entries with correct names, sizes, and crc32", () => { + const zip = buildStoreZip([ + { name: "models/dragon.stl", data: Buffer.from("DRAGON") }, + { name: "readme.txt", data: Buffer.from("hello world") }, + ]); + const entries = parseZipCentralDirectoryFromTail(zip, 0); + expect(entries.map((e) => e.fileName).sort()).toEqual(["dragon.stl", "readme.txt"]); + const dragon = entries.find((e) => e.fileName === "dragon.stl")!; + expect(dragon.path).toBe("models/dragon.stl"); + expect(dragon.uncompressedSize).toBe(6n); + expect(dragon.crc32).toMatch(/^[0-9a-f]{8}$/); + }); + + it("throws when the central directory begins before the tail window", () => { + const zip = buildStoreZip([{ name: "a.txt", data: Buffer.alloc(100) }]); + // Provide only the last 30 bytes but claim they start at offset (len-30): + const tail = zip.subarray(zip.length - 30); + expect(() => parseZipCentralDirectoryFromTail(tail, zip.length - 30)).toThrow(RangeError); + }); +}); diff --git a/worker/src/archive/central-directory.ts b/worker/src/archive/central-directory.ts new file mode 100644 index 0000000..48f8d46 --- /dev/null +++ b/worker/src/archive/central-directory.ts @@ -0,0 +1,90 @@ +import path from "path"; +import type { FileEntry } from "./zip-reader.js"; + +export const MIN_ZIP_TAIL_BYTES = 65_557; + +const EOCD_SIG = 0x06054b50; +const CD_SIG = 0x02014b50; + +function extOf(name: string): string | null { + const e = path.extname(name).replace(/^\./, "").toLowerCase(); + return e === "" ? null : e; +} + +/** Parse a ZIP central directory from the tail of an archive. */ +export function parseZipCentralDirectoryFromTail(tail: Buffer, tailStart: number): FileEntry[] { + // 1. Find EOCD by scanning backward for its signature. + let eocd = -1; + for (let i = tail.length - 22; i >= 0; i--) { + if (tail.readUInt32LE(i) === EOCD_SIG) { eocd = i; break; } + } + if (eocd < 0) throw new RangeError("EOCD not found in tail"); + + let cdSize = tail.readUInt32LE(eocd + 12); + let cdOffset = tail.readUInt32LE(eocd + 16); + + // ZIP64: sizes/offsets of 0xFFFFFFFF mean "see ZIP64 EOCD". + if (cdOffset === 0xffffffff || cdSize === 0xffffffff) { + const locSig = 0x07064b50; + let loc = -1; + for (let i = eocd - 20; i >= 0; i--) { + if (tail.readUInt32LE(i) === locSig) { loc = i; break; } + } + if (loc < 0) throw new RangeError("ZIP64 EOCD locator not in tail"); + const z64Abs = Number(tail.readBigUInt64LE(loc + 8)); // absolute offset of ZIP64 EOCD + const z64 = z64Abs - tailStart; + if (z64 < 0) throw new RangeError("ZIP64 EOCD before tail window"); + cdSize = Number(tail.readBigUInt64LE(z64 + 40)); + cdOffset = Number(tail.readBigUInt64LE(z64 + 48)); + } + + // 2. Map the absolute central-directory offset into the tail buffer. + const cdLocal = cdOffset - tailStart; + if (cdLocal < 0 || cdLocal + cdSize > tail.length) { + throw new RangeError("Central directory begins before tail window"); + } + + // 3. Walk central-directory headers. + const entries: FileEntry[] = []; + let p = cdLocal; + const end = cdLocal + cdSize; + while (p + 46 <= end && tail.readUInt32LE(p) === CD_SIG) { + let crc = tail.readUInt32LE(p + 16) >>> 0; + let comp = BigInt(tail.readUInt32LE(p + 20)); + let uncomp = BigInt(tail.readUInt32LE(p + 24)); + const nameLen = tail.readUInt16LE(p + 28); + const extraLen = tail.readUInt16LE(p + 30); + const commentLen = tail.readUInt16LE(p + 32); + const name = tail.toString("utf8", p + 46, p + 46 + nameLen); + + // ZIP64 extra field overrides 0xFFFFFFFF sizes. + if (comp === 0xffffffffn || uncomp === 0xffffffffn) { + let ep = p + 46 + nameLen; + const extraEnd = ep + extraLen; + while (ep + 4 <= extraEnd) { + const id = tail.readUInt16LE(ep); + const sz = tail.readUInt16LE(ep + 2); + if (id === 0x0001) { + let fp = ep + 4; + if (uncomp === 0xffffffffn) { uncomp = tail.readBigUInt64LE(fp); fp += 8; } + if (comp === 0xffffffffn) { comp = tail.readBigUInt64LE(fp); fp += 8; } + } + ep += 4 + sz; + } + } + + const isDir = name.endsWith("/"); + if (!isDir) { + entries.push({ + path: name, + fileName: path.basename(name), + extension: extOf(name), + compressedSize: comp, + uncompressedSize: uncomp, + crc32: crc !== 0 ? crc.toString(16).padStart(8, "0") : null, + }); + } + p += 46 + nameLen + extraLen + commentLen; + } + return entries; +}