From f5d913eb18b648206e0dad88e1a8f7f9dd3a3943 Mon Sep 17 00:00:00 2001 From: xCyanGrizzly Date: Mon, 27 Jul 2026 11:58:33 +0200 Subject: [PATCH] feat(worker): RAR ranged header-walk + single-part listing Co-Authored-By: Claude Opus 4.8 (1M context) --- worker/src/archive/ranged/rar-ranged.test.ts | 46 ++++++++++++++- worker/src/archive/ranged/rar-ranged.ts | 62 ++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/worker/src/archive/ranged/rar-ranged.test.ts b/worker/src/archive/ranged/rar-ranged.test.ts index 2842dc6..75feeca 100644 --- a/worker/src/archive/ranged/rar-ranged.test.ts +++ b/worker/src/archive/ranged/rar-ranged.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; -import { readVint, detectRarSignature, parseRar5BlockExtent, parseRar4BlockExtent } from "./rar-ranged.js"; +import { readVint, detectRarSignature, parseRar5BlockExtent, parseRar4BlockExtent, walkRarVolume, readRarListingRanged } from "./rar-ranged.js"; +import type { RangeReader } from "./range-reader.js"; describe("readVint", () => { it("reads single-byte and multi-byte values (base-128 LE)", () => { @@ -54,3 +55,46 @@ describe("parseRar4BlockExtent", () => { expect(ext.isEnd).toBe(false); }); }); + +// Build a synthetic RAR5 volume: signature + main header + 2 file blocks (each +// with data) + end block. We only need extents to be walkable. +function buildRar5Volume(): Buffer { + const sig = Buffer.from([0x52,0x61,0x72,0x21,0x1a,0x07,0x01,0x00]); + const block = (type: number, flags: number, dataSize: number, pad = 0) => { + const body = [Buffer.from([type]), Buffer.from([flags])]; + if (flags & 0x0002) body.push(Buffer.from([dataSize])); // DataSize (<=127 for test) + if (pad) body.push(Buffer.alloc(pad)); + const bodyBuf = Buffer.concat(body); + const hs = Buffer.from([bodyBuf.length]); // HeaderSize vint (<=127) + const header = Buffer.concat([Buffer.alloc(4), hs, bodyBuf]); // CRC(4)+HeaderSize+body + const data = Buffer.alloc(flags & 0x0002 ? dataSize : 0, 0xEE); + return Buffer.concat([header, data]); + }; + const main = block(1, 0, 0); // main archive header, no data + const f1 = block(2, 0x02, 20); // file header + 20 bytes data + const f2 = block(2, 0x02, 30); // file header + 30 bytes data + const end = block(5, 0, 0); // end of archive + return Buffer.concat([sig, main, f1, f2, end]); +} + +describe("walkRarVolume", () => { + it("harvests every block header and stops at end-of-archive", async () => { + const vol = buildRar5Volume(); + const read: RangeReader = async (_id, offset, length) => vol.subarray(offset, offset + length); + const regions = await walkRarVolume(read, { fileId: "1", fileSize: BigInt(vol.length), fileName: "a.rar" }, 5, 8); + expect(regions).not.toBeNull(); + // main + 2 files + end = 4 header regions + expect(regions!).toHaveLength(4); + // First region starts right after the 8-byte signature + expect(regions![0].offset).toBe(8); + }); +}); + +describe("readRarListingRanged (single part)", () => { + it("returns null cleanly when the reconstructed file isn't a real RAR", async () => { + const vol = buildRar5Volume(); + const read: RangeReader = async (_id, offset, length) => vol.subarray(offset, offset + length); + const res = await readRarListingRanged([{ fileId: "1", fileSize: BigInt(vol.length), fileName: "a.rar" }], read); + expect(res === null || Array.isArray(res)).toBe(true); // real unrar parse covered live + }); +}); diff --git a/worker/src/archive/ranged/rar-ranged.ts b/worker/src/archive/ranged/rar-ranged.ts index c4581d5..683d3f4 100644 --- a/worker/src/archive/ranged/rar-ranged.ts +++ b/worker/src/archive/ranged/rar-ranged.ts @@ -44,3 +44,65 @@ export function parseRar4BlockExtent(buf: Buffer, pos: number): BlockExtent { const dataSize = (flags & 0x8000) ? buf.readUInt32LE(pos + 7) : 0; return { headerBytes: headSize, dataSize, isEnd: type === 0x7b }; } + +import type { FileEntry } from "../zip-reader.js"; +import type { RangeReader } from "./range-reader.js"; +import type { RangedPart } from "./sevenz-ranged.js"; +import { listFromSparse, type SparsePart } from "./sparse-list.js"; +import { readRarContents } from "../rar-reader.js"; +import { childLogger } from "../../util/logger.js"; + +const rlog = childLogger("rar-ranged"); +const MAX_RAR_BLOCKS = 50000; +const HEADER_CHUNK = 8192; + +export async function walkRarVolume( + read: RangeReader, + part: RangedPart, + version: 4 | 5, + sigLen: number, +): Promise<{ offset: number; bytes: Buffer }[] | null> { + const size = Number(part.fileSize); + const regions: { offset: number; bytes: Buffer }[] = []; + let pos = sigLen; + let blocks = 0; + try { + while (pos < size) { + if (++blocks > MAX_RAR_BLOCKS) return null; + const chunkLen = Math.min(HEADER_CHUNK, size - pos); + let chunk = await read(part.fileId, pos, chunkLen, part.fileSize); + const ext = version === 5 ? parseRar5BlockExtent(chunk, 0) : parseRar4BlockExtent(chunk, 0); + // Ensure we have the full header bytes to harvest (long filenames). + let headerBuf = chunk; + if (ext.headerBytes > chunk.length) { + headerBuf = await read(part.fileId, pos, Math.min(ext.headerBytes, size - pos), part.fileSize); + } + regions.push({ offset: pos, bytes: headerBuf.subarray(0, Math.min(ext.headerBytes, size - pos)) }); + if (ext.isEnd) break; + const advance = ext.headerBytes + ext.dataSize; + if (advance <= 0) return null; + if (pos + advance > size) break; // data clamped at the volume boundary (multipart continuation) + pos += advance; + } + return regions; + } catch (err) { + rlog.warn({ err, fileId: part.fileId }, "RAR volume walk failed"); + return null; + } +} + +export async function readRarListingRanged( + parts: RangedPart[], + read: RangeReader, +): Promise { + const sparseParts: SparsePart[] = []; + for (const part of parts) { + const head = await read(part.fileId, 0, 16, part.fileSize); + const sig = detectRarSignature(head); + if (!sig) return null; + const regions = await walkRarVolume(read, part, sig.version, sig.sigLen); + if (!regions) return null; + sparseParts.push({ fileName: part.fileName, size: Number(part.fileSize), regions }); + } + return listFromSparse(sparseParts, readRarContents); +}