Files
suaveplan/packages/networking/drive/drive-storage-zip/src/parser/parser.test.ts
2026-05-27 23:47:53 +01:00

729 lines
29 KiB
TypeScript

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);
});
});