// zip-writer.mjs — Minimal stored-method (no compression) ZIP writer. // Zero dependencies. Deterministic output: fixed DOS timestamp, sorted entry order. // // Writes a valid ZIP that zip-extract.mjs can parse. Uses method=0 (STORE), // CRC-32 computed, no encryption, no ZIP64. Suitable for tiny test fixtures. import { createHash } from 'node:crypto'; // CRC-32 table (IEEE 802.3 polynomial). const CRC_TABLE = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) { c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); } t[n] = c >>> 0; } return t; })(); function crc32(buf) { let c = 0xFFFFFFFF; for (let i = 0; i < buf.length; i++) { c = (CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8)) >>> 0; } return (c ^ 0xFFFFFFFF) >>> 0; } const DOS_DATE = ((1980 - 1980) << 9) | (1 << 5) | 1; // 1980-01-01 const DOS_TIME = 0; // 00:00:00 /** * Build a ZIP buffer from a list of entries. * * @param {Array<{name: string, data: Buffer | string}>} entries * @returns {Buffer} */ export function createZip(entries) { // Normalize + sort for determinism const normalized = entries .map(e => ({ name: e.name, data: Buffer.isBuffer(e.data) ? e.data : Buffer.from(e.data, 'utf8'), })) .sort((a, b) => a.name.localeCompare(b.name)); const chunks = []; const centralHeaders = []; let offset = 0; for (const e of normalized) { const nameBuf = Buffer.from(e.name, 'utf8'); const c = crc32(e.data); const sz = e.data.length; // Local File Header (30 + nameLen) const lfh = Buffer.alloc(30); lfh.writeUInt32LE(0x04034b50, 0); // signature lfh.writeUInt16LE(20, 4); // version needed lfh.writeUInt16LE(0, 6); // flags lfh.writeUInt16LE(0, 8); // method = STORE lfh.writeUInt16LE(DOS_TIME, 10); lfh.writeUInt16LE(DOS_DATE, 12); lfh.writeUInt32LE(c, 14); // crc32 lfh.writeUInt32LE(sz, 18); // compressed size lfh.writeUInt32LE(sz, 22); // uncompressed size lfh.writeUInt16LE(nameBuf.length, 26); lfh.writeUInt16LE(0, 28); // extra field length chunks.push(lfh); chunks.push(nameBuf); chunks.push(e.data); const localOffset = offset; offset += 30 + nameBuf.length + sz; // Central Directory Header (46 + nameLen) const cdh = Buffer.alloc(46); cdh.writeUInt32LE(0x02014b50, 0); // signature cdh.writeUInt16LE(20, 4); // version made by cdh.writeUInt16LE(20, 6); // version needed cdh.writeUInt16LE(0, 8); // flags cdh.writeUInt16LE(0, 10); // method cdh.writeUInt16LE(DOS_TIME, 12); cdh.writeUInt16LE(DOS_DATE, 14); cdh.writeUInt32LE(c, 16); cdh.writeUInt32LE(sz, 20); cdh.writeUInt32LE(sz, 24); cdh.writeUInt16LE(nameBuf.length, 28); cdh.writeUInt16LE(0, 30); // extra cdh.writeUInt16LE(0, 32); // comment cdh.writeUInt16LE(0, 34); // disk cdh.writeUInt16LE(0, 36); // internal attrs cdh.writeUInt32LE(0, 38); // external attrs cdh.writeUInt32LE(localOffset, 42); centralHeaders.push({ cdh, nameBuf }); } const centralStart = offset; for (const { cdh, nameBuf } of centralHeaders) { chunks.push(cdh); chunks.push(nameBuf); offset += cdh.length + nameBuf.length; } const centralSize = offset - centralStart; // End of Central Directory const eocd = Buffer.alloc(22); eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(0, 4); eocd.writeUInt16LE(0, 6); eocd.writeUInt16LE(normalized.length, 8); eocd.writeUInt16LE(normalized.length, 10); eocd.writeUInt32LE(centralSize, 12); eocd.writeUInt32LE(centralStart, 16); eocd.writeUInt16LE(0, 20); // comment length chunks.push(eocd); return Buffer.concat(chunks); } /** * Compute SHA-256 of a buffer (hex). */ export function sha256Hex(buf) { return createHash('sha256').update(buf).digest('hex'); }