// A minimal, deterministic ZIP writer and reader. // // Used by script/lib/package.js to build the distributable archives: a Chrome // zip and a Firefox XPI are both ordinary zip files with manifest.json at the // root, so one implementation covers both. // // Why this rather than a package or the zip(1) binary. A dependency would have // to be hash-pinned like everything else in REPO_POLICIES.md, and this is // about a hundred lines of stdlib zlib for a format the archives use two // features of. The binary is worse: the release artifact would then depend on // whichever Info-ZIP the machine happens to have, which is the same objection // that keeps linting inside a container. // // Deterministic on purpose. Entries are sorted by name, every timestamp is the // same fixed 1980-01-01 the format's epoch starts at, and the compression // level is fixed, so building the same dist/ twice produces byte-identical // archives and the sha256 in SHA256SUMS is a property of the input rather than // of the clock. Two builds of the same commit that disagree are then visible // instead of expected. // // Deliberately NOT implemented: zip64, encryption, data descriptors, // directory entries (browsers infer directories from member paths), and // anything to do with symlinks. writeZip refuses input it cannot represent // rather than emitting an archive that is quietly wrong. "use strict"; const zlib = require("zlib"); const LOCAL_SIG = 0x04034b50; const CENTRAL_SIG = 0x02014b50; const EOCD_SIG = 0x06054b50; const METHOD_STORE = 0; const METHOD_DEFLATE = 8; // 1980-01-01 00:00:00, the earliest the MS-DOS timestamp fields can express. const DOS_DATE = (0 << 9) | (1 << 5) | 1; const DOS_TIME = 0; // Unix regular file, mode 0644, in the high 16 bits, which is where the "made // by unix" convention puts it. // >>> 0 because JS shifts are signed 32-bit and this one sets the top bit. const EXTERNAL_ATTRS = (0o100644 << 16) >>> 0; const VERSION_MADE_BY = (3 << 8) | 20; // unix, needs zip 2.0 const VERSION_NEEDED = 20; // Without zip64 every size and offset is a u32. const MAX_U32 = 0xffffffff; const CRC_TABLE = (() => { const table = new Int32Array(256); for (let i = 0; i < 256; i++) { let c = i; for (let k = 0; k < 8; k++) { c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; } table[i] = c; } return table; })(); // Written out rather than taken from zlib.crc32, which only exists from node // 22.2: this runs from script/ on whatever node the host has as well as inside // the pinned image, and a checksum that silently is not there is worse than // twelve lines. function crc32(buf) { let c = -1; for (let i = 0; i < buf.length; i++) { c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); } return (c ^ -1) >>> 0; } // Member names are stored as raw bytes. Anything outside ASCII would need the // UTF-8 flag and interoperability care that nothing this repo emits requires, // so it is refused instead of guessed at. function encodeName(name) { if (typeof name !== "string" || name === "") { throw new Error(`zip: unusable member name ${JSON.stringify(name)}`); } const segments = name.split("/"); if ( name.startsWith("/") || name.includes("\\") || segments.some((s) => s === "" || s === "." || s === "..") ) { throw new Error( `zip: refusing member name ${JSON.stringify(name)}: archive ` + `members must be relative paths under the archive root`, ); } if (/[^\x20-\x7e]/.test(name)) { throw new Error( `zip: refusing non-ASCII member name ${JSON.stringify(name)}`, ); } return Buffer.from(name, "ascii"); } function compress(data) { if (data.length === 0) { return { method: METHOD_STORE, body: data }; } const deflated = zlib.deflateRawSync(data, { level: 9 }); if (deflated.length >= data.length) { return { method: METHOD_STORE, body: data }; } return { method: METHOD_DEFLATE, body: deflated }; } // entries: [{ name, data }]. Returns the archive as a Buffer. function writeZip(entries) { if (!Array.isArray(entries) || entries.length === 0) { throw new Error("zip: refusing to write an archive with no members"); } const sorted = [...entries].sort((a, b) => (a.name < b.name ? -1 : 1)); const seen = new Set(); const locals = []; const centrals = []; let offset = 0; for (const entry of sorted) { const name = encodeName(entry.name); if (seen.has(entry.name)) { throw new Error(`zip: duplicate member ${entry.name}`); } seen.add(entry.name); const data = Buffer.from(entry.data); const { method, body } = compress(data); if (data.length > MAX_U32 || body.length > MAX_U32) { throw new Error( `zip: ${entry.name} is too large for a non-zip64 archive`, ); } const crc = crc32(data); const local = Buffer.alloc(30 + name.length); local.writeUInt32LE(LOCAL_SIG, 0); local.writeUInt16LE(VERSION_NEEDED, 4); local.writeUInt16LE(0, 6); local.writeUInt16LE(method, 8); local.writeUInt16LE(DOS_TIME, 10); local.writeUInt16LE(DOS_DATE, 12); local.writeUInt32LE(crc, 14); local.writeUInt32LE(body.length, 18); local.writeUInt32LE(data.length, 22); local.writeUInt16LE(name.length, 26); local.writeUInt16LE(0, 28); name.copy(local, 30); const central = Buffer.alloc(46 + name.length); central.writeUInt32LE(CENTRAL_SIG, 0); central.writeUInt16LE(VERSION_MADE_BY, 4); central.writeUInt16LE(VERSION_NEEDED, 6); central.writeUInt16LE(0, 8); central.writeUInt16LE(method, 10); central.writeUInt16LE(DOS_TIME, 12); central.writeUInt16LE(DOS_DATE, 14); central.writeUInt32LE(crc, 16); central.writeUInt32LE(body.length, 20); central.writeUInt32LE(data.length, 24); central.writeUInt16LE(name.length, 28); central.writeUInt16LE(0, 30); central.writeUInt16LE(0, 32); central.writeUInt16LE(0, 34); central.writeUInt16LE(0, 36); central.writeUInt32LE(EXTERNAL_ATTRS, 38); if (offset > MAX_U32) { throw new Error("zip: archive too large for a non-zip64 archive"); } central.writeUInt32LE(offset, 42); name.copy(central, 46); locals.push(local, body); centrals.push(central); offset += local.length + body.length; } const centralBuf = Buffer.concat(centrals); const eocd = Buffer.alloc(22); eocd.writeUInt32LE(EOCD_SIG, 0); eocd.writeUInt16LE(0, 4); eocd.writeUInt16LE(0, 6); eocd.writeUInt16LE(sorted.length, 8); eocd.writeUInt16LE(sorted.length, 10); eocd.writeUInt32LE(centralBuf.length, 12); eocd.writeUInt32LE(offset, 16); eocd.writeUInt16LE(0, 20); return Buffer.concat([...locals, centralBuf, eocd]); } // Read an archive back into [{ name, data }], from the central directory // rather than by scanning for local headers: the central directory is the // authoritative index, and a member reachable only by scanning is one a real // unzipper would not extract. // // Every member's CRC is checked. The point of reading an archive back is to // establish that it holds what it was meant to hold, so a member that does not // decompress to its recorded checksum is a failure and never a warning. function readZip(buf) { if (buf.length < 22) { throw new Error("zip: too short to be an archive"); } // No archive this writes has a trailing comment, so the EOCD is the last // 22 bytes. Anything else is not an archive this produced. const eocdAt = buf.length - 22; if (buf.readUInt32LE(eocdAt) !== EOCD_SIG) { throw new Error( "zip: no end-of-central-directory record at the end of the " + "archive (a trailing comment, or not a zip at all)", ); } const count = buf.readUInt16LE(eocdAt + 10); const centralSize = buf.readUInt32LE(eocdAt + 12); let at = buf.readUInt32LE(eocdAt + 16); if (at + centralSize > eocdAt) { throw new Error("zip: central directory runs past the archive"); } const out = []; for (let i = 0; i < count; i++) { if (buf.readUInt32LE(at) !== CENTRAL_SIG) { throw new Error(`zip: bad central directory entry ${i}`); } const method = buf.readUInt16LE(at + 10); const crc = buf.readUInt32LE(at + 16); const compSize = buf.readUInt32LE(at + 20); const rawSize = buf.readUInt32LE(at + 24); const nameLen = buf.readUInt16LE(at + 28); const extraLen = buf.readUInt16LE(at + 30); const commentLen = buf.readUInt16LE(at + 32); const localAt = buf.readUInt32LE(at + 42); const name = buf.toString("ascii", at + 46, at + 46 + nameLen); at += 46 + nameLen + extraLen + commentLen; if (buf.readUInt32LE(localAt) !== LOCAL_SIG) { throw new Error(`zip: ${name} has no local header`); } // The local header's own name and extra lengths, not the central // directory's: the two are allowed to differ and the data starts after // the local ones. const localNameLen = buf.readUInt16LE(localAt + 26); const localExtraLen = buf.readUInt16LE(localAt + 28); const dataAt = localAt + 30 + localNameLen + localExtraLen; const body = buf.subarray(dataAt, dataAt + compSize); let data; if (method === METHOD_STORE) { data = Buffer.from(body); } else if (method === METHOD_DEFLATE) { data = zlib.inflateRawSync(body); } else { throw new Error(`zip: ${name} uses compression method ${method}`); } if (data.length !== rawSize) { throw new Error( `zip: ${name} decompressed to ${data.length} bytes, not the ` + `recorded ${rawSize}`, ); } if (crc32(data) !== crc) { throw new Error(`zip: ${name} fails its recorded CRC32`); } out.push({ name, data }); } return out; } module.exports = { crc32, readZip, writeZip };