From c2590fb66ffdc0db03c7d2f336d5021e1a66056c Mon Sep 17 00:00:00 2001 From: xCyanGrizzly Date: Thu, 23 Jul 2026 11:19:28 +0200 Subject: [PATCH] feat(worker): CRC32 archive fingerprint compare Co-Authored-By: Claude Opus 4.8 (1M context) --- worker/src/archive/fingerprint.test.ts | 34 +++++++++++++++++++++++--- worker/src/archive/fingerprint.ts | 21 ++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 worker/src/archive/fingerprint.ts diff --git a/worker/src/archive/fingerprint.test.ts b/worker/src/archive/fingerprint.test.ts index ba24106..e8971cb 100644 --- a/worker/src/archive/fingerprint.test.ts +++ b/worker/src/archive/fingerprint.test.ts @@ -1,7 +1,35 @@ import { describe, it, expect } from "vitest"; +import { crcFingerprint, fingerprintsMatch } from "./fingerprint.js"; +import type { FileEntry } from "./zip-reader.js"; -describe("harness", () => { - it("runs", () => { - expect(1 + 1).toBe(2); +const fe = (crc: string | null): FileEntry => ({ + path: "a", fileName: "a", extension: null, + compressedSize: 0n, uncompressedSize: 0n, crc32: crc, +}); + +describe("crcFingerprint", () => { + it("sorts crcs and marks complete", () => { + expect(crcFingerprint([fe("00ff"), fe("00aa")])).toEqual({ crcs: ["00aa", "00ff"], complete: true }); + }); + it("is incomplete when any crc is null", () => { + expect(crcFingerprint([fe("00aa"), fe(null)]).complete).toBe(false); + }); + it("is incomplete when empty", () => { + expect(crcFingerprint([]).complete).toBe(false); + }); +}); + +describe("fingerprintsMatch", () => { + it("matches identical crc multisets regardless of order", () => { + expect(fingerprintsMatch([fe("01"), fe("02")], [fe("02"), fe("01")])).toBe(true); + }); + it("rejects different counts", () => { + expect(fingerprintsMatch([fe("01")], [fe("01"), fe("02")])).toBe(false); + }); + it("rejects disjoint sets", () => { + expect(fingerprintsMatch([fe("01")], [fe("09")])).toBe(false); + }); + it("rejects when either side is incomplete", () => { + expect(fingerprintsMatch([fe("01"), fe(null)], [fe("01"), fe("02")])).toBe(false); }); }); diff --git a/worker/src/archive/fingerprint.ts b/worker/src/archive/fingerprint.ts new file mode 100644 index 0000000..a183122 --- /dev/null +++ b/worker/src/archive/fingerprint.ts @@ -0,0 +1,21 @@ +import type { FileEntry } from "./zip-reader.js"; + +export function crcFingerprint(entries: FileEntry[]): { crcs: string[]; complete: boolean } { + if (entries.length === 0) return { crcs: [], complete: false }; + const crcs: string[] = []; + let complete = true; + for (const e of entries) { + if (e.crc32 == null) { complete = false; continue; } + crcs.push(e.crc32.toLowerCase()); + } + crcs.sort(); + return { crcs, complete }; +} + +export function fingerprintsMatch(a: FileEntry[], b: FileEntry[]): boolean { + const fa = crcFingerprint(a); + const fb = crcFingerprint(b); + if (!fa.complete || !fb.complete) return false; + if (fa.crcs.length !== fb.crcs.length) return false; + return fa.crcs.every((c, i) => c === fb.crcs[i]); +}