import { inflateRawSync } from 'node:zlib'; /** * Just enough of the ZIP format to open a spreadsheet. * * Both formats worth importing — XLSX and ODS — are ZIP archives of XML, so * reading them starts here. Node already ships the hard part (`inflateRaw` is * in zlib), which leaves the container itself: a few dozen bytes of offsets * that have not changed since 1989. A dependency for that is a supply chain, * an audit and an upgrade forever, in exchange for code that would fit on this * screen. * * Deliberately partial. Nothing here writes archives, walks encrypted entries * or follows ZIP64 offsets; anything unusual raises rather than guesses, * because an archive this cannot read is a file the operator should be told * about, not one that quietly imports half of itself. */ const EOCD_SIGNATURE = 0x06054b50; const CENTRAL_SIGNATURE = 0x02014b50; const LOCAL_SIGNATURE = 0x04034b50; /** The end-of-central-directory record, plus the longest comment it may carry. */ const EOCD_MIN = 22; const MAX_COMMENT = 0xffff; const STORED = 0; const DEFLATED = 8; /** * How much an archive may unpack to. * * A few hundred kilobytes of ZIP can inflate to gigabytes if it was built to, * and the request that carries it costs the sender nothing. The ceiling is far * above any real spreadsheet and far below anything that would hurt. */ const MAX_UNPACKED = 64 * 1024 * 1024; /** A sentinel in a 32-bit field meaning "the real number is in a ZIP64 record". */ const ZIP64_MARKER = 0xffffffff; export class ZipError extends Error {} /** * Every file in the archive, by name. * * The whole archive is decompressed at once rather than entry by entry. The * body limit on the import route already bounds how much that can be, and a * lazy reader would buy nothing but a second place for offsets to be wrong. */ export function readZip(bytes: Buffer): Map { const eocd = findEocd(bytes); const entries = bytes.readUInt16LE(eocd + 10); let cursor = bytes.readUInt32LE(eocd + 16); if (cursor === ZIP64_MARKER || entries === 0xffff) { throw new ZipError('That archive is in ZIP64 form, which this cannot read.'); } const files = new Map(); let unpacked = 0; for (let index = 0; index < entries; index++) { if (cursor + 46 > bytes.length || bytes.readUInt32LE(cursor) !== CENTRAL_SIGNATURE) { throw new ZipError('That file is not a readable archive.'); } const flags = bytes.readUInt16LE(cursor + 8); const method = bytes.readUInt16LE(cursor + 10); const compressed = bytes.readUInt32LE(cursor + 20); const nameLength = bytes.readUInt16LE(cursor + 28); const extraLength = bytes.readUInt16LE(cursor + 30); const commentLength = bytes.readUInt16LE(cursor + 32); const localOffset = bytes.readUInt32LE(cursor + 42); const name = bytes.toString('utf8', cursor + 46, cursor + 46 + nameLength); cursor += 46 + nameLength + extraLength + commentLength; // Bit 0 is the encryption flag. An encrypted member decompresses into // noise, so it has to be refused by name rather than by symptom. if (flags & 0x1) throw new ZipError('That file is password protected.'); if (localOffset === ZIP64_MARKER || compressed === ZIP64_MARKER) { throw new ZipError('That archive is in ZIP64 form, which this cannot read.'); } // Directory entries: no content, and no reason to carry one around. if (name.endsWith('/')) continue; const content = readEntry(bytes, localOffset, method, compressed); unpacked += content.length; if (unpacked > MAX_UNPACKED) throw new ZipError('That file unpacks to far more than a spreadsheet should.'); files.set(name, content); } return files; } /** * The bytes of one member. * * The compressed size is taken from the central directory rather than from the * local header, because a streamed archive — which is what most spreadsheet * writers produce — leaves the local header's sizes at zero and puts the real * ones in a trailer after the data. */ function readEntry(bytes: Buffer, offset: number, method: number, compressed: number): Buffer { if (offset + 30 > bytes.length || bytes.readUInt32LE(offset) !== LOCAL_SIGNATURE) { throw new ZipError('That file is not a readable archive.'); } const nameLength = bytes.readUInt16LE(offset + 26); const extraLength = bytes.readUInt16LE(offset + 28); const start = offset + 30 + nameLength + extraLength; const body = bytes.subarray(start, start + compressed); if (method === STORED) return body; if (method !== DEFLATED) { throw new ZipError(`That archive uses a compression this cannot read (method ${method}).`); } try { return inflateRawSync(body); } catch { throw new ZipError('That archive is damaged and could not be unpacked.'); } } /** * Where the central directory says it is. * * Searched backwards from the end because the record sits behind a comment of * unknown length — the one place in the format that cannot be reached by * arithmetic. */ function findEocd(bytes: Buffer): number { if (bytes.length < EOCD_MIN) throw new ZipError('That file is not a readable archive.'); const floor = Math.max(0, bytes.length - EOCD_MIN - MAX_COMMENT); for (let at = bytes.length - EOCD_MIN; at >= floor; at--) { if (bytes.readUInt32LE(at) === EOCD_SIGNATURE) return at; } throw new ZipError('That file is not a readable archive.'); } /** Whether these bytes open like a ZIP archive — `PK\x03\x04`. */ export function looksLikeZip(bytes: Buffer): boolean { return bytes.length > 4 && bytes.readUInt32LE(0) === LOCAL_SIGNATURE; }