mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-09-21 05:21:43 +00:00
feat(worker): RAR ranged header-walk + single-part listing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
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", () => {
|
describe("readVint", () => {
|
||||||
it("reads single-byte and multi-byte values (base-128 LE)", () => {
|
it("reads single-byte and multi-byte values (base-128 LE)", () => {
|
||||||
@@ -54,3 +55,46 @@ describe("parseRar4BlockExtent", () => {
|
|||||||
expect(ext.isEnd).toBe(false);
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -44,3 +44,65 @@ export function parseRar4BlockExtent(buf: Buffer, pos: number): BlockExtent {
|
|||||||
const dataSize = (flags & 0x8000) ? buf.readUInt32LE(pos + 7) : 0;
|
const dataSize = (flags & 0x8000) ? buf.readUInt32LE(pos + 7) : 0;
|
||||||
return { headerBytes: headSize, dataSize, isEnd: type === 0x7b };
|
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<FileEntry[] | null> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user