Files
archive-tools/tests/archive/zip-service.test.ts
T
zemion fe578f46bd
Verify / verify (push) Canceled after 0s
Release Archive Tools 0.2.0
2026-09-02 09:34:59 +02:00

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