← back to Rentv Adintel

src/export/zip.js

191 lines

'use strict';
/**
 * Pure-Node minimal ZIP writer — STORE method (no compression).
 * Produces a valid ZIP file openable by macOS, Windows, and standard tools.
 *
 * Format references:
 *   PKWARE APPNOTE.TXT §4.3 — local file headers + central directory + EOCD
 *   CRC-32 via standard IEEE 802.3 polynomial (0xEDB88320)
 *
 * Usage:
 *   const zip = new ZipWriter();
 *   zip.addEntry('folder/file.txt', Buffer.from('hello'));
 *   const buf = zip.finalize();          // returns a Buffer
 *   fs.writeFileSync('out.zip', buf);
 *
 * @module src/export/zip
 */

/** Pre-built CRC-32 lookup table (IEEE 802.3 polynomial 0xEDB88320). */
const CRC_TABLE = (() => {
  const t = new Uint32Array(256);
  for (let i = 0; i < 256; i++) {
    let c = i;
    for (let j = 0; j < 8; j++) {
      c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
    }
    t[i] = c;
  }
  return t;
})();

/**
 * Compute CRC-32 checksum of a Buffer.
 * @param {Buffer} buf
 * @returns {number} unsigned 32-bit integer
 */
function crc32(buf) {
  let c = 0xffffffff;
  for (let i = 0; i < buf.length; i++) {
    c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
  }
  return (c ^ 0xffffffff) >>> 0;
}

/**
 * Write a 32-bit little-endian uint into a Buffer at offset.
 * @param {Buffer} buf
 * @param {number} offset
 * @param {number} value
 */
function writeUInt32LE(buf, offset, value) {
  buf.writeUInt32LE(value >>> 0, offset);
}

/**
 * Write a 16-bit little-endian uint into a Buffer at offset.
 * @param {Buffer} buf
 * @param {number} offset
 * @param {number} value
 */
function writeUInt16LE(buf, offset, value) {
  buf.writeUInt16LE(value & 0xffff, offset);
}

/**
 * DOS date/time encoding for a JS Date.
 * @param {Date} date
 * @returns {{ dosTime: number, dosDate: number }}
 */
function dosDateTime(date) {
  const d = date instanceof Date ? date : new Date();
  const dosTime =
    ((d.getHours() & 0x1f) << 11) |
    ((d.getMinutes() & 0x3f) << 5) |
    (Math.floor(d.getSeconds() / 2) & 0x1f);
  const dosDate =
    (((d.getFullYear() - 1980) & 0x7f) << 9) |
    (((d.getMonth() + 1) & 0x0f) << 5) |
    (d.getDate() & 0x1f);
  return { dosTime, dosDate };
}

/**
 * @typedef {Object} ZipEntry
 * @property {string} name     - file path inside the ZIP (forward slashes)
 * @property {Buffer} data     - raw file bytes
 * @property {number} crc      - CRC-32 of data
 * @property {number} localOffset - byte offset of local file header in the archive
 * @property {number} dosTime
 * @property {number} dosDate
 */

class ZipWriter {
  constructor() {
    /** @type {Buffer[]} */
    this._parts = [];
    /** @type {ZipEntry[]} */
    this._entries = [];
    this._offset = 0;
    this._now = new Date();
  }

  /**
   * Add a file entry.
   * @param {string} name   - path inside zip, e.g. 'dir/file.txt'
   * @param {Buffer|string} data - content (string will be UTF-8 encoded)
   */
  addEntry(name, data) {
    const dataBuf = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
    const nameBuf = Buffer.from(name, 'utf8');
    const crc = crc32(dataBuf);
    const { dosTime, dosDate } = dosDateTime(this._now);

    const localOffset = this._offset;

    // Local file header (signature 0x04034b50, 30 bytes + name)
    const lhSize = 30 + nameBuf.length;
    const lh = Buffer.alloc(lhSize, 0);
    writeUInt32LE(lh, 0, 0x04034b50); // local file header sig
    writeUInt16LE(lh, 4, 20);         // version needed: 2.0
    writeUInt16LE(lh, 6, 0);          // general purpose bit flag
    writeUInt16LE(lh, 8, 0);          // compression method: STORE
    writeUInt16LE(lh, 10, dosTime);
    writeUInt16LE(lh, 12, dosDate);
    writeUInt32LE(lh, 14, crc);
    writeUInt32LE(lh, 18, dataBuf.length); // compressed size
    writeUInt32LE(lh, 22, dataBuf.length); // uncompressed size
    writeUInt16LE(lh, 26, nameBuf.length);
    writeUInt16LE(lh, 28, 0);         // extra field length
    nameBuf.copy(lh, 30);

    this._parts.push(lh);
    this._parts.push(dataBuf);
    this._offset += lhSize + dataBuf.length;

    this._entries.push({ name, nameBuf, data: dataBuf, crc, localOffset, dosTime, dosDate });
  }

  /**
   * Finalize the ZIP archive and return the complete Buffer.
   * @returns {Buffer}
   */
  finalize() {
    const cdOffset = this._offset;

    // Central directory headers
    const cdParts = [];
    for (const entry of this._entries) {
      const cdSize = 46 + entry.nameBuf.length;
      const cd = Buffer.alloc(cdSize, 0);
      writeUInt32LE(cd, 0, 0x02014b50);  // central dir signature
      writeUInt16LE(cd, 4, 20);           // version made by
      writeUInt16LE(cd, 6, 20);           // version needed
      writeUInt16LE(cd, 8, 0);            // general purpose bit flag
      writeUInt16LE(cd, 10, 0);           // compression: STORE
      writeUInt16LE(cd, 12, entry.dosTime);
      writeUInt16LE(cd, 14, entry.dosDate);
      writeUInt32LE(cd, 16, entry.crc);
      writeUInt32LE(cd, 20, entry.data.length); // compressed size
      writeUInt32LE(cd, 24, entry.data.length); // uncompressed size
      writeUInt16LE(cd, 28, entry.nameBuf.length);
      writeUInt16LE(cd, 30, 0);           // extra field length
      writeUInt16LE(cd, 32, 0);           // file comment length
      writeUInt16LE(cd, 34, 0);           // disk number start
      writeUInt16LE(cd, 36, 0);           // internal file attributes
      writeUInt32LE(cd, 38, 0);           // external file attributes
      writeUInt32LE(cd, 42, entry.localOffset);
      entry.nameBuf.copy(cd, 46);
      cdParts.push(cd);
    }

    const cdBuf = Buffer.concat(cdParts);
    const cdSize = cdBuf.length;

    // End of central directory record (22 bytes)
    const eocd = Buffer.alloc(22, 0);
    writeUInt32LE(eocd, 0, 0x06054b50);  // EOCD signature
    writeUInt16LE(eocd, 4, 0);            // disk number
    writeUInt16LE(eocd, 6, 0);            // disk with central dir start
    writeUInt16LE(eocd, 8, this._entries.length);  // entries on disk
    writeUInt16LE(eocd, 10, this._entries.length); // total entries
    writeUInt32LE(eocd, 12, cdSize);
    writeUInt32LE(eocd, 16, cdOffset);
    writeUInt16LE(eocd, 20, 0);           // comment length

    return Buffer.concat([...this._parts, cdBuf, eocd]);
  }
}

module.exports = { ZipWriter, crc32 };