Release Archive Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 09:34:59 +02:00
parent a49ec6b17a
commit fe578f46bd
47 changed files with 3337 additions and 253 deletions
+277
View File
@@ -0,0 +1,277 @@
// @vitest-environment node
import { File as NodeFile } from "node:buffer";
import { describe, expect, it } from "vitest";
import { crc32 } from "../../src/archive/checksums";
import { inspectArchive, reportJson } from "../../src/archive/service";
const asFile = (bytes: Uint8Array, name: string) =>
new NodeFile([new Uint8Array(bytes).buffer], name) as unknown as File;
describe("bounded 7z structural inspection", () => {
it("validates both start CRCs and inventories plain FilesInfo names", async () => {
const names = utf16le("alpha.txt\0folder\0");
const next = bytes(
[
0x01,
0x05,
0x02,
0x0e,
0x01,
0x40,
0x0f,
0x01,
0x00,
0x11,
names.length + 1,
0x00,
],
names,
[0x00, 0x00],
);
const document = await inspectArchive(asFile(sevenZip(next), "plain.7z"));
expect(document.structural).toMatchObject({
parser: "7z",
headerCrcs: { verified: 2, failed: 0 },
nextHeader: { kind: "plain", metadataParsed: true },
});
expect(
document.entries.map(({ path, kind, sizeKnown, extractable }) => ({
path,
kind,
sizeKnown,
extractable,
})),
).toEqual([
{ path: "alpha.txt", kind: "file", sizeKnown: false, extractable: false },
{
path: "folder/",
kind: "directory",
sizeKnown: true,
extractable: false,
},
]);
expect(reportJson(document)).toContain(
"packed streams, codec chains, passwords and extraction are unsupported",
);
});
it("reports an encoded next header without decoding it", async () => {
const document = await inspectArchive(
asFile(sevenZip(new Uint8Array([0x17])), "encoded.7z"),
);
expect(document.entries).toEqual([]);
expect(document.structural?.nextHeader).toMatchObject({
kind: "encoded",
metadataParsed: false,
});
expect(document.issues.map((issue) => issue.code)).toContain(
"SEVEN_ZIP_ENCODED_HEADER",
);
});
it("fails closed on a corrupt next-header CRC", async () => {
const input = sevenZip(new Uint8Array([0x17]));
input[input.length - 1] = input[input.length - 1]! ^ 1;
await expect(inspectArchive(asFile(input, "corrupt.7z"))).rejects.toThrow(
/next-header CRC/iu,
);
});
});
describe("bounded RAR structural inspection", () => {
it("validates RAR5 blocks and inventories a stored file", async () => {
const name = new TextEncoder().encode("hello.txt");
const archive = bytes(
[0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00],
rar5Block([0x01, 0x00, 0x00]),
rar5Block(
[0x02, 0x1b, 0x02, 0x03, 0x00, 0x03, 0x00, 0x00, 0x00, name.length],
bytes(name, [0x01, 0x01]),
[1, 2, 3],
),
rar5Block([0x05, 0x00, 0x00]),
);
const document = await inspectArchive(asFile(archive, "sample.rar"));
expect(document.structural).toMatchObject({
parser: "rar5",
blocks: 3,
ended: true,
});
expect(document.entries[0]).toMatchObject({
path: "hello.txt",
size: 3,
compressedSize: 3,
compression: "RAR5 stored",
encrypted: true,
extractable: false,
});
expect(document.entries[0]?.issues.map((issue) => issue.code)).toEqual(
expect.arrayContaining(["RAR_ENCRYPTED_ENTRY", "RAR_SPLIT_ENTRY"]),
);
});
it("validates RAR4 blocks and inventories legacy file metadata", async () => {
const name = new TextEncoder().encode("legacy.txt");
const fileBody = new Uint8Array(25 + name.length);
const view = new DataView(fileBody.buffer);
view.setUint32(0, 3, true);
view.setUint32(4, 3, true);
view.setUint32(9, 0x352441c2, true);
fileBody[17] = 20;
fileBody[18] = 0x30;
view.setUint16(19, name.length, true);
name.forEach((byte, index) => (fileBody[25 + index] = byte));
const archive = bytes(
[0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00],
rar4Block(0x73, 0, new Uint8Array(6)),
rar4Block(0x74, 0x8000, fileBody, new Uint8Array([1, 2, 3])),
rar4Block(0x7b, 0, new Uint8Array()),
);
const document = await inspectArchive(asFile(archive, "legacy.rar"));
expect(document.structural).toMatchObject({
parser: "rar4",
blocks: 3,
ended: true,
});
expect(document.entries[0]).toMatchObject({
path: "legacy.txt",
size: 3,
compressedSize: 3,
compression: "RAR4 stored",
extractable: false,
});
});
it("stops safely at an encrypted RAR5 header and rejects bad header CRCs", async () => {
const main = rar5Block([0x01, 0x00, 0x00]);
const encryptedHeader = rar5Block([0x04, 0x00, 0x00]);
const encrypted = bytes(
[0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00],
main,
encryptedHeader,
new Uint8Array([1, 2, 3]),
);
const document = await inspectArchive(
asFile(encrypted, "headers-encrypted.rar"),
);
expect(document.structural).toMatchObject({
encryptedHeaders: true,
ended: false,
});
expect(document.issues.map((issue) => issue.code)).toContain(
"RAR5_ENCRYPTED_HEADERS",
);
const corrupt = bytes(
[0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00],
main,
);
corrupt[8] = corrupt[8]! ^ 1;
await expect(
inspectArchive(asFile(corrupt, "corrupt.rar")),
).rejects.toThrow(/CRC-32/iu);
await expect(
inspectArchive(
asFile(
bytes([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00], [1, 2, 3]),
"truncated.rar",
),
),
).rejects.toThrow(/truncated/iu);
});
it("stops safely before encrypted RAR4 file headers", async () => {
const archive = bytes(
[0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00],
rar4Block(0x73, 0x0080, new Uint8Array(6)),
[9, 9, 9],
);
const document = await inspectArchive(
asFile(archive, "rar4-encrypted-headers.rar"),
);
expect(document.entries).toEqual([]);
expect(document.structural).toMatchObject({
parser: "rar4",
encryptedHeaders: true,
ended: false,
});
expect(document.issues.map((issue) => issue.code)).toContain(
"RAR4_ENCRYPTED_HEADERS",
);
});
});
function sevenZip(next: Uint8Array): Uint8Array {
const result = new Uint8Array(32 + next.length);
result.set([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c, 0x00, 0x04]);
const view = new DataView(result.buffer);
view.setBigUint64(12, 0n, true);
view.setBigUint64(20, BigInt(next.length), true);
view.setUint32(28, crc32(next), true);
view.setUint32(8, crc32(result.subarray(12, 32)), true);
result.set(next, 32);
return result;
}
function rar5Block(
body: number[] | Uint8Array,
suffix?: Uint8Array,
data?: number[] | Uint8Array,
): Uint8Array {
const headerBody = bytes(body, suffix ?? new Uint8Array());
const size = vint(headerBody.length);
const crcInput = bytes(size, headerBody);
const header = new Uint8Array(4 + crcInput.length);
new DataView(header.buffer).setUint32(0, crc32(crcInput), true);
header.set(crcInput, 4);
return bytes(header, data ?? new Uint8Array());
}
function rar4Block(
type: number,
flags: number,
body: Uint8Array,
data = new Uint8Array(),
): Uint8Array {
const header = new Uint8Array(7 + body.length);
const view = new DataView(header.buffer);
header[2] = type;
view.setUint16(3, flags, true);
view.setUint16(5, header.length, true);
header.set(body, 7);
view.setUint16(0, crc32(header.subarray(2)) & 0xffff, true);
return bytes(header, data);
}
function vint(value: number): Uint8Array {
const result: number[] = [];
do {
const next = value & 0x7f;
value >>>= 7;
result.push(value ? next | 0x80 : next);
} while (value);
return new Uint8Array(result);
}
function utf16le(value: string): Uint8Array {
const result = new Uint8Array(value.length * 2);
const view = new DataView(result.buffer);
for (let index = 0; index < value.length; index += 1)
view.setUint16(index * 2, value.charCodeAt(index), true);
return result;
}
function bytes(...parts: (number[] | Uint8Array)[]): Uint8Array {
const arrays = parts.map((part) =>
part instanceof Uint8Array ? part : new Uint8Array(part),
);
const result = new Uint8Array(
arrays.reduce((sum, part) => sum + part.length, 0),
);
let offset = 0;
for (const part of arrays) {
result.set(part, offset);
offset += part.length;
}
return result;
}
+72 -3
View File
@@ -1,7 +1,11 @@
// @vitest-environment node
import { File } from "node:buffer";
import { describe, expect, it } from "vitest";
import { gzipDeterministic, gunzipBounded } from "../../src/archive/gzip";
import {
gzipDeterministic,
gunzipBounded,
gunzipBoundedDetailed,
} from "../../src/archive/gzip";
import { createTar, parseTar } from "../../src/archive/tar";
function input(path: string, text: string) {
@@ -73,16 +77,81 @@ describe("TAR, USTAR, PAX and gzip", () => {
expect(() => gunzipBounded(compressed, 1024)).toThrow(/expanded-byte/iu);
});
it("explicitly rejects concatenated gzip members", () => {
it("decodes concatenated gzip members and validates each footer", () => {
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);
expect(new TextDecoder().decode(gunzipBounded(joined, 1024))).toBe(
"leftright",
);
expect(gunzipBoundedDetailed(joined, 1024).members).toMatchObject([
{ index: 0, expandedBytes: 4 },
{ index: 1, expandedBytes: 5 },
]);
joined[left.length - 8] = joined[left.length - 8]! ^ 1;
expect(() => gunzipBounded(joined, 1024)).toThrow(/member 1/iu);
});
it("reads bounded GNU long-name and long-link metadata", async () => {
const regular = await createTar([input("placeholder", "data")]);
const longPath = `${"gnu/".repeat(35)}entry.txt`;
const named = prependGnuMetadata(regular, 0x4c, longPath);
expect(parseTar(named).entries[0]?.path).toBe(longPath);
const linkTarget = `${"target/".repeat(24)}file.txt`;
const linked = prependGnuMetadata(regular.slice(), 0x4b, linkTarget);
linked[512 + paddedPayloadSize(linkTarget) + 156] = 0x32;
rewriteChecksum(
linked.subarray(
512 + paddedPayloadSize(linkTarget),
1024 + paddedPayloadSize(linkTarget),
),
);
expect(parseTar(linked).entries[0]).toMatchObject({
kind: "symlink",
linkTarget,
extractable: false,
});
});
});
function paddedPayloadSize(value: string): number {
return (
Math.ceil(new TextEncoder().encode(`${value}\0`).byteLength / 512) * 512
);
}
function prependGnuMetadata(
tar: Uint8Array,
type: number,
value: string,
): Uint8Array {
const payload = new TextEncoder().encode(`${value}\0`);
const padded = Math.ceil(payload.byteLength / 512) * 512;
const output = new Uint8Array(512 + padded + tar.byteLength);
const header = output.subarray(0, 512);
header.set(new TextEncoder().encode("././@LongLink"), 0);
header.set(new TextEncoder().encode("0000644\0"), 100);
header.set(new TextEncoder().encode("0000000\0"), 108);
header.set(new TextEncoder().encode("0000000\0"), 116);
header.set(
new TextEncoder().encode(
`${payload.byteLength.toString(8).padStart(11, "0")}\0`,
),
124,
);
header.set(new TextEncoder().encode("00000000000\0"), 136);
header[156] = type;
header.set(new TextEncoder().encode("ustar\0"), 257);
header.set(new TextEncoder().encode("00"), 263);
rewriteChecksum(header);
output.set(payload, 512);
output.set(tar, 512 + padded);
return output;
}
function rewriteChecksum(header: Uint8Array) {
header.fill(0x20, 148, 156);
const checksum = header.reduce((sum, byte) => sum + byte, 0);
+76 -5
View File
@@ -9,6 +9,7 @@ import {
createSafeSelectionZip,
inspectArchive,
readEntryBytes,
detectArchiveFormat,
} from "../../src/archive/service";
const asFile = (
@@ -18,6 +19,26 @@ const asFile = (
) => 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"),
@@ -41,7 +62,7 @@ describe("ZIP inspection and safe workflows", () => {
).resolves.toMatchObject({ kind: "text", text: "hello" });
});
it("blocks traversal, case-colliding and encrypted entries at inspection", async () => {
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"), {
@@ -67,12 +88,62 @@ describe("ZIP inspection and safe workflows", () => {
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([]);
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")],