diff --git a/src/core/zip.ts b/src/core/zip.ts new file mode 100644 index 0000000..9c132f3 --- /dev/null +++ b/src/core/zip.ts @@ -0,0 +1,99 @@ +// A store-only ZIP writer (spec 002 §2): covers are JPEG/PNG/WebP and don't shrink, so no compression. + +export interface ZipEntry { + name: string; + bytes: Uint8Array; + modified: Date; +} + +export class ZipError extends Error { + override name = "ZipError"; +} + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c >>> 0; + } + return table; +})(); + +export function crc32(bytes: Uint8Array): number { + let c = 0xffffffff; + for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +/** MS-DOS time and date, from the UTC fields (ZIP has no time zone). */ +function dosTime(d: Date): { time: number; date: number } { + return { + time: (d.getUTCHours() << 11) | (d.getUTCMinutes() << 5) | (d.getUTCSeconds() >> 1), + date: ((Math.max(d.getUTCFullYear(), 1980) - 1980) << 9) | ((d.getUTCMonth() + 1) << 5) | d.getUTCDate(), + }; +} + +const UTF8_NAMES = 0x0800; +const LIMIT = 0xffffffff; + +export function zipStore(entries: readonly ZipEntry[]): Uint8Array { + const names = entries.map((e) => new TextEncoder().encode(e.name)); + const local = entries.reduce((sum, e, i) => sum + 30 + names[i]!.length + e.bytes.length, 0); + const central = names.reduce((sum, n) => sum + 46 + n.length, 0); + if (local + central + 22 > LIMIT) throw new ZipError("These covers add up to more than 4 GiB; select fewer."); + + const out = new Uint8Array(local + central + 22); + const view = new DataView(out.buffer); + const offsets: number[] = []; + const crcs: number[] = []; + let at = 0; + entries.forEach((e, i) => { + const name = names[i]!; + const { time, date } = dosTime(e.modified); + const crc = crc32(e.bytes); + offsets.push(at); + crcs.push(crc); + view.setUint32(at, 0x04034b50, true); + view.setUint16(at + 4, 20, true); + view.setUint16(at + 6, UTF8_NAMES, true); + view.setUint16(at + 8, 0, true); + view.setUint16(at + 10, time, true); + view.setUint16(at + 12, date, true); + view.setUint32(at + 14, crc, true); + view.setUint32(at + 18, e.bytes.length, true); + view.setUint32(at + 22, e.bytes.length, true); + view.setUint16(at + 26, name.length, true); + view.setUint16(at + 28, 0, true); + out.set(name, at + 30); + out.set(e.bytes, at + 30 + name.length); + at += 30 + name.length + e.bytes.length; + }); + const centralStart = at; + entries.forEach((e, i) => { + const name = names[i]!; + const { time, date } = dosTime(e.modified); + view.setUint32(at, 0x02014b50, true); + view.setUint16(at + 4, 20, true); + view.setUint16(at + 6, 20, true); + view.setUint16(at + 8, UTF8_NAMES, true); + view.setUint16(at + 10, 0, true); + view.setUint16(at + 12, time, true); + view.setUint16(at + 14, date, true); + view.setUint32(at + 16, crcs[i]!, true); + view.setUint32(at + 20, e.bytes.length, true); + view.setUint32(at + 24, e.bytes.length, true); + view.setUint16(at + 28, name.length, true); + // extra, comment, disk number, internal attributes: 0 + view.setUint32(at + 38, 0, true); + view.setUint32(at + 42, offsets[i]!, true); + out.set(name, at + 46); + at += 46 + name.length; + }); + view.setUint32(at, 0x06054b50, true); + view.setUint16(at + 8, entries.length, true); + view.setUint16(at + 10, entries.length, true); + view.setUint32(at + 12, at - centralStart, true); + view.setUint32(at + 16, centralStart, true); + return out; +} diff --git a/test/zip.test.ts b/test/zip.test.ts new file mode 100644 index 0000000..07f3017 --- /dev/null +++ b/test/zip.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ZipError, crc32, zipStore, type ZipEntry } from "../src/core/zip"; + +const text = (s: string) => new TextEncoder().encode(s); + +test("crc32 matches the standard check value", () => { + expect(crc32(text("123456789"))).toBe(0xcbf43926); + expect(crc32(new Uint8Array())).toBe(0); +}); + +test("a stored archive passes unzip -t and gives back the exact bytes", async () => { + const image = new Uint8Array(70_000).map((_, i) => (i * 31) & 0xff); + const entries: ZipEntry[] = [ + { name: "03_poster_zh-Hant_480x623.jpg", bytes: image, modified: new Date("2026-10-15T12:34:56Z") }, + { name: "covers.txt", bytes: text("Tower of God — 탑\n"), modified: new Date("2026-10-15T12:34:56Z") }, + ]; + const zip = zipStore(entries); + // General-purpose flag bit 11: names are UTF-8. + expect(new DataView(zip.buffer, zip.byteOffset).getUint16(6, true) & 0x0800).toBe(0x0800); + + const path = join(mkdtempSync(join(tmpdir(), "mbseed-zip-")), "covers.zip"); + await Bun.write(path, zip); + const check = Bun.spawnSync(["unzip", "-t", path]); + expect(check.exitCode).toBe(0); + const out = Bun.spawnSync(["unzip", "-p", path, "03_poster_zh-Hant_480x623.jpg"]); + expect(new Uint8Array(out.stdout)).toEqual(image); + const txt = Bun.spawnSync(["unzip", "-p", path, "covers.txt"]); + expect(new TextDecoder().decode(txt.stdout)).toBe("Tower of God — 탑\n"); +}); + +test("refuses archives past 4 GiB instead of writing a broken one", () => { + const huge = { length: 2 ** 32 } as unknown as Uint8Array; + expect(() => zipStore([{ name: "big.jpg", bytes: huge, modified: new Date() }])).toThrow(ZipError); +});