// A minimal, deterministic ZIP writer and reader.
//
// Used by script/lib/package.js to build the distributable archives: a Chrome
// zip and a Firefox XPI are both ordinary zip files with manifest.json at the
// root, so one implementation covers both.
//
// Why this rather than a package or the zip(1) binary. A dependency would have
// to be hash-pinned like everything else in REPO_POLICIES.md, and this is
// about a hundred lines of stdlib zlib for a format the archives use two
// features of. The binary is worse: the release artifact would then depend on
// whichever Info-ZIP the machine happens to have, which is the same objection
// that keeps linting inside a container.
//
// Deterministic on purpose. Entries are sorted by name, every timestamp is the
// same fixed 1980-01-01 the format's epoch starts at, and the compression
// level is fixed, so building the same dist/ twice produces byte-identical
// archives and the sha256 in SHA256SUMS is a property of the input rather than
// of the clock. Two builds of the same commit that disagree are then visible
// instead of expected.
//
// Deliberately NOT implemented: zip64, encryption, data descriptors,
// directory entries (browsers infer directories from member paths), and
// anything to do with symlinks. writeZip refuses input it cannot represent
// rather than emitting an archive that is quietly wrong.

"use strict";

const zlib = require("zlib");

const LOCAL_SIG = 0x04034b50;
const CENTRAL_SIG = 0x02014b50;
const EOCD_SIG = 0x06054b50;

const METHOD_STORE = 0;
const METHOD_DEFLATE = 8;

// 1980-01-01 00:00:00, the earliest the MS-DOS timestamp fields can express.
const DOS_DATE = (0 << 9) | (1 << 5) | 1;
const DOS_TIME = 0;

// Unix regular file, mode 0644, in the high 16 bits, which is where the "made
// by unix" convention puts it.
// >>> 0 because JS shifts are signed 32-bit and this one sets the top bit.
const EXTERNAL_ATTRS = (0o100644 << 16) >>> 0;
const VERSION_MADE_BY = (3 << 8) | 20; // unix, needs zip 2.0
const VERSION_NEEDED = 20;

// Without zip64 every size and offset is a u32.
const MAX_U32 = 0xffffffff;

const CRC_TABLE = (() => {
    const table = new Int32Array(256);
    for (let i = 0; i < 256; i++) {
        let c = i;
        for (let k = 0; k < 8; k++) {
            c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
        }
        table[i] = c;
    }
    return table;
})();

// Written out rather than taken from zlib.crc32, which only exists from node
// 22.2: this runs from script/ on whatever node the host has as well as inside
// the pinned image, and a checksum that silently is not there is worse than
// twelve lines.
function crc32(buf) {
    let c = -1;
    for (let i = 0; i < buf.length; i++) {
        c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
    }
    return (c ^ -1) >>> 0;
}

// Member names are stored as raw bytes. Anything outside ASCII would need the
// UTF-8 flag and interoperability care that nothing this repo emits requires,
// so it is refused instead of guessed at.
function encodeName(name) {
    if (typeof name !== "string" || name === "") {
        throw new Error(`zip: unusable member name ${JSON.stringify(name)}`);
    }
    const segments = name.split("/");
    if (
        name.startsWith("/") ||
        name.includes("\\") ||
        segments.some((s) => s === "" || s === "." || s === "..")
    ) {
        throw new Error(
            `zip: refusing member name ${JSON.stringify(name)}: archive ` +
                `members must be relative paths under the archive root`,
        );
    }
    if (/[^\x20-\x7e]/.test(name)) {
        throw new Error(
            `zip: refusing non-ASCII member name ${JSON.stringify(name)}`,
        );
    }
    return Buffer.from(name, "ascii");
}

function compress(data) {
    if (data.length === 0) {
        return { method: METHOD_STORE, body: data };
    }
    const deflated = zlib.deflateRawSync(data, { level: 9 });
    if (deflated.length >= data.length) {
        return { method: METHOD_STORE, body: data };
    }
    return { method: METHOD_DEFLATE, body: deflated };
}

// entries: [{ name, data }]. Returns the archive as a Buffer.
function writeZip(entries) {
    if (!Array.isArray(entries) || entries.length === 0) {
        throw new Error("zip: refusing to write an archive with no members");
    }

    const sorted = [...entries].sort((a, b) => (a.name < b.name ? -1 : 1));
    const seen = new Set();
    const locals = [];
    const centrals = [];
    let offset = 0;

    for (const entry of sorted) {
        const name = encodeName(entry.name);
        if (seen.has(entry.name)) {
            throw new Error(`zip: duplicate member ${entry.name}`);
        }
        seen.add(entry.name);

        const data = Buffer.from(entry.data);
        const { method, body } = compress(data);
        if (data.length > MAX_U32 || body.length > MAX_U32) {
            throw new Error(
                `zip: ${entry.name} is too large for a non-zip64 archive`,
            );
        }
        const crc = crc32(data);

        const local = Buffer.alloc(30 + name.length);
        local.writeUInt32LE(LOCAL_SIG, 0);
        local.writeUInt16LE(VERSION_NEEDED, 4);
        local.writeUInt16LE(0, 6);
        local.writeUInt16LE(method, 8);
        local.writeUInt16LE(DOS_TIME, 10);
        local.writeUInt16LE(DOS_DATE, 12);
        local.writeUInt32LE(crc, 14);
        local.writeUInt32LE(body.length, 18);
        local.writeUInt32LE(data.length, 22);
        local.writeUInt16LE(name.length, 26);
        local.writeUInt16LE(0, 28);
        name.copy(local, 30);

        const central = Buffer.alloc(46 + name.length);
        central.writeUInt32LE(CENTRAL_SIG, 0);
        central.writeUInt16LE(VERSION_MADE_BY, 4);
        central.writeUInt16LE(VERSION_NEEDED, 6);
        central.writeUInt16LE(0, 8);
        central.writeUInt16LE(method, 10);
        central.writeUInt16LE(DOS_TIME, 12);
        central.writeUInt16LE(DOS_DATE, 14);
        central.writeUInt32LE(crc, 16);
        central.writeUInt32LE(body.length, 20);
        central.writeUInt32LE(data.length, 24);
        central.writeUInt16LE(name.length, 28);
        central.writeUInt16LE(0, 30);
        central.writeUInt16LE(0, 32);
        central.writeUInt16LE(0, 34);
        central.writeUInt16LE(0, 36);
        central.writeUInt32LE(EXTERNAL_ATTRS, 38);
        if (offset > MAX_U32) {
            throw new Error("zip: archive too large for a non-zip64 archive");
        }
        central.writeUInt32LE(offset, 42);
        name.copy(central, 46);

        locals.push(local, body);
        centrals.push(central);
        offset += local.length + body.length;
    }

    const centralBuf = Buffer.concat(centrals);
    const eocd = Buffer.alloc(22);
    eocd.writeUInt32LE(EOCD_SIG, 0);
    eocd.writeUInt16LE(0, 4);
    eocd.writeUInt16LE(0, 6);
    eocd.writeUInt16LE(sorted.length, 8);
    eocd.writeUInt16LE(sorted.length, 10);
    eocd.writeUInt32LE(centralBuf.length, 12);
    eocd.writeUInt32LE(offset, 16);
    eocd.writeUInt16LE(0, 20);

    return Buffer.concat([...locals, centralBuf, eocd]);
}

// Read an archive back into [{ name, data }], from the central directory
// rather than by scanning for local headers: the central directory is the
// authoritative index, and a member reachable only by scanning is one a real
// unzipper would not extract.
//
// Every member's CRC is checked. The point of reading an archive back is to
// establish that it holds what it was meant to hold, so a member that does not
// decompress to its recorded checksum is a failure and never a warning.
function readZip(buf) {
    if (buf.length < 22) {
        throw new Error("zip: too short to be an archive");
    }
    // No archive this writes has a trailing comment, so the EOCD is the last
    // 22 bytes. Anything else is not an archive this produced.
    const eocdAt = buf.length - 22;
    if (buf.readUInt32LE(eocdAt) !== EOCD_SIG) {
        throw new Error(
            "zip: no end-of-central-directory record at the end of the " +
                "archive (a trailing comment, or not a zip at all)",
        );
    }
    const count = buf.readUInt16LE(eocdAt + 10);
    const centralSize = buf.readUInt32LE(eocdAt + 12);
    let at = buf.readUInt32LE(eocdAt + 16);
    if (at + centralSize > eocdAt) {
        throw new Error("zip: central directory runs past the archive");
    }

    const out = [];
    for (let i = 0; i < count; i++) {
        if (buf.readUInt32LE(at) !== CENTRAL_SIG) {
            throw new Error(`zip: bad central directory entry ${i}`);
        }
        const method = buf.readUInt16LE(at + 10);
        const crc = buf.readUInt32LE(at + 16);
        const compSize = buf.readUInt32LE(at + 20);
        const rawSize = buf.readUInt32LE(at + 24);
        const nameLen = buf.readUInt16LE(at + 28);
        const extraLen = buf.readUInt16LE(at + 30);
        const commentLen = buf.readUInt16LE(at + 32);
        const localAt = buf.readUInt32LE(at + 42);
        const name = buf.toString("ascii", at + 46, at + 46 + nameLen);
        at += 46 + nameLen + extraLen + commentLen;

        if (buf.readUInt32LE(localAt) !== LOCAL_SIG) {
            throw new Error(`zip: ${name} has no local header`);
        }
        // The local header's own name and extra lengths, not the central
        // directory's: the two are allowed to differ and the data starts after
        // the local ones.
        const localNameLen = buf.readUInt16LE(localAt + 26);
        const localExtraLen = buf.readUInt16LE(localAt + 28);
        const dataAt = localAt + 30 + localNameLen + localExtraLen;
        const body = buf.subarray(dataAt, dataAt + compSize);

        let data;
        if (method === METHOD_STORE) {
            data = Buffer.from(body);
        } else if (method === METHOD_DEFLATE) {
            data = zlib.inflateRawSync(body);
        } else {
            throw new Error(`zip: ${name} uses compression method ${method}`);
        }

        if (data.length !== rawSize) {
            throw new Error(
                `zip: ${name} decompressed to ${data.length} bytes, not the ` +
                    `recorded ${rawSize}`,
            );
        }
        if (crc32(data) !== crc) {
            throw new Error(`zip: ${name} fails its recorded CRC32`);
        }
        out.push({ name, data });
    }
    return out;
}

module.exports = { crc32, readZip, writeZip };
