@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user