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