Initial Commit
This commit is contained in:
@@ -0,0 +1,728 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user