diff --git a/packages/cli/src/lib/build/zip-writer.ts b/packages/cli/src/lib/build/zip-writer.ts index 14d665f32..3d87c5bb0 100644 --- a/packages/cli/src/lib/build/zip-writer.ts +++ b/packages/cli/src/lib/build/zip-writer.ts @@ -18,7 +18,7 @@ import type { FileHandle } from "node:fs/promises"; import { open } from "node:fs/promises"; -import { crc32 } from "node:zlib"; +import { crc32 } from "../crc32.js"; /** Local-file-header signature (`PK\x03\x04`). */ const LOCAL_FILE_HEADER_SIG = 0x0403_4b50; diff --git a/packages/cli/src/lib/crc32.ts b/packages/cli/src/lib/crc32.ts new file mode 100644 index 000000000..e8221137d --- /dev/null +++ b/packages/cli/src/lib/crc32.ts @@ -0,0 +1,52 @@ +/** + * CRC-32 checksum that works on every Node.js version the npm package runs on. + * + * `zlib.crc32` only exists on Node.js >= 20.15 / >= 22.2, but the npm package + * accepts Node.js 18+ (see the version gate in `script/bundle.ts`). Older + * runtimes fall back to a table-driven implementation of the same IEEE 802.3 + * polynomial, so the checksums are identical either way. + */ + +import zlib from "node:zlib"; + +/** Reflected CRC-32 (IEEE 802.3) polynomial. */ +const POLYNOMIAL = 0xed_b8_83_20; + +let table: Uint32Array | undefined; + +// biome-ignore-start lint/suspicious/noBitwiseOperators: CRC-32 is defined in terms of bitwise operations +function getTable(): Uint32Array { + if (!table) { + table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) { + c = c & 1 ? POLYNOMIAL ^ (c >>> 1) : c >>> 1; + } + table[n] = c; + } + } + return table; +} + +/** + * Portable CRC-32, used when `zlib.crc32` is unavailable. + * + * @internal Exported for testing + */ +export function crc32Fallback(data: Uint8Array, value = 0): number { + const lookup = getTable(); + let crc = ~value; + for (const byte of data) { + crc = (lookup[(crc ^ byte) & 0xff] as number) ^ (crc >>> 8); + } + return ~crc >>> 0; +} +// biome-ignore-end lint/suspicious/noBitwiseOperators: CRC-32 is defined in terms of bitwise operations + +/** + * Compute the CRC-32 of `data`, optionally continuing from a previous + * checksum `value`. Returns an unsigned 32-bit integer, like `zlib.crc32`. + */ +export const crc32: (data: Uint8Array, value?: number) => number = + typeof zlib.crc32 === "function" ? zlib.crc32 : crc32Fallback; diff --git a/packages/cli/src/lib/sourcemap/zip.ts b/packages/cli/src/lib/sourcemap/zip.ts index 622ca2e04..2678cb975 100644 --- a/packages/cli/src/lib/sourcemap/zip.ts +++ b/packages/cli/src/lib/sourcemap/zip.ts @@ -14,7 +14,8 @@ import type { FileHandle } from "node:fs/promises"; import { open } from "node:fs/promises"; import { promisify } from "node:util"; -import { crc32, deflateRaw as deflateRawCb } from "node:zlib"; +import { deflateRaw as deflateRawCb } from "node:zlib"; +import { crc32 } from "../crc32.js"; const deflateRaw = promisify(deflateRawCb); diff --git a/packages/cli/test/lib/build/zip-writer.mocked.test.ts b/packages/cli/test/lib/build/zip-writer.mocked.test.ts new file mode 100644 index 000000000..d2c3e8a1c --- /dev/null +++ b/packages/cli/test/lib/build/zip-writer.mocked.test.ts @@ -0,0 +1,43 @@ +/** + * DeterministicZipWriter compatibility tests for Node.js versions without + * `zlib.crc32`. + */ + +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { unzipSync } from "fflate"; +import { expect, test, vi } from "vitest"; + +vi.mock("node:zlib", async (importOriginal) => { + const actual = await importOriginal>(); + const withoutCrc32 = { ...actual, crc32: undefined }; + return { ...withoutCrc32, default: withoutCrc32 }; +}); + +// Import after the mock so crc32.ts selects its portable implementation. +import { DeterministicZipWriter } from "../../../src/lib/build/zip-writer.js"; + +test("writes valid CRCs when zlib.crc32 is unavailable", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "zip-writer-no-crc32-")); + try { + const outputPath = join(tmpDir, "bundle.zip"); + const sourcePath = join(tmpDir, "source.bin"); + await writeFile(sourcePath, Buffer.from("streamed contents")); + + const zip = await DeterministicZipWriter.create(outputPath); + await zip.addData("memory.txt", Buffer.from("in-memory contents")); + await zip.addFile("streamed.txt", sourcePath); + await zip.finalize(); + + const entries = unzipSync(await readFile(outputPath)); + expect(Buffer.from(entries["memory.txt"])).toEqual( + Buffer.from("in-memory contents") + ); + expect(Buffer.from(entries["streamed.txt"])).toEqual( + Buffer.from("streamed contents") + ); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/test/lib/crc32.property.test.ts b/packages/cli/test/lib/crc32.property.test.ts new file mode 100644 index 000000000..656ddf33d --- /dev/null +++ b/packages/cli/test/lib/crc32.property.test.ts @@ -0,0 +1,39 @@ +/** + * Property-Based Tests for the portable CRC-32 fallback. + * + * The fallback must be byte-for-byte compatible with `zlib.crc32` (the + * native implementation on Node.js >= 20.15 / >= 22.2), including when a + * checksum is continued across chunks. + */ + +import { crc32 as nativeCrc32 } from "node:zlib"; +import { assert as fcAssert, property, uint8Array } from "fast-check"; +import { describe, expect, test } from "vitest"; +import { crc32Fallback } from "../../src/lib/crc32.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +describe("property: crc32Fallback", () => { + test("matches zlib.crc32 for arbitrary bytes", () => { + fcAssert( + property(uint8Array({ maxLength: 4096 }), (data) => { + expect(crc32Fallback(data)).toBe(nativeCrc32(data)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("continuing from a previous checksum equals hashing the concatenation", () => { + fcAssert( + property(uint8Array(), uint8Array(), (a, b) => { + const joined = Buffer.concat([a, b]); + expect(crc32Fallback(b, crc32Fallback(a))).toBe(crc32Fallback(joined)); + expect(crc32Fallback(b, nativeCrc32(a))).toBe(nativeCrc32(joined)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("produces the standard CRC-32 check value", () => { + expect(crc32Fallback(Buffer.from("123456789"))).toBe(0xcb_f4_39_26); + }); +}); diff --git a/packages/cli/test/lib/sourcemap/zip.mocked.test.ts b/packages/cli/test/lib/sourcemap/zip.mocked.test.ts new file mode 100644 index 000000000..4ac0630b0 --- /dev/null +++ b/packages/cli/test/lib/sourcemap/zip.mocked.test.ts @@ -0,0 +1,44 @@ +/** + * ZipWriter on Node.js versions without `zlib.crc32` (< 20.15 / < 22.2). + * Kept in a separate file so the node:zlib mock doesn't leak into zip.test.ts. + */ + +import { spawnSync } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +vi.mock("node:zlib", async (importOriginal) => { + const actual = await importOriginal>(); + const withoutCrc32 = { ...actual, crc32: undefined }; + return { ...withoutCrc32, default: withoutCrc32 }; +}); + +// Import AFTER the mock so zip.ts sees a node:zlib without crc32. +import { ZipWriter } from "../../../src/lib/sourcemap/zip.js"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "zip-no-crc32-")); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +test("writes archives with valid CRCs when zlib.crc32 is unavailable", async () => { + const zipPath = join(tmpDir, "bundle.zip"); + const zip = await ZipWriter.create(zipPath); + await zip.addEntry("bundle.js", Buffer.from("console.log(1);\n")); + await zip.addEntry("bundle.js.map", Buffer.from('{"version":3}')); + await zip.finalize(); + + // `unzip -t` recomputes each entry's CRC-32 and fails on a mismatch. + const proc = spawnSync("unzip", ["-t", zipPath], { + stdio: ["pipe", "pipe", "pipe"], + }); + expect(proc.stderr.toString()).toBe(""); + expect(proc.status).toBe(0); +});