Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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<string, Buffer> {
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<string, Buffer>();
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;
}