← 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 };