265 lines
8.5 KiB
TypeScript
265 lines
8.5 KiB
TypeScript
// @vitest-environment node
|
|
import { File as NodeFile } from "node:buffer";
|
|
import { BlobReader, BlobWriter, TextReader, ZipWriter } from "@zip.js/zip.js";
|
|
import { describe, expect, it } from "vitest";
|
|
import { compareArchives } from "../../src/archive/compare";
|
|
import { previewEntry } from "../../src/archive/preview";
|
|
import {
|
|
createArchive,
|
|
createSafeSelectionZip,
|
|
inspectArchive,
|
|
readEntryBytes,
|
|
detectArchiveFormat,
|
|
} from "../../src/archive/service";
|
|
|
|
const asFile = (
|
|
parts: ConstructorParameters<typeof NodeFile>[0],
|
|
name: string,
|
|
type = "application/octet-stream",
|
|
) => new NodeFile(parts, name, { type }) as unknown as globalThis.File;
|
|
|
|
describe("ZIP inspection and safe workflows", () => {
|
|
it("identifies structural-only 7z, RAR4 and RAR5 inputs", () => {
|
|
expect(
|
|
detectArchiveFormat(
|
|
"archive.bin",
|
|
new Uint8Array([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c]),
|
|
),
|
|
).toBe("7z");
|
|
expect(
|
|
detectArchiveFormat(
|
|
"archive.bin",
|
|
new Uint8Array([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00]),
|
|
),
|
|
).toBe("rar4");
|
|
expect(
|
|
detectArchiveFormat(
|
|
"archive.bin",
|
|
new Uint8Array([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00]),
|
|
),
|
|
).toBe("rar5");
|
|
});
|
|
it("creates byte-deterministic ZIPs, reads CRC-checked content and previews text", async () => {
|
|
const files = [
|
|
asFile(["hello"], "hello.txt"),
|
|
asFile(["world"], "world.txt"),
|
|
];
|
|
const first = await createArchive(files, "zip");
|
|
const second = await createArchive(files, "zip");
|
|
expect(new Uint8Array(await first.arrayBuffer())).toEqual(
|
|
new Uint8Array(await second.arrayBuffer()),
|
|
);
|
|
|
|
const document = await inspectArchive(
|
|
asFile([await first.arrayBuffer()], "sample.zip"),
|
|
);
|
|
expect(document.entries).toHaveLength(2);
|
|
expect(document.entries.every((entry) => entry.extractable)).toBe(true);
|
|
const bytes = await readEntryBytes(document, document.entries[0]!, 1024);
|
|
expect(new TextDecoder().decode(bytes)).toBe("hello");
|
|
await expect(
|
|
previewEntry(document, document.entries[0]!),
|
|
).resolves.toMatchObject({ kind: "text", text: "hello" });
|
|
});
|
|
|
|
it("blocks unsafe paths while opening AES entries only with a password", async () => {
|
|
const blobWriter = new BlobWriter("application/zip");
|
|
const writer = new ZipWriter(blobWriter, { useWebWorkers: false });
|
|
await writer.add("../escape.txt", new TextReader("bad"), {
|
|
useWebWorkers: false,
|
|
});
|
|
await writer.add("Report.txt", new TextReader("one"), {
|
|
useWebWorkers: false,
|
|
});
|
|
await writer.add("report.TXT", new TextReader("two"), {
|
|
useWebWorkers: false,
|
|
});
|
|
await writer.add("secret.txt", new TextReader("secret"), {
|
|
password: "test-password",
|
|
useWebWorkers: false,
|
|
});
|
|
const blob = await writer.close();
|
|
const document = await inspectArchive(
|
|
asFile([await blob.arrayBuffer()], "unsafe.zip"),
|
|
);
|
|
expect(document.entries[0]?.issues.map((issue) => issue.code)).toContain(
|
|
"PATH_TRAVERSAL",
|
|
);
|
|
expect(document.entries[2]?.issues.map((issue) => issue.code)).toContain(
|
|
"DUPLICATE_PATH",
|
|
);
|
|
expect(document.entries[3]).toMatchObject({
|
|
encrypted: true,
|
|
encryption: "AES-256",
|
|
extractable: true,
|
|
});
|
|
await expect(
|
|
readEntryBytes(document, document.entries[3]!, 1024),
|
|
).rejects.toThrow(/password is required/iu);
|
|
await expect(
|
|
readEntryBytes(document, document.entries[3]!, 1024, undefined, "wrong"),
|
|
).rejects.toThrow(/incorrect|damaged/iu);
|
|
expect(
|
|
new TextDecoder().decode(
|
|
await readEntryBytes(
|
|
document,
|
|
document.entries[3]!,
|
|
1024,
|
|
undefined,
|
|
"test-password",
|
|
),
|
|
),
|
|
).toBe("secret");
|
|
});
|
|
|
|
it.each([
|
|
["aes-256", "AES-256"],
|
|
["zipcrypto", "ZipCrypto"],
|
|
] as const)(
|
|
"creates and reads %s encrypted ZIPs locally",
|
|
async (method, label) => {
|
|
const encrypted = await createArchive(
|
|
[asFile(["classified"], "secret.txt")],
|
|
"zip",
|
|
undefined,
|
|
undefined,
|
|
{ password: "correct horse battery staple", method },
|
|
);
|
|
const document = await inspectArchive(
|
|
asFile([await encrypted.arrayBuffer()], `${method}.zip`),
|
|
);
|
|
expect(document.entries[0]).toMatchObject({
|
|
encrypted: true,
|
|
encryption: label,
|
|
extractable: true,
|
|
});
|
|
const bytes = await readEntryBytes(
|
|
document,
|
|
document.entries[0]!,
|
|
1024,
|
|
undefined,
|
|
"correct horse battery staple",
|
|
);
|
|
expect(new TextDecoder().decode(bytes)).toBe("classified");
|
|
},
|
|
);
|
|
|
|
it("enforces compression-ratio policy before decompression", async () => {
|
|
const blob = await createArchive(
|
|
[asFile([new Uint8Array(1024 * 1024)], "zeros.bin")],
|
|
"zip",
|
|
);
|
|
const document = await inspectArchive(
|
|
asFile([await blob.arrayBuffer()], "ratio.zip"),
|
|
);
|
|
expect(document.entries[0]?.extractable).toBe(false);
|
|
expect(document.entries[0]?.issues.map((issue) => issue.code)).toContain(
|
|
"COMPRESSION_RATIO",
|
|
);
|
|
});
|
|
|
|
it("detects stored-entry corruption when content is requested", async () => {
|
|
const writer = new ZipWriter(new BlobWriter("application/zip"), {
|
|
level: 0,
|
|
useWebWorkers: false,
|
|
});
|
|
await writer.add("hello.txt", new TextReader("hello"), {
|
|
level: 0,
|
|
useWebWorkers: false,
|
|
});
|
|
const blob = await writer.close();
|
|
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
const nameLength = bytes[26]! | (bytes[27]! << 8);
|
|
const extraLength = bytes[28]! | (bytes[29]! << 8);
|
|
bytes[30 + nameLength + extraLength] =
|
|
bytes[30 + nameLength + extraLength]! ^ 1;
|
|
const document = await inspectArchive(asFile([bytes], "corrupt.zip"));
|
|
await expect(
|
|
readEntryBytes(document, document.entries[0]!, 1024),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("repackages only a verified selection into a fresh safe ZIP", async () => {
|
|
const original = await createArchive(
|
|
[asFile(["one"], "one.txt"), asFile(["two"], "two.txt")],
|
|
"zip",
|
|
);
|
|
const document = await inspectArchive(
|
|
asFile([await original.arrayBuffer()], "original.zip"),
|
|
);
|
|
const safe = await createSafeSelectionZip(document, [
|
|
document.entries[1]!.id,
|
|
]);
|
|
const repackaged = await inspectArchive(
|
|
asFile([await safe.arrayBuffer()], "safe.zip"),
|
|
);
|
|
expect(repackaged.entries.map((entry) => entry.path)).toEqual(["two.txt"]);
|
|
expect(
|
|
new TextDecoder().decode(
|
|
await readEntryBytes(repackaged, repackaged.entries[0]!, 1024),
|
|
),
|
|
).toBe("two");
|
|
});
|
|
|
|
it("compares archive content independent of container format", async () => {
|
|
const leftBlob = await createArchive(
|
|
[asFile(["same"], "same.txt"), asFile(["old"], "changed.txt")],
|
|
"zip",
|
|
);
|
|
const rightBlob = await createArchive(
|
|
[asFile(["same"], "same.txt"), asFile(["new"], "changed.txt")],
|
|
"tar",
|
|
);
|
|
const left = await inspectArchive(
|
|
asFile([await leftBlob.arrayBuffer()], "left.zip"),
|
|
);
|
|
const right = await inspectArchive(
|
|
asFile([await rightBlob.arrayBuffer()], "right.tar"),
|
|
);
|
|
expect(
|
|
compareArchives(left, right).map(({ path, status }) => ({
|
|
path,
|
|
status,
|
|
})),
|
|
).toEqual([
|
|
{ path: "changed.txt", status: "changed" },
|
|
{ path: "same.txt", status: "same" },
|
|
]);
|
|
});
|
|
|
|
it("reads a ZIP produced externally through a BlobReader", async () => {
|
|
const writer = new ZipWriter(new BlobWriter("application/zip"), {
|
|
useWebWorkers: false,
|
|
});
|
|
await writer.add(
|
|
"folder/data.bin",
|
|
new BlobReader(new Blob([new Uint8Array([0, 1, 2, 3])])),
|
|
{ useWebWorkers: false },
|
|
);
|
|
const document = await inspectArchive(
|
|
asFile([await (await writer.close()).arrayBuffer()], "external.zip"),
|
|
);
|
|
expect(document.entries[0]).toMatchObject({
|
|
path: "folder/data.bin",
|
|
size: 4,
|
|
compression: "deflate",
|
|
});
|
|
});
|
|
|
|
it("recognizes ZIP64 metadata without requiring a multi-gigabyte fixture", async () => {
|
|
const writer = new ZipWriter(new BlobWriter("application/zip"), {
|
|
useWebWorkers: false,
|
|
zip64: true,
|
|
});
|
|
await writer.add("zip64.txt", new TextReader("small fixture"), {
|
|
useWebWorkers: false,
|
|
zip64: true,
|
|
});
|
|
const document = await inspectArchive(
|
|
asFile([await (await writer.close()).arrayBuffer()], "forced-zip64.zip"),
|
|
);
|
|
expect(document.zip64).toBe(true);
|
|
expect(document.entries[0]?.zip64).toBe(true);
|
|
});
|
|
});
|