Release Archive Tools 0.1.0

This commit is contained in:
2026-09-01 02:42:42 +02:00
commit a49ec6b17a
77 changed files with 11528 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { assessArchivePath } from "../../src/archive/paths";
describe("archive path policy", () => {
it.each([
"../escape.txt",
"/absolute.txt",
"C:/drive.txt",
"safe/../../escape",
"CON",
"file.txt:stream",
"trailing. ",
"invoice\u202egnp.exe",
])("blocks unsafe path %s", (path) =>
expect(assessArchivePath(path).safe).toBe(false),
);
it("normalizes Unicode and treats backslashes as cross-platform separators", () => {
const result = assessArchivePath("Folder\\cafe\u0301.txt");
expect(result.normalized).toBe("Folder/café.txt");
expect(result.safe).toBe(true);
expect(result.issues.map((issue) => issue.code)).toContain(
"BACKSLASH_PATH",
);
});
it("produces a conservative case-insensitive collision key", () => {
expect(assessArchivePath("Folder/Report.TXT").collisionKey).toBe(
assessArchivePath("folder/report.txt").collisionKey,
);
});
});
+119
View File
@@ -0,0 +1,119 @@
// @vitest-environment node
import { File as NodeFile } from "node:buffer";
import { describe, expect, it } from "vitest";
import { previewEntry } from "../../src/archive/preview";
import type {
ArchiveDocument,
ArchiveEntryRecord,
} from "../../src/archive/types";
describe("bounded previews", () => {
it("returns a safe static PNG Blob only after dimension inspection", async () => {
const png = Uint8Array.from(
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
),
);
await expect(previewFixture("pixel.png", png)).resolves.toMatchObject({
kind: "image",
mimeType: "image/png",
width: 1,
height: 1,
});
});
it("refuses animated PNG previews before browser decoding", async () => {
const png = Uint8Array.from(
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
),
);
const idat = findChunk(png, "IDAT");
const actl = new Uint8Array([
0, 0, 0, 8, 0x61, 0x63, 0x54, 0x4c, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0,
]);
const animated = new Uint8Array(png.length + actl.length);
animated.set(png.subarray(0, idat), 0);
animated.set(actl, idat);
animated.set(png.subarray(idat), idat + actl.length);
await expect(previewFixture("animated.png", animated)).rejects.toThrow(
/Animated/iu,
);
});
it("supports a bounded VP8X WebP inventory without interpreting active content", async () => {
const webp = new Uint8Array(30);
webp.set(new TextEncoder().encode("RIFF"), 0);
new DataView(webp.buffer).setUint32(4, 22, true);
webp.set(new TextEncoder().encode("WEBPVP8X"), 8);
new DataView(webp.buffer).setUint32(16, 10, true);
webp[24] = 2;
webp[27] = 3;
await expect(previewFixture("sample.webp", webp)).resolves.toMatchObject({
kind: "image",
width: 3,
height: 4,
mimeType: "image/webp",
});
});
it("shows nested archives as bytes without recursive expansion", async () => {
const nested = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 1, 2, 3]);
await expect(previewFixture("nested.zip", nested)).resolves.toMatchObject({
kind: "hex",
note: expect.stringMatching(/Nested archive expansion/iu),
});
});
});
function documentFor(path: string, payload: Uint8Array): ArchiveDocument {
const entry: ArchiveEntryRecord = {
id: "0",
sourceIndex: 0,
rawPath: path,
path,
collisionKey: path.toLocaleLowerCase("en-US"),
kind: "file",
size: payload.length,
extractable: true,
issues: [],
dataOffset: 0,
};
return {
name: "fixture.gz",
format: "gzip",
source: new NodeFile(
[Buffer.from(payload)],
"fixture.gz",
) as unknown as File,
sourceBytes: payload.length,
entries: [entry],
issues: [],
expandedBytes: payload.length,
compressedBytes: payload.length,
zip64: false,
payload,
};
}
function previewFixture(path: string, payload: Uint8Array) {
const document = documentFor(path, payload);
return previewEntry(document, document.entries[0]!);
}
function findChunk(bytes: Uint8Array, type: string): number {
const expected = new TextEncoder().encode(type);
for (let offset = 8; offset + 8 <= bytes.length;) {
if (expected.every((value, index) => bytes[offset + 4 + index] === value))
return offset;
const length = new DataView(
bytes.buffer,
bytes.byteOffset + offset,
4,
).getUint32(0);
offset += 12 + length;
}
throw new Error(`${type} chunk not found`);
}
+93
View File
@@ -0,0 +1,93 @@
// @vitest-environment node
import { File } from "node:buffer";
import { describe, expect, it } from "vitest";
import { gzipDeterministic, gunzipBounded } from "../../src/archive/gzip";
import { createTar, parseTar } from "../../src/archive/tar";
function input(path: string, text: string) {
return {
path,
file: new File(
[text],
path.split("/").at(-1)!,
) as unknown as globalThis.File,
};
}
describe("TAR, USTAR, PAX and gzip", () => {
it("creates deterministic TAR and parses regular entries", async () => {
const inputs = [input("b.txt", "second"), input("folder/a.txt", "first")];
const first = await createTar(inputs);
const second = await createTar(inputs);
expect(first).toEqual(second);
const parsed = parseTar(first);
expect(parsed.entries.map((entry) => entry.path)).toEqual([
"b.txt",
"folder/a.txt",
]);
expect(parsed.entries.every((entry) => entry.extractable)).toBe(true);
expect(parsed.entries[1]?.crc32).toBe("9271ee57");
});
it("round-trips a long path through a bounded PAX path header", async () => {
const longPath = `${"long-".repeat(24)}name.txt`;
const tar = await createTar([input(longPath, "PAX")]);
expect(parseTar(tar).entries[0]?.path).toBe(longPath);
});
it("lists but blocks symbolic links", async () => {
const tar = await createTar([input("link", "target")]);
tar[156] = 0x32;
rewriteChecksum(tar.subarray(0, 512));
const entry = parseTar(tar).entries[0]!;
expect(entry.kind).toBe("symlink");
expect(entry.extractable).toBe(false);
expect(entry.issues.map((issue) => issue.code)).toContain("SPECIAL_ENTRY");
});
it("rejects a damaged TAR header checksum", async () => {
const tar = await createTar([input("file.txt", "data")]);
tar[0] = tar[0]! ^ 1;
expect(() => parseTar(tar)).toThrow(/checksum/iu);
});
it("rejects ambiguous non-zero data after the TAR end marker", async () => {
const tar = await createTar([input("file.txt", "data")]);
const appended = new Uint8Array(tar.length + 1);
appended.set(tar);
appended[tar.length] = 1;
expect(() => parseTar(appended)).toThrow(/follows the TAR end/iu);
});
it("creates deterministic gzip and validates CRC and ISIZE", () => {
const source = new TextEncoder().encode("local archive payload");
const first = gzipDeterministic(source);
expect(first).toEqual(gzipDeterministic(source));
expect(gunzipBounded(first, 1024)).toEqual(source);
first[first.length - 8] = first[first.length - 8]! ^ 1;
expect(() => gunzipBounded(first, 1024)).toThrow(/CRC-32|footer/iu);
});
it("stops gzip expansion at the configured operation limit", () => {
const compressed = gzipDeterministic(new Uint8Array(32_768));
expect(() => gunzipBounded(compressed, 1024)).toThrow(/expanded-byte/iu);
});
it("explicitly rejects concatenated gzip members", () => {
const left = gzipDeterministic(new TextEncoder().encode("left"));
const right = gzipDeterministic(new TextEncoder().encode("right"));
const joined = new Uint8Array(left.length + right.length);
joined.set(left);
joined.set(right, left.length);
expect(() => gunzipBounded(joined, 1024)).toThrow(/multi-member/iu);
});
});
function rewriteChecksum(header: Uint8Array) {
header.fill(0x20, 148, 156);
const checksum = header.reduce((sum, byte) => sum + byte, 0);
const text = checksum.toString(8).padStart(6, "0");
header.set(new TextEncoder().encode(text), 148);
header[154] = 0;
header[155] = 0x20;
}
+193
View File
@@ -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);
});
});