import { describe, expect, it } from "@suaveplan/testing/runner"; import { deflateSync } from "fflate"; import { buildPlainZip } from "../__fixtures__/build-zip.js"; import { ZIP_CODES } from "../errors/error-codes.js"; import { normalizePath } from "../storage/normalize-path.js"; import { parseCdrEntry } from "./central-directory/parse-cdr-entry.js"; import { walkCdr } from "./central-directory/walk-cdr.js"; import { compress } from "./compression/compress.js"; import { decompress } from "./compression/decompress.js"; import { crc32 } from "./crc32.js"; import { aesDecrypt } from "./crypto/aes/aes-decrypt.js"; import { aesWriteBlock } from "./crypto/aes/aes-write-block.js"; import { findEocdOffset } from "./eocd/find-eocd.js"; import { parseEocd } from "./eocd/parse-eocd.js"; import { mergeEocd64, parseEocd64Locator, parseEocd64Record, } from "./eocd/parse-eocd64.js"; import { parseLocalHeader } from "./local-header/parse-local-header.js"; import { readEntryBytes } from "./local-header/read-entry-bytes.js"; import { AE_EXTRA_TAG, CDR_SIG, EOCD_SIG, EOCD64_LOCATOR_SIG, EOCD64_SIG, LOCAL_HEADER_SIG, ZIP64_EXTRA_TAG, } from "./signatures.js"; const enc = (s: string) => new TextEncoder().encode(s); describe("parser unit", () => { it("findEocdOffset returns null for empty/short buffer", () => { expect(findEocdOffset(new Uint8Array(0))).toBeNull(); expect(findEocdOffset(new Uint8Array(10))).toBeNull(); expect(findEocdOffset(new Uint8Array(100))).toBeNull(); }); it("findEocdOffset rejects when comment length spans wrong", () => { // Build a fake buffer with EOCD magic but mismatched comment length. const buf = new Uint8Array(40); const v = new DataView(buf.buffer); v.setUint32(10, EOCD_SIG, true); v.setUint16(10 + 20, 999, true); // declared comment longer than remainder expect(findEocdOffset(buf)).toBeNull(); }); it("parseEocd throws for offset out of range", () => { const buf = new Uint8Array(50); expect(() => parseEocd(buf, -1)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.EOCD_NOT_FOUND }) ); expect(() => parseEocd(buf, 100)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.EOCD_NOT_FOUND }) ); }); it("parseEocd throws when signature is wrong", () => { const buf = new Uint8Array(40); expect(() => parseEocd(buf, 0)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.EOCD_NOT_FOUND }) ); }); it("parseEocd throws when comment length overflows", () => { const buf = new Uint8Array(24); const v = new DataView(buf.buffer); v.setUint32(0, EOCD_SIG, true); v.setUint16(20, 100, true); // way too long expect(() => parseEocd(buf, 0)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.EOCD_NOT_FOUND }) ); }); it("parseEocd64Locator throws when no room before legacy EOCD", () => { const buf = new Uint8Array(20); expect(() => parseEocd64Locator(buf, 0)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.ZIP64_REQUIRED }) ); }); it("parseEocd64Locator throws when locator signature missing", () => { const buf = new Uint8Array(40); expect(() => parseEocd64Locator(buf, 20)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.ZIP64_REQUIRED }) ); }); it("parseEocd64Record throws on bad signature / range", () => { const buf = new Uint8Array(60); expect(() => parseEocd64Record(buf, -1)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.ZIP64_REQUIRED }) ); expect(() => parseEocd64Record(buf, 100)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.ZIP64_REQUIRED }) ); expect(() => parseEocd64Record(buf, 0)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.ZIP64_REQUIRED }) ); }); it("parseEocd64Locator + record + merge happy path", () => { // Build a hand-rolled ZIP64 set. const locator = new Uint8Array(20); const lv = new DataView(locator.buffer); lv.setUint32(0, EOCD64_LOCATOR_SIG, true); lv.setUint32(4, 0, true); lv.setUint32(8, 5, true); // low offset lv.setUint32(12, 0, true); lv.setUint32(16, 1, true); const eocd64Buf = new Uint8Array(56); const ev = new DataView(eocd64Buf.buffer); ev.setUint32(0, EOCD64_SIG, true); // remaining fields default to 0 which is sufficient // Concat into one buffer where legacyEocdOffset = 20 (after locator). const buf = new Uint8Array(76); buf.set(eocd64Buf, 0); buf.set(locator, 56); const loc = parseEocd64Locator(buf, 76); expect(loc.totalDisks).toBe(1); }); it("readU64 rejects values exceeding MAX_SAFE_INTEGER", () => { const buf = new Uint8Array(8); const v = new DataView(buf.buffer); v.setUint32(0, 0, true); v.setUint32(4, 0xffffffff, true); // Build a ZIP64 record whose totalEntries field overflows. const z64 = new Uint8Array(56); const zv = new DataView(z64.buffer); zv.setUint32(0, EOCD64_SIG, true); zv.setUint32(32, 0, true); zv.setUint32(36, 0xffffffff, true); // hi expect(() => parseEocd64Record(z64, 0)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.ZIP64_REQUIRED }) ); }); it("mergeEocd64 overrides every sentinel field from ZIP64 record", () => { const legacy = { diskNumber: 0xffff, diskWithCentralDir: 0xffff, entriesOnThisDisk: 0xffff, totalEntries: 0xffff, centralDirSize: 0xffffffff, centralDirOffset: 0xffffffff, comment: new Uint8Array(0), isZip64: false, }; const z64 = { diskNumber: 5, diskWithCentralDir: 5, entriesOnThisDisk: 999, totalEntries: 999, centralDirSize: 12345, centralDirOffset: 67890, }; const merged = mergeEocd64(legacy, z64); expect(merged.diskNumber).toBe(5); expect(merged.diskWithCentralDir).toBe(5); expect(merged.entriesOnThisDisk).toBe(999); expect(merged.totalEntries).toBe(999); expect(merged.centralDirSize).toBe(12345); expect(merged.centralDirOffset).toBe(67890); }); it("mergeEocd64 preserves legacy values that fit", () => { const legacy = { diskNumber: 1, diskWithCentralDir: 1, entriesOnThisDisk: 3, totalEntries: 3, centralDirSize: 100, centralDirOffset: 50, comment: new Uint8Array([0x42]), isZip64: false, }; const z64 = { diskNumber: 99, diskWithCentralDir: 99, entriesOnThisDisk: 999, totalEntries: 999, centralDirSize: 99999, centralDirOffset: 99999, }; const merged = mergeEocd64(legacy, z64); expect(merged.diskNumber).toBe(1); expect(merged.totalEntries).toBe(3); expect(merged.centralDirSize).toBe(100); expect(merged.isZip64).toBe(true); expect(merged.comment).toEqual(new Uint8Array([0x42])); }); it("parseCdrEntry throws when buffer too small", () => { const buf = new Uint8Array(20); expect(() => parseCdrEntry(buf, 0)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT }) ); }); it("parseCdrEntry throws on bad signature", () => { const buf = new Uint8Array(60); expect(() => parseCdrEntry(buf, 0)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT }) ); }); it("parseCdrEntry throws when variable-length tails overshoot", () => { const buf = new Uint8Array(60); const v = new DataView(buf.buffer); v.setUint32(0, CDR_SIG, true); v.setUint16(28, 1000, true); // huge name expect(() => parseCdrEntry(buf, 0)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT }) ); }); it("parseCdrEntry ignores ZIP64 extra when no sentinels are set", () => { const name = enc("a.txt"); // ZIP64 extra carries some bytes but no 32-bit field is sentinel. const z64Extra = new Uint8Array(4 + 8); const zv = new DataView(z64Extra.buffer); zv.setUint16(0, ZIP64_EXTRA_TAG, true); zv.setUint16(2, 8, true); const cdr = new Uint8Array(46 + name.byteLength + z64Extra.byteLength); const cv = new DataView(cdr.buffer); cv.setUint32(0, CDR_SIG, true); cv.setUint32(20, 10, true); cv.setUint32(24, 10, true); cv.setUint16(28, name.byteLength, true); cv.setUint16(30, z64Extra.byteLength, true); cv.setUint16(34, 0, true); cv.setUint32(42, 1, true); cdr.set(name, 46); cdr.set(z64Extra, 46 + name.byteLength); const { entry } = parseCdrEntry(cdr, 0); expect(entry.compressedSize).toBe(10); expect(entry.uncompressedSize).toBe(10); expect(entry.localHeaderOffset).toBe(1); expect(entry.diskNumberStart).toBe(0); }); it("parseCdrEntry decodes ZIP64 extra overrides", () => { // Construct a CDR entry whose 32-bit fields are sentinel and // whose ZIP64 extra provides 64-bit overrides. const name = enc("big.bin"); const z64Extra = new Uint8Array(4 + 28); const zv = new DataView(z64Extra.buffer); zv.setUint16(0, ZIP64_EXTRA_TAG, true); zv.setUint16(2, 28, true); // uncompressed, compressed, localOffset (each 8 bytes), then disk (4 bytes) zv.setUint32(4, 1000, true); zv.setUint32(8, 0, true); zv.setUint32(12, 500, true); zv.setUint32(16, 0, true); zv.setUint32(20, 200, true); zv.setUint32(24, 0, true); zv.setUint32(28, 7, true); const cdr = new Uint8Array(46 + name.byteLength + z64Extra.byteLength); const cv = new DataView(cdr.buffer); cv.setUint32(0, CDR_SIG, true); cv.setUint32(20, 0xffffffff, true); // compressed cv.setUint32(24, 0xffffffff, true); // uncompressed cv.setUint16(28, name.byteLength, true); cv.setUint16(30, z64Extra.byteLength, true); cv.setUint16(34, 0xffff, true); // disk cv.setUint32(42, 0xffffffff, true); // local offset cdr.set(name, 46); cdr.set(z64Extra, 46 + name.byteLength); const { entry } = parseCdrEntry(cdr, 0); expect(entry.uncompressedSize).toBe(1000); expect(entry.compressedSize).toBe(500); expect(entry.localHeaderOffset).toBe(200); expect(entry.diskNumberStart).toBe(7); }); it("parseCdrEntry throws when ZIP64 extra missing compressed-size override", () => { const name = enc("a"); const z64Extra = new Uint8Array(4 + 8); // only 8 payload bytes — uncompressed override only const zv = new DataView(z64Extra.buffer); zv.setUint16(0, ZIP64_EXTRA_TAG, true); zv.setUint16(2, 8, true); zv.setUint32(4, 1000, true); const cdr = new Uint8Array(46 + name.byteLength + z64Extra.byteLength); const cv = new DataView(cdr.buffer); cv.setUint32(0, CDR_SIG, true); cv.setUint32(20, 0xffffffff, true); // compressed sentinel cv.setUint32(24, 0xffffffff, true); // uncompressed sentinel cv.setUint16(28, name.byteLength, true); cv.setUint16(30, z64Extra.byteLength, true); cdr.set(name, 46); cdr.set(z64Extra, 46 + name.byteLength); expect(() => parseCdrEntry(cdr, 0)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT }) ); }); it("parseCdrEntry throws when ZIP64 extra missing local-offset override", () => { const name = enc("a"); const z64Extra = new Uint8Array(4 + 16); // uncompressed + compressed, no offset const zv = new DataView(z64Extra.buffer); zv.setUint16(0, ZIP64_EXTRA_TAG, true); zv.setUint16(2, 16, true); const cdr = new Uint8Array(46 + name.byteLength + z64Extra.byteLength); const cv = new DataView(cdr.buffer); cv.setUint32(0, CDR_SIG, true); cv.setUint32(20, 0xffffffff, true); cv.setUint32(24, 0xffffffff, true); cv.setUint16(28, name.byteLength, true); cv.setUint16(30, z64Extra.byteLength, true); cv.setUint32(42, 0xffffffff, true); // localOffset sentinel cdr.set(name, 46); cdr.set(z64Extra, 46 + name.byteLength); expect(() => parseCdrEntry(cdr, 0)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT }) ); }); it("parseCdrEntry throws when ZIP64 extra missing disk-number override", () => { const name = enc("a"); // 8+8+8 = 24 bytes (uncompressed, compressed, localOffset) — no disk const z64Extra = new Uint8Array(4 + 24); const zv = new DataView(z64Extra.buffer); zv.setUint16(0, ZIP64_EXTRA_TAG, true); zv.setUint16(2, 24, true); const cdr = new Uint8Array(46 + name.byteLength + z64Extra.byteLength); const cv = new DataView(cdr.buffer); cv.setUint32(0, CDR_SIG, true); cv.setUint32(20, 0xffffffff, true); cv.setUint32(24, 0xffffffff, true); cv.setUint16(28, name.byteLength, true); cv.setUint16(30, z64Extra.byteLength, true); cv.setUint16(34, 0xffff, true); // disk sentinel cv.setUint32(42, 0xffffffff, true); cdr.set(name, 46); cdr.set(z64Extra, 46 + name.byteLength); expect(() => parseCdrEntry(cdr, 0)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT }) ); }); it("parseCdrEntry throws when ZIP64 value > Number.MAX_SAFE_INTEGER", () => { const name = enc("a"); const z64Extra = new Uint8Array(4 + 8); const zv = new DataView(z64Extra.buffer); zv.setUint16(0, ZIP64_EXTRA_TAG, true); zv.setUint16(2, 8, true); zv.setUint32(4, 0, true); zv.setUint32(8, 0xffffffff, true); // high 32 bits overflow const cdr = new Uint8Array(46 + name.byteLength + z64Extra.byteLength); const cv = new DataView(cdr.buffer); cv.setUint32(0, CDR_SIG, true); cv.setUint32(24, 0xffffffff, true); cv.setUint16(28, name.byteLength, true); cv.setUint16(30, z64Extra.byteLength, true); cdr.set(name, 46); cdr.set(z64Extra, 46 + name.byteLength); expect(() => parseCdrEntry(cdr, 0)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.ZIP64_REQUIRED }) ); }); it("parseCdrEntry throws when ZIP64 extra truncated", () => { const name = enc("a"); const z64Extra = new Uint8Array(4 + 4); // declares only 4 bytes, not enough for one 8-byte field const zv = new DataView(z64Extra.buffer); zv.setUint16(0, ZIP64_EXTRA_TAG, true); zv.setUint16(2, 4, true); const cdr = new Uint8Array(46 + name.byteLength + z64Extra.byteLength); const cv = new DataView(cdr.buffer); cv.setUint32(0, CDR_SIG, true); cv.setUint32(24, 0xffffffff, true); cv.setUint16(28, name.byteLength, true); cv.setUint16(30, z64Extra.byteLength, true); cdr.set(name, 46); cdr.set(z64Extra, 46 + name.byteLength); expect(() => parseCdrEntry(cdr, 0)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT }) ); }); it("parseCdrEntry rejects malformed extras (declared size overshoots)", () => { const name = enc("a"); const badExtra = new Uint8Array(8); const ev = new DataView(badExtra.buffer); ev.setUint16(0, 0xdead, true); ev.setUint16(2, 100, true); // claims 100 bytes const cdr = new Uint8Array(46 + name.byteLength + badExtra.byteLength); const cv = new DataView(cdr.buffer); cv.setUint32(0, CDR_SIG, true); cv.setUint16(28, name.byteLength, true); cv.setUint16(30, badExtra.byteLength, true); cdr.set(name, 46); cdr.set(badExtra, 46 + name.byteLength); expect(() => parseCdrEntry(cdr, 0)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT }) ); }); it("parseCdrEntry rejects AE extra truncated", () => { const name = enc("a"); const aeExtra = new Uint8Array(4 + 3); // only 3 payload bytes const av = new DataView(aeExtra.buffer); av.setUint16(0, AE_EXTRA_TAG, true); av.setUint16(2, 3, true); const cdr = new Uint8Array(46 + name.byteLength + aeExtra.byteLength); const cv = new DataView(cdr.buffer); cv.setUint32(0, CDR_SIG, true); cv.setUint16(28, name.byteLength, true); cv.setUint16(30, aeExtra.byteLength, true); cdr.set(name, 46); cdr.set(aeExtra, 46 + name.byteLength); expect(() => parseCdrEntry(cdr, 0)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT }) ); }); it("parseCdrEntry with AE extra but compressionMethod != placeholder keeps original method", () => { const name = enc("a"); const aeExtra = new Uint8Array(4 + 7); const av = new DataView(aeExtra.buffer); av.setUint16(0, AE_EXTRA_TAG, true); av.setUint16(2, 7, true); av.setUint16(4, 1, true); // AE-1 aeExtra[6] = "A".charCodeAt(0); aeExtra[7] = "E".charCodeAt(0); aeExtra[8] = 3; av.setUint16(9, 8, true); // originalMethod (advisory only) const cdr = new Uint8Array(46 + name.byteLength + aeExtra.byteLength); const cv = new DataView(cdr.buffer); cv.setUint32(0, CDR_SIG, true); cv.setUint16(10, 8, true); // compressionMethod = DEFLATE (not placeholder) cv.setUint16(28, name.byteLength, true); cv.setUint16(30, aeExtra.byteLength, true); cdr.set(name, 46); cdr.set(aeExtra, 46 + name.byteLength); const { entry } = parseCdrEntry(cdr, 0); expect(entry.compressionMethod).toBe(8); expect(entry.effectiveCompressionMethod).toBe(8); // unchanged expect(entry.aes).toBeDefined(); }); it("parseCdrEntry rejects AE strength byte not in 1..3", () => { const name = enc("a"); const aeExtra = new Uint8Array(4 + 7); const av = new DataView(aeExtra.buffer); av.setUint16(0, AE_EXTRA_TAG, true); av.setUint16(2, 7, true); av.setUint16(4, 2, true); aeExtra[6] = "A".charCodeAt(0); aeExtra[7] = "E".charCodeAt(0); aeExtra[8] = 7; // invalid strength av.setUint16(9, 0, true); const cdr = new Uint8Array(46 + name.byteLength + aeExtra.byteLength); const cv = new DataView(cdr.buffer); cv.setUint32(0, CDR_SIG, true); cv.setUint16(28, name.byteLength, true); cv.setUint16(30, aeExtra.byteLength, true); cdr.set(name, 46); cdr.set(aeExtra, 46 + name.byteLength); expect(() => parseCdrEntry(cdr, 0)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT }) ); }); it("walkCdr throws when buffer exhausted before expected count", () => { const buf = new Uint8Array(20); expect(() => walkCdr(buf, 0, 1)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT }) ); // Iteration index check expect(() => walkCdr(buf, 100, 1)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT }) ); }); it("decompress DEFLATE falls back when uncompressedSize is misadvertised", () => { // Synthesise a deflate stream by compressing 'abc' but tell decompress // we expect a larger output buffer. fflate writes the true bytes and // returns a sub-view; decompress should return the sub-view rather // than a partially-zero-filled out buffer. const compressed = deflateSync(enc("abc"), { level: 6 }); const out = decompress(compressed, 8, 999); expect(new TextDecoder().decode(out)).toBe("abc"); }); it("decompress STORE returns copy", () => { const src = new Uint8Array([1, 2, 3]); const out = decompress(src, 0, 3); expect(out).toEqual(src); // mutation isolated out[0] = 9; expect(src[0]).toBe(1); }); it("decompress throws for unsupported method", () => { expect(() => decompress(new Uint8Array(4), 99, 4)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.UNSUPPORTED_COMPRESSION, }) ); }); it("compress short input uses STORE", () => { const { method } = compress(enc("x")); expect(method).toBe(0); }); it("compress empty input uses STORE with zero bytes", () => { const { bytes, method } = compress(new Uint8Array(0)); expect(method).toBe(0); expect(bytes.byteLength).toBe(0); }); it("compress shrinkable input uses DEFLATE", () => { const big = new Uint8Array(4096).fill(0x41); const { method, bytes } = compress(big); expect(method).toBe(8); expect(bytes.byteLength).toBeLessThan(big.byteLength); }); it("parseLocalHeader throws for offset out of range", () => { const buf = new Uint8Array(20); expect(() => parseLocalHeader(buf, 0)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.LOCAL_HEADER_INVALID }) ); }); it("parseLocalHeader throws on bad signature", () => { const buf = new Uint8Array(40); expect(() => parseLocalHeader(buf, 0)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.LOCAL_HEADER_INVALID }) ); }); it("parseLocalHeader throws on variable-length overshoot", () => { const buf = new Uint8Array(40); const v = new DataView(buf.buffer); v.setUint32(0, LOCAL_HEADER_SIG, true); v.setUint16(26, 1000, true); // name length too big expect(() => parseLocalHeader(buf, 0)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.LOCAL_HEADER_INVALID }) ); }); it("parseLocalHeader decodes extras with truncated final extra (ignored)", () => { // Build a valid local header with 4 bytes of extras containing a tag with a too-large size. const name = enc("a"); const extras = new Uint8Array(4); const ev = new DataView(extras.buffer); ev.setUint16(0, 0xdead, true); ev.setUint16(2, 100, true); const lh = new Uint8Array(30 + name.byteLength + extras.byteLength); const v = new DataView(lh.buffer); v.setUint32(0, LOCAL_HEADER_SIG, true); v.setUint16(26, name.byteLength, true); v.setUint16(28, extras.byteLength, true); lh.set(name, 30); lh.set(extras, 30 + name.byteLength); const parsed = parseLocalHeader(lh, 0); expect(parsed.extras.size).toBe(0); // truncated extra is skipped }); it("readEntryBytes throws when payload exceeds buffer", () => { const bytes = buildPlainZip([ { name: "a.txt", payload: enc("hello"), method: 0 }, ]); // Build a synthetic entry referencing a payload past the end. const fake = { versionMadeBy: 0, versionNeeded: 20, flags: 0x0800, compressionMethod: 0, effectiveCompressionMethod: 0, mtimeDos: 0, mdateDos: 0x21, crc32: 0, compressedSize: 999999, uncompressedSize: 999999, diskNumberStart: 0, internalAttrs: 0, externalAttrs: 0, localHeaderOffset: 0, name: "a.txt", extras: new Map(), comment: new Uint8Array(0), }; expect(() => readEntryBytes(bytes, fake)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.LOCAL_HEADER_INVALID }) ); }); it("normalizePath happy path returns input", () => { expect(normalizePath("a/b.txt")).toBe("a/b.txt"); }); it("normalizePath rejects every disallowed form", () => { expect(() => normalizePath("")).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.PATH_INVALID }) ); expect(() => normalizePath("a\\b")).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.PATH_INVALID }) ); expect(() => normalizePath("/abs")).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.PATH_INVALID }) ); expect(() => normalizePath("a/../b")).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.PATH_INVALID }) ); }); it("aesKeyLength branches for AES-128 / AES-192 strengths", async () => { const aes128 = { aeVersion: 1, vendorId: "AE", strength: 1 as const, originalMethod: 0, }; const aes192 = { aeVersion: 1, vendorId: "AE", strength: 2 as const, originalMethod: 0, }; // These will fail at the shortness check (payload too small for any salt), // but only AFTER the keyLen branch is hit. await expect( aesDecrypt(aes128, new Uint8Array(5), "pw", "x") ).rejects.toMatchObject({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT }); await expect( aesDecrypt(aes192, new Uint8Array(5), "pw", "x") ).rejects.toMatchObject({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT }); }); it("aesDecrypt rejects payload shorter than salt + verifier + hmac", async () => { const aes = { aeVersion: 2, vendorId: "AE", strength: 3 as const, originalMethod: 0, }; await expect( aesDecrypt(aes, new Uint8Array(8), "pw", "x.txt") ).rejects.toMatchObject({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT }); }); it("storage propagates non-auth AES errors verbatim", async () => { // Build a normal AES archive then truncate the payload so aesDecrypt // raises CENTRAL_DIR_CORRUPT instead of AES_AUTH_FAILED. const { buildAesZip } = await import("../__fixtures__/build-zip.js"); const aesBytes = await buildAesZip( "x.txt", new TextEncoder().encode("payload"), "pw", 0 ); const { ZipArchiveStorage } = await import( "../storage/zip-archive-storage.js" ); // Patch the CDR compressedSize to 4 (way below saltLen+2+10=28) so // readEntryBytes succeeds but aesDecrypt fails the overhead check. const corrupted = aesBytes.slice(); const v = new DataView(corrupted.buffer); const eocdOffset = corrupted.byteLength - 22; const cdrOff = v.getUint32(eocdOffset + 16, true); v.setUint32(cdrOff + 20, 4, true); // compressedSize in CDR const storage = await ZipArchiveStorage.open({ source: corrupted, mode: "read-only", password: async () => "pw", }); await expect(storage.get("x.txt")).rejects.toMatchObject({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT, }); }); it("aesDecrypt rejects HMAC tampering with HMAC mismatch", async () => { // Build a fixture, then flip the HMAC tag. const { buildAesZip } = await import("../__fixtures__/build-zip.js"); const aesBytes = await buildAesZip( "x.txt", new TextEncoder().encode("payload"), "pw", 0 ); // Find the AES payload in the buffer. The payload sits between the // local-header and the CDR; the local header starts at byte 0. Find // the local-header length by reading name+extras. const v = new DataView(aesBytes.buffer); const nameLen = v.getUint16(26, true); const extraLen = v.getUint16(28, true); const compressedSize = v.getUint32(18, true); const payloadStart = 30 + nameLen + extraLen; // Last 10 bytes of the payload are the HMAC tag. const tagStart = payloadStart + compressedSize - 10; // Build a copy and corrupt one HMAC byte to force HMAC mismatch. const corrupted = aesBytes.slice(); corrupted[tagStart] = corrupted[tagStart] ^ 0xff; const { ZipArchiveStorage } = await import( "../storage/zip-archive-storage.js" ); const storage = await ZipArchiveStorage.open({ source: corrupted, mode: "read-only", password: async () => "pw", }); // The PASSWORD_VERIFIER bytes are still valid (we only touched HMAC), // so verifier check passes and HMAC check fails. await expect(storage.get("x.txt")).rejects.toMatchObject({ archiveCode: "ARCHIVE.PASSWORD_INVALID", }); }); it("aesWriteBlock always throws", () => { expect(() => aesWriteBlock("pw", new Uint8Array(4), 3)).toThrow( expect.objectContaining({ zipCode: ZIP_CODES.AES_WRITE_UNSUPPORTED, }) ); }); it("crc32 matches known-answer for the empty string", () => { expect(crc32(new Uint8Array(0))).toBe(0); expect(crc32(enc("123456789"))).toBe(0xcbf43926); }); });