import { createHash } from 'node:crypto'; import { createWriteStream } from 'node:fs'; import { open } from 'node:fs/promises'; const ZIP_LOCAL_FILE_HEADER = 0x04034b50; const ZIP_CENTRAL_DIRECTORY_HEADER = 0x02014b50; const ZIP_DATA_DESCRIPTOR = 0x08074b50; const ZIP_END_OF_CENTRAL_DIRECTORY = 0x06054b50; const ZIP_VERSION = 20; const ZIP_DATA_DESCRIPTOR_FLAG = 0x0008; const ZIP_STORE_METHOD = 0; const ZIP_FILE_MODE = 0o100644; const FIXED_ZIP_TIME = new Date(1980, 0, 1, 0, 0, 0, 0); export type StreamingZipEntry = { path: string; source: { filePath: string } | { bytes: Buffer }; beforeRead?: () => Promise; afterRead?: (result: { crc32: number; size: number }) => Promise; }; export type StreamingZipResult = { archiveBytes: number; sha256: string; }; type CentralEntry = { path: string; crc32: number; size: number; offset: number; }; type OutputState = { error: Error | null; }; const CRC_TABLE = (() => { const table = new Uint32Array(256); for (let index = 0; index < table.length; index += 1) { let value = index; for (let bit = 0; bit < 8; bit += 1) { value = (value & 1) === 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; } table[index] = value >>> 0; } return table; })(); function updateCrc32(current: number, chunk: Uint8Array): number { let value = current ^ 0xffffffff; for (const byte of chunk) value = CRC_TABLE[(value ^ byte) & 0xff] ^ (value >>> 8); return (value ^ 0xffffffff) >>> 0; } function dosDateTime(date: Date): { time: number; day: number } { const year = Math.max(1980, Math.min(2107, date.getFullYear())); return { time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2), day: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(), }; } function pathBytes(path: string): Buffer { const bytes = Buffer.from(path, 'utf8'); if (bytes.length > 0xffff) throw new Error('ZIP entry path is too long'); return bytes; } function localHeader(path: Buffer, date: Date): Buffer { const header = Buffer.alloc(30 + path.length); const dos = dosDateTime(date); header.writeUInt32LE(ZIP_LOCAL_FILE_HEADER, 0); header.writeUInt16LE(ZIP_VERSION, 4); header.writeUInt16LE(ZIP_DATA_DESCRIPTOR_FLAG, 6); header.writeUInt16LE(ZIP_STORE_METHOD, 8); header.writeUInt16LE(dos.time, 10); header.writeUInt16LE(dos.day, 12); header.writeUInt16LE(0, 14); header.writeUInt32LE(0, 18); header.writeUInt32LE(0, 22); header.writeUInt16LE(path.length, 26); header.writeUInt16LE(0, 28); path.copy(header, 30); return header; } function dataDescriptor(crc32: number, size: number): Buffer { const descriptor = Buffer.alloc(16); descriptor.writeUInt32LE(ZIP_DATA_DESCRIPTOR, 0); descriptor.writeUInt32LE(crc32 >>> 0, 4); descriptor.writeUInt32LE(size >>> 0, 8); descriptor.writeUInt32LE(size >>> 0, 12); return descriptor; } function centralHeader(entry: CentralEntry, date: Date): Buffer { const path = pathBytes(entry.path); const header = Buffer.alloc(46 + path.length); const dos = dosDateTime(date); header.writeUInt32LE(ZIP_CENTRAL_DIRECTORY_HEADER, 0); header.writeUInt16LE(ZIP_VERSION, 4); header.writeUInt16LE(ZIP_VERSION, 6); header.writeUInt16LE(ZIP_DATA_DESCRIPTOR_FLAG, 8); header.writeUInt16LE(ZIP_STORE_METHOD, 10); header.writeUInt16LE(dos.time, 12); header.writeUInt16LE(dos.day, 14); header.writeUInt32LE(entry.crc32 >>> 0, 16); header.writeUInt32LE(entry.size >>> 0, 20); header.writeUInt32LE(entry.size >>> 0, 24); header.writeUInt16LE(path.length, 28); header.writeUInt16LE(0, 30); header.writeUInt16LE(0, 32); header.writeUInt16LE(0, 34); header.writeUInt16LE(0, 36); header.writeUInt32LE((ZIP_FILE_MODE << 16) >>> 0, 38); header.writeUInt32LE(entry.offset >>> 0, 42); path.copy(header, 46); return header; } function endOfCentralDirectory(count: number, size: number, offset: number): Buffer { const footer = Buffer.alloc(22); footer.writeUInt32LE(ZIP_END_OF_CENTRAL_DIRECTORY, 0); footer.writeUInt16LE(0, 4); footer.writeUInt16LE(0, 6); footer.writeUInt16LE(count, 8); footer.writeUInt16LE(count, 10); footer.writeUInt32LE(size, 12); footer.writeUInt32LE(offset, 16); footer.writeUInt16LE(0, 20); return footer; } async function writeChunk( output: ReturnType, hash: ReturnType, chunk: Buffer, state: OutputState, ): Promise { if (state.error) throw state.error; hash.update(chunk); if (output.write(chunk)) return; await new Promise((resolve, reject) => { const onDrain = () => { cleanup(); resolve(); }; const onError = (error: Error) => { cleanup(); reject(error); }; const cleanup = () => { output.removeListener('drain', onDrain); output.removeListener('error', onError); }; output.once('drain', onDrain); output.once('error', onError); }); if (state.error) throw state.error; } async function writeEntryData( output: ReturnType, hash: ReturnType, entry: StreamingZipEntry, state: OutputState, signal?: AbortSignal, ): Promise<{ crc32: number; size: number }> { let crc32 = 0; let size = 0; const writeData = async (chunk: Buffer): Promise => { if (signal?.aborted) throw new Error('ZIP_WRITE_CANCELLED'); crc32 = updateCrc32(crc32, chunk); size += chunk.length; if (size > 0xffffffff) throw new Error('ZIP entry exceeds 4 GiB limit'); await writeChunk(output, hash, chunk, state); }; if ('bytes' in entry.source) { await entry.beforeRead?.(); await writeData(entry.source.bytes); } else { // Open the file before reading so a later path replacement cannot redirect // the stream outside the package scan's validated inode. The explicit // open also lets callers validate a stable-file race immediately before // the descriptor is acquired. const handle = await open(entry.source.filePath, 'r'); try { await entry.beforeRead?.(); const input = handle.createReadStream({ autoClose: false }); for await (const chunk of input) { await writeData(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } } finally { await handle.close(); } } await entry.afterRead?.({ crc32, size }); return { crc32, size }; } /** Writes a deterministic, uncompressed ZIP without retaining file contents. */ export async function writeStoredZip( entries: readonly StreamingZipEntry[], archivePath: string, options: { signal?: AbortSignal; date?: Date } = {}, ): Promise { if (entries.length > 0xffff) throw new Error('ZIP contains too many entries'); const date = options.date ?? FIXED_ZIP_TIME; const output = createWriteStream(archivePath, { mode: 0o600 }); const outputState: OutputState = { error: null }; output.on('error', (error) => { outputState.error = error; }); const hash = createHash('sha256'); const central: CentralEntry[] = []; let offset = 0; try { for (const entry of entries) { const path = pathBytes(entry.path); const start = offset; const header = localHeader(path, date); await writeChunk(output, hash, header, outputState); offset += header.length; const data = await writeEntryData(output, hash, entry, outputState, options.signal); offset += data.size; const descriptor = dataDescriptor(data.crc32, data.size); await writeChunk(output, hash, descriptor, outputState); offset += descriptor.length; central.push({ path: entry.path, crc32: data.crc32, size: data.size, offset: start }); } const centralOffset = offset; for (const entry of central) { const header = centralHeader(entry, date); await writeChunk(output, hash, header, outputState); offset += header.length; } const footer = endOfCentralDirectory(central.length, offset - centralOffset, centralOffset); await writeChunk(output, hash, footer, outputState); offset += footer.length; await new Promise((resolve, reject) => { const onError = (error: Error) => { cleanup(); reject(error); }; const cleanup = () => output.removeListener('error', onError); output.once('error', onError); output.end(() => { cleanup(); if (outputState.error) reject(outputState.error); else resolve(); }); }); return { archiveBytes: offset, sha256: hash.digest('hex') }; } catch (error) { output.destroy(); throw error; } }