Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

5.5 kB Ruw bestand
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;
}