278 lines
8.3 KiB
TypeScript
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;
|
|
}
|