import { createReadStream } from "node:fs"; import { open, rm, stat } from "node:fs/promises"; import { crc32 } from "node:zlib"; /* * Minimal store-only (no compression) ZIP writer for export bundles. Videos * are already compressed, so storing keeps it fast and dependency-free. * * Entry names are flat file names from the bundle's fixed allowlist: no * directories, no "..", no absolute paths, so extracting can never write * outside the target folder (zip slip). Timestamps are a fixed 1980-01-01 so * the same inputs give the same archive. No ZIP64: an entry or archive past * 4 GiB is rejected with ZipTooLargeError. */ const ENTRY_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)*$/; const MAX_ENTRY_NAME = 100; const MAX_ZIP32 = 0xffff_fffe; const MAX_ENTRIES = 0xffff; const DOS_TIME = 0; /** 1980-01-01 in DOS date format (year offset 0, month 1, day 1). */ const DOS_DATE = (1 << 5) | 1; export class ZipTooLargeError extends Error { constructor() { super("bundle is larger than a ZIP archive without ZIP64 can hold (4 GiB)"); this.name = "ZipTooLargeError"; } } export interface ZipEntry { /** Flat file name inside the archive. */ name: string; /** Absolute path of the file to store. */ path: string; } /** Whether a name is a safe flat archive entry (letters, digits, "-", "_", inner dots). */ export function isSafeZipEntryName(name: string): boolean { return name.length <= MAX_ENTRY_NAME && ENTRY_NAME.test(name); } async function fileCrc(file: string, signal?: AbortSignal): Promise { let crc = 0; for await (const chunk of createReadStream(file, { signal })) { crc = crc32(chunk as Buffer, crc); } return crc >>> 0; } /** Write a store-only ZIP of `entries` to `outPath`, replacing it. Returns the archive size. */ export async function writeStoreZip( outPath: string, entries: ZipEntry[], signal?: AbortSignal, ): Promise { if (entries.length > MAX_ENTRIES) throw new ZipTooLargeError(); const seen = new Set(); for (const entry of entries) { if (!isSafeZipEntryName(entry.name)) throw new Error(`unsafe zip entry name: ${entry.name}`); if (seen.has(entry.name)) throw new Error(`duplicate zip entry: ${entry.name}`); seen.add(entry.name); } const handle = await open(outPath, "w", 0o600); let offset = 0; const write = async (bytes: Uint8Array) => { signal?.throwIfAborted(); await handle.write(bytes); offset += bytes.byteLength; }; const central: Buffer[] = []; try { for (const entry of entries) { const size = (await stat(entry.path)).size; const name = Buffer.from(entry.name, "ascii"); if (size > MAX_ZIP32 || offset + 30 + name.length + size > MAX_ZIP32) { throw new ZipTooLargeError(); } const crc = await fileCrc(entry.path, signal); const headerOffset = offset; const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(10, 4); // version needed: 1.0, stored local.writeUInt16LE(0, 6); // flags local.writeUInt16LE(0, 8); // method: store local.writeUInt16LE(DOS_TIME, 10); local.writeUInt16LE(DOS_DATE, 12); local.writeUInt32LE(crc, 14); local.writeUInt32LE(size, 18); local.writeUInt32LE(size, 22); local.writeUInt16LE(name.length, 26); local.writeUInt16LE(0, 28); await write(local); await write(name); let copied = 0; for await (const chunk of createReadStream(entry.path, { signal })) { await write(chunk as Buffer); copied += (chunk as Buffer).byteLength; } if (copied !== size) throw new Error(`${entry.name} changed while archiving`); const record = Buffer.alloc(46); record.writeUInt32LE(0x02014b50, 0); record.writeUInt16LE((3 << 8) | 20, 4); // made by: Unix, 2.0 record.writeUInt16LE(10, 6); record.writeUInt16LE(0, 8); record.writeUInt16LE(0, 10); record.writeUInt16LE(DOS_TIME, 12); record.writeUInt16LE(DOS_DATE, 14); record.writeUInt32LE(crc, 16); record.writeUInt32LE(size, 20); record.writeUInt32LE(size, 24); record.writeUInt16LE(name.length, 28); record.writeUInt16LE(0, 30); // extra record.writeUInt16LE(0, 32); // comment record.writeUInt16LE(0, 34); // disk record.writeUInt16LE(0, 36); // internal attributes record.writeUInt32LE((0o100644 << 16) >>> 0, 38); // regular file, rw-r--r-- record.writeUInt32LE(headerOffset, 42); central.push(record, name); } const directory = Buffer.concat(central); const directoryOffset = offset; if (directoryOffset + directory.length + 22 > MAX_ZIP32) throw new ZipTooLargeError(); await write(directory); const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(0, 4); end.writeUInt16LE(0, 6); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10); end.writeUInt32LE(directory.length, 12); end.writeUInt32LE(directoryOffset, 16); end.writeUInt16LE(0, 20); await write(end); await handle.close(); return offset; } catch (error) { await handle.close().catch(() => undefined); await rm(outPath, { force: true }); throw error; } }