Release Archive Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
// @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,
|
||||
} 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("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 traversal, case-colliding and encrypted entries at inspection", 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]?.issues.map((issue) => issue.code)).toContain(
|
||||
"ENCRYPTED_ENTRY",
|
||||
);
|
||||
expect(document.entries.filter((entry) => entry.extractable)).toEqual([]);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user