Files
archive-tools/tests/archive/structural-formats.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

278 lines
8.3 KiB
TypeScript

// @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;
}