mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-09-21 05:21:43 +00:00
Live spike showed start+end sparse reconstruction is insufficient for encoded-header 7z; fetch the mid-file packed header region as a 3rd region (parse PackInfo). Adds Task 4b. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1074 lines
44 KiB
Markdown
1074 lines
44 KiB
Markdown
# Ranged inner-file listing (RAR & 7z) Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Index the inner files of RAR & 7z placeholder packages via small ranged reads (no full download), so the reindex/provenance-backfill path fills `fileCount`/`package_files` for all archive types.
|
||
|
||
**Architecture:** For each archive, do the minimum binary parsing to locate its header bytes, fetch only those via `downloadFileRange`, write them into a sparse temp file at their true offsets (data regions left as zero holes), then run the existing `7z l` / `unrar lt` (via `read7zContents` / `readRarContents`) and reuse their parsers. A ranged reader returning `null` triggers a size-capped full-download fallback.
|
||
|
||
**Tech Stack:** TypeScript (strict, ESM, `.js` import specifiers), TDLib via `tdl`, vitest, `unrar`/`7z` CLIs (already installed in the worker image), Prisma/Postgres.
|
||
|
||
## Global Constraints
|
||
|
||
- TypeScript strict; ESM import specifiers end in `.js`. Copy the surrounding files' style.
|
||
- ESLint does NOT cover `worker/` — but keep types clean; no `any` unless mirroring existing patterns.
|
||
- Tests: vitest, files match `src/**/*.test.ts`; run from `worker/` with `npx vitest run`.
|
||
- All TDLib calls must go through FLOOD_WAIT-safe wrappers. `downloadFileRange` already wraps `client.invoke` in `withFloodWait` — do not add a second wrapper. Keep ranged reads **sequential** (never `Promise.all` a walk).
|
||
- Full-download fallback is gated by `config.maxZipSizeMB` (env `WORKER_MAX_ZIP_SIZE_MB`, default 204800). Over the cap → do NOT download; write a `SystemNotification` and return `null`.
|
||
- No new DB migration. Reuse the existing `zipsBackfilled` counter.
|
||
- Shared return type is `FileEntry` from `worker/src/archive/zip-reader.ts`:
|
||
`{ path: string; fileName: string; extension: string | null; compressedSize: bigint; uncompressedSize: bigint; crc32: string | null }`.
|
||
- Deploy is local (no GitHub push): build `worker/Dockerfile` locally, recreate the `dragonsstash-worker` container from the local image WITHOUT `pull` (see Task 4 / Task 8 deploy steps).
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
- Create `worker/src/archive/ranged/sparse-list.ts` — sparse temp-file reconstruction + CLI lister. (+ `sparse-list.test.ts`)
|
||
- Create `worker/src/archive/ranged/sevenz-ranged.ts` — 7z signature parse + ranged listing. (+ `sevenz-ranged.test.ts`)
|
||
- Create `worker/src/archive/ranged/rar-ranged.ts` — RAR signature/vint/block-extent parse + ranged walk. (+ `rar-ranged.test.ts`)
|
||
- Create `worker/src/archive/ranged/range-reader.ts` — `RangeReader` type + default TDLib impl.
|
||
- Create `worker/src/archive/ranged/fallback.ts` — size-capped full-download fallback + notification.
|
||
- Modify `worker/src/provenance-backfill.ts` — dispatch scanned/destination listing by archive type; call fallback.
|
||
- Modify `worker/src/worker.ts` — include `fileName` in the `scannedParts` passed to `tryProvenanceBackfill`.
|
||
|
||
---
|
||
|
||
## Task 1: Sparse reconstruction helper (`sparse-list.ts`)
|
||
|
||
**Files:**
|
||
- Create: `worker/src/archive/ranged/sparse-list.ts`
|
||
- Test: `worker/src/archive/ranged/sparse-list.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `FileEntry` from `../zip-reader.js`; `config.tempDir` from `../../util/config.js`.
|
||
- Produces:
|
||
- `interface SparsePart { fileName: string; size: number; regions: { offset: number; bytes: Buffer }[] }`
|
||
- `type SparseLister = (firstPartPath: string) => Promise<FileEntry[]>`
|
||
- `async function listFromSparse(parts: SparsePart[], lister: SparseLister): Promise<FileEntry[] | null>`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```typescript
|
||
// worker/src/archive/ranged/sparse-list.test.ts
|
||
import { describe, it, expect } from "vitest";
|
||
import { open } from "fs/promises";
|
||
import { listFromSparse } from "./sparse-list.js";
|
||
|
||
describe("listFromSparse", () => {
|
||
it("writes each region at its offset into a sparse file and passes the path to the lister", async () => {
|
||
const size = 1_000_000;
|
||
const regions = [
|
||
{ offset: 0, bytes: Buffer.from("HEAD") },
|
||
{ offset: size - 4, bytes: Buffer.from("TAIL") },
|
||
];
|
||
let seenPath = "";
|
||
const entries = await listFromSparse(
|
||
[{ fileName: "sample.7z", size, regions }],
|
||
async (firstPartPath) => {
|
||
seenPath = firstPartPath;
|
||
const fh = await open(firstPartPath, "r");
|
||
try {
|
||
const head = Buffer.alloc(4); await fh.read(head, 0, 4, 0);
|
||
const tail = Buffer.alloc(4); await fh.read(tail, 0, 4, size - 4);
|
||
const hole = Buffer.alloc(4); await fh.read(hole, 0, 4, 500_000);
|
||
expect(head.toString()).toBe("HEAD");
|
||
expect(tail.toString()).toBe("TAIL");
|
||
expect(hole.equals(Buffer.alloc(4))).toBe(true); // gap is zero
|
||
} finally { await fh.close(); }
|
||
return [{ path: "a/b.stl", fileName: "b.stl", extension: "stl", compressedSize: 1n, uncompressedSize: 1n, crc32: null }];
|
||
},
|
||
);
|
||
expect(seenPath.endsWith("sample.7z")).toBe(true);
|
||
expect(entries).not.toBeNull();
|
||
expect(entries!).toHaveLength(1);
|
||
});
|
||
|
||
it("returns null when the lister yields no entries", async () => {
|
||
const res = await listFromSparse(
|
||
[{ fileName: "x.7z", size: 100, regions: [{ offset: 0, bytes: Buffer.from("A") }] }],
|
||
async () => [],
|
||
);
|
||
expect(res).toBeNull();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd worker && npx vitest run src/archive/ranged/sparse-list.test.ts`
|
||
Expected: FAIL — `Cannot find module './sparse-list.js'`.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
```typescript
|
||
// worker/src/archive/ranged/sparse-list.ts
|
||
import { mkdtemp, open, rm } from "fs/promises";
|
||
import path from "path";
|
||
import { config } from "../../util/config.js";
|
||
import { childLogger } from "../../util/logger.js";
|
||
import type { FileEntry } from "../zip-reader.js";
|
||
|
||
const log = childLogger("sparse-list");
|
||
|
||
export interface SparsePart {
|
||
fileName: string;
|
||
size: number;
|
||
regions: { offset: number; bytes: Buffer }[];
|
||
}
|
||
|
||
export type SparseLister = (firstPartPath: string) => Promise<FileEntry[]>;
|
||
|
||
/**
|
||
* Reconstruct archive header bytes into sparse temp files (data areas left as
|
||
* zero holes), run `lister` on the first part, return its entries.
|
||
* Returns null on any error or when the lister finds nothing.
|
||
*/
|
||
export async function listFromSparse(
|
||
parts: SparsePart[],
|
||
lister: SparseLister,
|
||
): Promise<FileEntry[] | null> {
|
||
if (parts.length === 0) return null;
|
||
const dir = await mkdtemp(path.join(config.tempDir, "ranged-"));
|
||
try {
|
||
let firstPath = "";
|
||
for (let i = 0; i < parts.length; i++) {
|
||
const p = parts[i];
|
||
const filePath = path.join(dir, p.fileName);
|
||
if (i === 0) firstPath = filePath;
|
||
const fh = await open(filePath, "w");
|
||
try {
|
||
await fh.truncate(p.size); // create the sparse hole
|
||
for (const r of p.regions) {
|
||
await fh.write(r.bytes, 0, r.bytes.length, r.offset);
|
||
}
|
||
} finally {
|
||
await fh.close();
|
||
}
|
||
}
|
||
const entries = await lister(firstPath);
|
||
return entries.length > 0 ? entries : null;
|
||
} catch (err) {
|
||
log.warn({ err }, "sparse listing failed");
|
||
return null;
|
||
} finally {
|
||
await rm(dir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd worker && npx vitest run src/archive/ranged/sparse-list.test.ts`
|
||
Expected: PASS (2 passed).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add worker/src/archive/ranged/sparse-list.ts worker/src/archive/ranged/sparse-list.test.ts
|
||
git commit -m "feat(worker): sparse-file reconstruction helper for ranged archive listing"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: 7z signature-header parser (`sevenz-ranged.ts`, pure part)
|
||
|
||
**Files:**
|
||
- Create: `worker/src/archive/ranged/sevenz-ranged.ts`
|
||
- Test: `worker/src/archive/ranged/sevenz-ranged.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `function parseSevenZSignatureHeader(buf: Buffer): { nextHeaderOffset: number; nextHeaderSize: number } | null`
|
||
- Returns null unless `buf` starts with the 6-byte 7z magic and is ≥ 32 bytes.
|
||
- `nextHeaderOffset` is relative to the end of the 32-byte signature header (absolute end-header start = `32 + nextHeaderOffset`).
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```typescript
|
||
// worker/src/archive/ranged/sevenz-ranged.test.ts
|
||
import { describe, it, expect } from "vitest";
|
||
import { parseSevenZSignatureHeader } from "./sevenz-ranged.js";
|
||
|
||
const MAGIC = Buffer.from([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c]);
|
||
|
||
function buildSignatureHeader(nextOffset: bigint, nextSize: bigint): Buffer {
|
||
const buf = Buffer.alloc(32);
|
||
MAGIC.copy(buf, 0);
|
||
buf.writeUInt8(0, 6); buf.writeUInt8(4, 7); // version 0.4
|
||
buf.writeUInt32LE(0, 8); // StartHeaderCRC (unused here)
|
||
buf.writeBigUInt64LE(nextOffset, 12);
|
||
buf.writeBigUInt64LE(nextSize, 20);
|
||
buf.writeUInt32LE(0, 28); // NextHeaderCRC (unused here)
|
||
return buf;
|
||
}
|
||
|
||
describe("parseSevenZSignatureHeader", () => {
|
||
it("reads NextHeaderOffset and NextHeaderSize", () => {
|
||
const buf = buildSignatureHeader(1_000_000n, 4096n);
|
||
expect(parseSevenZSignatureHeader(buf)).toEqual({ nextHeaderOffset: 1_000_000, nextHeaderSize: 4096 });
|
||
});
|
||
|
||
it("returns null on bad magic", () => {
|
||
expect(parseSevenZSignatureHeader(Buffer.alloc(32))).toBeNull();
|
||
});
|
||
|
||
it("returns null when shorter than 32 bytes", () => {
|
||
expect(parseSevenZSignatureHeader(MAGIC)).toBeNull();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd worker && npx vitest run src/archive/ranged/sevenz-ranged.test.ts`
|
||
Expected: FAIL — `Cannot find module './sevenz-ranged.js'`.
|
||
|
||
- [ ] **Step 3: Write minimal implementation** (signature parser only — orchestrator added in Task 3)
|
||
|
||
```typescript
|
||
// worker/src/archive/ranged/sevenz-ranged.ts
|
||
const SEVENZ_MAGIC = Buffer.from([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c]);
|
||
|
||
export function parseSevenZSignatureHeader(
|
||
buf: Buffer,
|
||
): { nextHeaderOffset: number; nextHeaderSize: number } | null {
|
||
if (buf.length < 32) return null;
|
||
if (!buf.subarray(0, 6).equals(SEVENZ_MAGIC)) return null;
|
||
return {
|
||
nextHeaderOffset: Number(buf.readBigUInt64LE(12)),
|
||
nextHeaderSize: Number(buf.readBigUInt64LE(20)),
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd worker && npx vitest run src/archive/ranged/sevenz-ranged.test.ts`
|
||
Expected: PASS (3 passed).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add worker/src/archive/ranged/sevenz-ranged.ts worker/src/archive/ranged/sevenz-ranged.test.ts
|
||
git commit -m "feat(worker): 7z signature-header parser"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: `RangeReader` + 7z ranged listing orchestrator
|
||
|
||
**Files:**
|
||
- Create: `worker/src/archive/ranged/range-reader.ts`
|
||
- Modify: `worker/src/archive/ranged/sevenz-ranged.ts` (add orchestrator)
|
||
- Test: `worker/src/archive/ranged/sevenz-ranged.test.ts` (add case)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `parseSevenZSignatureHeader` (Task 2); `listFromSparse`, `SparsePart` (Task 1); `read7zContents` from `../sevenz-reader.js`; `downloadFileRange` from `../../tdlib/range-download.js`.
|
||
- Produces:
|
||
- `worker/src/archive/ranged/range-reader.ts`:
|
||
- `type RangeReader = (fileId: string, offset: number, length: number, partSize: bigint) => Promise<Buffer>`
|
||
- `function tdlibRangeReader(client: import("tdl").Client): RangeReader`
|
||
- `sevenz-ranged.ts`:
|
||
- `interface RangedPart { fileId: string; fileSize: bigint; fileName: string }`
|
||
- `async function readSevenZListingRanged(parts: RangedPart[], read: RangeReader): Promise<FileEntry[] | null>`
|
||
(7z placeholders are single-part; uses `parts[0]`.)
|
||
|
||
- [ ] **Step 1: Write the failing test** (append to `sevenz-ranged.test.ts`)
|
||
|
||
```typescript
|
||
import { readSevenZListingRanged } from "./sevenz-ranged.js";
|
||
import type { RangeReader } from "./range-reader.js";
|
||
|
||
describe("readSevenZListingRanged", () => {
|
||
it("reads the signature + end-header regions and reconstructs for 7z l", async () => {
|
||
const size = 5_000_000;
|
||
const endHeaderOffset = 4_900_000; // absolute
|
||
const nextHeaderOffset = endHeaderOffset - 32;
|
||
const sig = Buffer.alloc(32);
|
||
Buffer.from([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c]).copy(sig, 0);
|
||
sig.writeBigUInt64LE(BigInt(nextHeaderOffset), 12);
|
||
sig.writeBigUInt64LE(100n, 20);
|
||
|
||
const reads: { offset: number; length: number }[] = [];
|
||
const read: RangeReader = async (_id, offset, length) => {
|
||
reads.push({ offset, length });
|
||
if (offset === 0) return sig.subarray(0, length);
|
||
return Buffer.alloc(length, 0xAB); // stand-in end-header bytes
|
||
};
|
||
|
||
// Inject a fake lister via the module boundary: readSevenZListingRanged
|
||
// calls listFromSparse(parts, read7zContents). We assert the ranged reads
|
||
// it issued; the sparse file + real 7z is covered by live verification.
|
||
const entries = await readSevenZListingRanged(
|
||
[{ fileId: "1", fileSize: BigInt(size), fileName: "a.7z" }],
|
||
read,
|
||
);
|
||
// entries may be null here because the stand-in bytes aren't a real 7z;
|
||
// the contract under test is the ranged-read offsets:
|
||
expect(reads[0]).toEqual({ offset: 0, length: 32 });
|
||
expect(reads[1]).toEqual({ offset: endHeaderOffset, length: 100 });
|
||
expect(entries === null || Array.isArray(entries)).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd worker && npx vitest run src/archive/ranged/sevenz-ranged.test.ts`
|
||
Expected: FAIL — `readSevenZListingRanged`/`range-reader.js` not found.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
```typescript
|
||
// worker/src/archive/ranged/range-reader.ts
|
||
import type { Client } from "tdl";
|
||
import { downloadFileRange } from "../../tdlib/range-download.js";
|
||
|
||
export type RangeReader = (
|
||
fileId: string,
|
||
offset: number,
|
||
length: number,
|
||
partSize: bigint,
|
||
) => Promise<Buffer>;
|
||
|
||
export function tdlibRangeReader(client: Client): RangeReader {
|
||
return (fileId, offset, length, partSize) =>
|
||
downloadFileRange(client, fileId, offset, length, partSize);
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// append to worker/src/archive/ranged/sevenz-ranged.ts
|
||
import type { FileEntry } from "../zip-reader.js";
|
||
import { read7zContents } from "../sevenz-reader.js";
|
||
import { listFromSparse } from "./sparse-list.js";
|
||
import type { RangeReader } from "./range-reader.js";
|
||
import { childLogger } from "../../util/logger.js";
|
||
|
||
const log = childLogger("sevenz-ranged");
|
||
|
||
export interface RangedPart { fileId: string; fileSize: bigint; fileName: string }
|
||
|
||
export async function readSevenZListingRanged(
|
||
parts: RangedPart[],
|
||
read: RangeReader,
|
||
): Promise<FileEntry[] | null> {
|
||
const part = parts[0];
|
||
if (!part) return null;
|
||
const size = Number(part.fileSize);
|
||
try {
|
||
const sig = await read(part.fileId, 0, 32, part.fileSize);
|
||
const parsed = parseSevenZSignatureHeader(sig);
|
||
if (!parsed) return null;
|
||
const endStart = 32 + parsed.nextHeaderOffset;
|
||
if (endStart < 0 || endStart + parsed.nextHeaderSize > size) return null;
|
||
const endHeader = await read(part.fileId, endStart, parsed.nextHeaderSize, part.fileSize);
|
||
return listFromSparse(
|
||
[{
|
||
fileName: part.fileName,
|
||
size,
|
||
regions: [
|
||
{ offset: 0, bytes: sig },
|
||
{ offset: endStart, bytes: endHeader },
|
||
],
|
||
}],
|
||
read7zContents,
|
||
);
|
||
} catch (err) {
|
||
log.warn({ err, fileId: part.fileId }, "ranged 7z listing failed");
|
||
return null;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd worker && npx vitest run src/archive/ranged/sevenz-ranged.test.ts`
|
||
Expected: PASS (all cases).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add worker/src/archive/ranged/range-reader.ts worker/src/archive/ranged/sevenz-ranged.ts worker/src/archive/ranged/sevenz-ranged.test.ts
|
||
git commit -m "feat(worker): ranged 7z listing orchestrator + RangeReader"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Wire 7z into provenance-backfill + fallback stub; deploy & live-verify (the productive spike)
|
||
|
||
**Files:**
|
||
- Create: `worker/src/archive/ranged/fallback.ts`
|
||
- Modify: `worker/src/provenance-backfill.ts` (dispatch scanned/dest listing; call fallback)
|
||
- Modify: `worker/src/worker.ts` (add `fileName` to `scannedParts`)
|
||
- Test: `worker/src/archive/ranged/fallback.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `readSevenZListingRanged`, `RangedPart` (Task 3); `tdlibRangeReader` (Task 3); `read7zContents`/`readRarContents`; `downloadFile` from `../../tdlib/download.js`; `config.maxZipSizeMB`, `config.tempDir`; `db` from `../../db/client.js`.
|
||
- Produces:
|
||
- `fallback.ts`: `async function fullDownloadListing(args: { client: import("tdl").Client; parts: RangedPart[]; archiveType: string; totalSize: bigint; fileName: string; }): Promise<FileEntry[] | null>` — downloads all parts if `totalSize <= maxZipSizeMB`, runs the CLI reader, returns entries; over cap → writes a `SystemNotification` and returns null.
|
||
- In `provenance-backfill.ts`: `async function readScannedListingRanged(archiveType: string, client, parts: RangedPart[]): Promise<FileEntry[] | null>` used in place of the ZIP-only branch. (RAR routes to Task 6/7's function — see Task 8; for now RAR returns null → fallback.)
|
||
|
||
- [ ] **Step 1: Write the failing test** (fallback size-cap behavior — pure, no real download)
|
||
|
||
```typescript
|
||
// worker/src/archive/ranged/fallback.test.ts
|
||
import { describe, it, expect, vi } from "vitest";
|
||
|
||
vi.mock("../../util/config.js", () => ({ config: { maxZipSizeMB: 1, tempDir: "/tmp" } }));
|
||
const created: unknown[] = [];
|
||
vi.mock("../../db/client.js", () => ({
|
||
db: { systemNotification: { create: async (a: unknown) => { created.push(a); } } },
|
||
}));
|
||
|
||
import { fullDownloadListing } from "./fallback.js";
|
||
|
||
describe("fullDownloadListing", () => {
|
||
it("refuses to download over the size cap and records a notification", async () => {
|
||
const res = await fullDownloadListing({
|
||
client: {} as never,
|
||
parts: [{ fileId: "1", fileSize: 2n * 1024n * 1024n * 1024n, fileName: "big.rar" }],
|
||
archiveType: "RAR",
|
||
totalSize: 2n * 1024n * 1024n * 1024n,
|
||
fileName: "big.rar",
|
||
});
|
||
expect(res).toBeNull();
|
||
expect(created).toHaveLength(1);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd worker && npx vitest run src/archive/ranged/fallback.test.ts`
|
||
Expected: FAIL — `Cannot find module './fallback.js'`.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
```typescript
|
||
// worker/src/archive/ranged/fallback.ts
|
||
import { mkdtemp, rm } from "fs/promises";
|
||
import path from "path";
|
||
import type { Client } from "tdl";
|
||
import { config } from "../../util/config.js";
|
||
import { db } from "../../db/client.js";
|
||
import { childLogger } from "../../util/logger.js";
|
||
import { downloadFile } from "../../tdlib/download.js";
|
||
import { read7zContents } from "../sevenz-reader.js";
|
||
import { readRarContents } from "../rar-reader.js";
|
||
import type { FileEntry } from "../zip-reader.js";
|
||
import type { RangedPart } from "./sevenz-ranged.js";
|
||
|
||
const log = childLogger("ranged-fallback");
|
||
|
||
export async function fullDownloadListing(args: {
|
||
client: Client;
|
||
parts: RangedPart[];
|
||
archiveType: string;
|
||
totalSize: bigint;
|
||
fileName: string;
|
||
}): Promise<FileEntry[] | null> {
|
||
const capBytes = BigInt(config.maxZipSizeMB) * 1024n * 1024n;
|
||
if (args.totalSize > capBytes) {
|
||
await db.systemNotification.create({
|
||
data: {
|
||
type: "INTEGRITY_AUDIT",
|
||
severity: "WARNING",
|
||
title: `Listing skipped (over size cap): ${args.fileName}`,
|
||
message: `Ranged listing failed and the archive (${args.totalSize} bytes) exceeds WORKER_MAX_ZIP_SIZE_MB; not downloaded. Inner files left unindexed.`,
|
||
context: { fileName: args.fileName, archiveType: args.archiveType },
|
||
},
|
||
});
|
||
log.warn({ fileName: args.fileName }, "fallback skipped — over size cap");
|
||
return null;
|
||
}
|
||
const dir = await mkdtemp(path.join(config.tempDir, "fallback-"));
|
||
const paths: string[] = [];
|
||
try {
|
||
for (const p of args.parts) {
|
||
const dest = path.join(dir, p.fileName);
|
||
await downloadFile(args.client, p.fileId, dest, p.fileSize, p.fileName, () => {});
|
||
paths.push(dest);
|
||
}
|
||
const entries =
|
||
args.archiveType === "SEVEN_Z" ? await read7zContents(paths[0])
|
||
: args.archiveType === "RAR" ? await readRarContents(paths[0])
|
||
: [];
|
||
return entries.length > 0 ? entries : null;
|
||
} catch (err) {
|
||
log.warn({ err, fileName: args.fileName }, "full-download fallback failed");
|
||
return null;
|
||
} finally {
|
||
await rm(dir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd worker && npx vitest run src/archive/ranged/fallback.test.ts`
|
||
Expected: PASS (1 passed).
|
||
|
||
- [ ] **Step 5: Wire the dispatcher into `provenance-backfill.ts`**
|
||
|
||
Replace the ZIP-only scanned-listing branch. Find (around line 146-149):
|
||
|
||
```typescript
|
||
let scannedEntries: FileEntry[] | null = null;
|
||
if (args.archiveType === "ZIP") {
|
||
scannedEntries = await readScannedZipListing(args.client, args.scannedParts);
|
||
}
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```typescript
|
||
let scannedEntries: FileEntry[] | null = await readScannedListingRanged(
|
||
args.archiveType, args.client, args.scannedParts,
|
||
);
|
||
// Cheap ranged read failed — fall back to a size-capped full download so the
|
||
// listing still gets indexed. Only worth it when the candidate lacks a listing.
|
||
if (!scannedEntries && candidates.some((c) => c.fileCount === 0)) {
|
||
const totalSize = args.scannedParts.reduce((s, p) => s + p.fileSize, 0n);
|
||
scannedEntries = await fullDownloadListing({
|
||
client: args.client, parts: args.scannedParts, archiveType: args.archiveType,
|
||
totalSize, fileName: args.fileName,
|
||
});
|
||
}
|
||
```
|
||
|
||
Add this dispatcher function near the other read helpers in `provenance-backfill.ts`:
|
||
|
||
```typescript
|
||
async function readScannedListingRanged(
|
||
archiveType: string,
|
||
client: Client,
|
||
parts: RangedPart[],
|
||
): Promise<FileEntry[] | null> {
|
||
const read = tdlibRangeReader(client);
|
||
if (archiveType === "ZIP") return readScannedZipListing(client, parts);
|
||
if (archiveType === "SEVEN_Z") return readSevenZListingRanged(parts, read);
|
||
// RAR enabled in Task 8.
|
||
return null;
|
||
}
|
||
```
|
||
|
||
Add imports at the top of `provenance-backfill.ts`:
|
||
|
||
```typescript
|
||
import { readSevenZListingRanged, type RangedPart } from "./archive/ranged/sevenz-ranged.js";
|
||
import { tdlibRangeReader } from "./archive/ranged/range-reader.js";
|
||
import { fullDownloadListing } from "./archive/ranged/fallback.js";
|
||
```
|
||
|
||
Change `args.scannedParts` type in `BackfillArgs` from `{ fileId: string; fileSize: bigint }[]` to `RangedPart[]` (adds `fileName`).
|
||
|
||
- [ ] **Step 6: Add `fileName` to `scannedParts` in `worker.ts`**
|
||
|
||
Find (around line 1674):
|
||
|
||
```typescript
|
||
scannedParts: archiveSet.parts.map((p) => ({ fileId: p.fileId, fileSize: p.fileSize })),
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```typescript
|
||
scannedParts: archiveSet.parts.map((p) => ({ fileId: p.fileId, fileSize: p.fileSize, fileName: p.fileName })),
|
||
```
|
||
|
||
- [ ] **Step 7: Typecheck + full test run + build**
|
||
|
||
```bash
|
||
cd worker && npx tsc --noEmit && npx vitest run
|
||
```
|
||
Expected: no TS errors; all tests pass.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add worker/src/archive/ranged/fallback.ts worker/src/archive/ranged/fallback.test.ts worker/src/provenance-backfill.ts worker/src/worker.ts
|
||
git commit -m "feat(worker): dispatch 7z ranged listing + size-capped full-download fallback"
|
||
```
|
||
|
||
- [ ] **Step 9: Deploy locally (no push) and live-verify 7z**
|
||
|
||
```bash
|
||
cd /home/sam/Documents/DragonsStash
|
||
docker build -f worker/Dockerfile -t git.samagsteribbe.nl/admin/dragonsstash-worker:latest .
|
||
docker compose --project-name dragonsstash --project-directory /opt/stacks/DragonsStash \
|
||
-f /opt/stacks/DragonsStash/docker-compose.yml up -d --no-deps --force-recreate worker
|
||
```
|
||
|
||
Then verify the 7z ranged path is populating listings (not full-downloading):
|
||
|
||
```bash
|
||
# Watch for ranged 7z work + confirm no multi-GB downloads for 7z
|
||
docker logs -f --since 30s dragonsstash-worker 2>&1 | grep -iE "sevenz-ranged|sparse-list|Backfilled provenance|Downloading archive part"
|
||
```
|
||
|
||
DB check — 7z placeholders gaining a listing:
|
||
```sql
|
||
SELECT count(*) FROM packages WHERE "contentHash" LIKE 'rebuild:%' AND "archiveType"='SEVEN_Z' AND "fileCount">0;
|
||
```
|
||
Expected: climbs over successive cycles. Spot-check one against truth: pick a backfilled 7z package's `fileName`, and compare its `package_files` count to `7z l` on a real copy.
|
||
|
||
**Gate:** if 7z listings populate correctly via the ranged path with no full downloads, the two spike risks (arbitrary-offset ranged read + `7z l` on a sparse file) are proven. Proceed to RAR. If not, stop and debug before building RAR.
|
||
|
||
---
|
||
|
||
## Task 5: RAR parsers — vint, signature, block-extent (pure)
|
||
|
||
**Files:**
|
||
- Create: `worker/src/archive/ranged/rar-ranged.ts` (pure parsers only in this task)
|
||
- Test: `worker/src/archive/ranged/rar-ranged.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `function readVint(buf: Buffer, pos: number): { value: number; bytes: number }` (RAR5 base-128 LE varint)
|
||
- `function detectRarSignature(buf: Buffer): { version: 4 | 5; sigLen: number } | null`
|
||
- `interface BlockExtent { headerBytes: number; dataSize: number; isEnd: boolean }`
|
||
- `function parseRar5BlockExtent(buf: Buffer, pos: number): BlockExtent`
|
||
- `function parseRar4BlockExtent(buf: Buffer, pos: number): BlockExtent`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```typescript
|
||
// worker/src/archive/ranged/rar-ranged.test.ts
|
||
import { describe, it, expect } from "vitest";
|
||
import { readVint, detectRarSignature, parseRar5BlockExtent, parseRar4BlockExtent } from "./rar-ranged.js";
|
||
|
||
describe("readVint", () => {
|
||
it("reads single-byte and multi-byte values (base-128 LE)", () => {
|
||
expect(readVint(Buffer.from([0x08]), 0)).toEqual({ value: 8, bytes: 1 });
|
||
// 0x80,0x01 => 0 | (1<<7) = 128
|
||
expect(readVint(Buffer.from([0x80, 0x01]), 0)).toEqual({ value: 128, bytes: 2 });
|
||
});
|
||
});
|
||
|
||
describe("detectRarSignature", () => {
|
||
it("detects RAR5 and RAR4", () => {
|
||
expect(detectRarSignature(Buffer.from([0x52,0x61,0x72,0x21,0x1a,0x07,0x01,0x00]))).toEqual({ version: 5, sigLen: 8 });
|
||
expect(detectRarSignature(Buffer.from([0x52,0x61,0x72,0x21,0x1a,0x07,0x00]))).toEqual({ version: 4, sigLen: 7 });
|
||
expect(detectRarSignature(Buffer.alloc(8))).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("parseRar5BlockExtent", () => {
|
||
it("computes header+data extent and flags end-of-archive", () => {
|
||
// CRC32(4) | HeaderSize vint=5 | Type vint=2 (file) | Flags vint=2 (data present) | DataSize vint=100 | (pad to headerSize)
|
||
const b = Buffer.concat([
|
||
Buffer.from([0,0,0,0]), // CRC
|
||
Buffer.from([0x05]), // HeaderSize = 5 (bytes after this vint)
|
||
Buffer.from([0x02]), // Type = 2 (file)
|
||
Buffer.from([0x02]), // Flags = 0x02 -> data present
|
||
Buffer.from([0x64]), // DataSize = 100
|
||
Buffer.from([0x00, 0x00]), // padding to fill HeaderSize(5): Type+Flags+DataSize=3, +2 pad =5
|
||
]);
|
||
const ext = parseRar5BlockExtent(b, 0);
|
||
// headerBytes = 4 (CRC) + 1 (HeaderSize vint) + 5 (HeaderSize) = 10
|
||
expect(ext.headerBytes).toBe(10);
|
||
expect(ext.dataSize).toBe(100);
|
||
expect(ext.isEnd).toBe(false);
|
||
|
||
const endBlk = Buffer.from([0,0,0,0, 0x02, 0x05, 0x00]); // HeaderSize=2, Type=5(end), Flags=0
|
||
const e2 = parseRar5BlockExtent(endBlk, 0);
|
||
expect(e2.isEnd).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("parseRar4BlockExtent", () => {
|
||
it("computes extent with ADD_SIZE when flag 0x8000 is set", () => {
|
||
// CRC(2) TYPE(1)=0x74 FLAGS(2)=0x8000 HEAD_SIZE(2)=11 ADD_SIZE(4)=200
|
||
const b = Buffer.alloc(11);
|
||
b.writeUInt8(0x74, 2);
|
||
b.writeUInt16LE(0x8000, 3);
|
||
b.writeUInt16LE(11, 5);
|
||
b.writeUInt32LE(200, 7);
|
||
const ext = parseRar4BlockExtent(b, 0);
|
||
expect(ext.headerBytes).toBe(11);
|
||
expect(ext.dataSize).toBe(200);
|
||
expect(ext.isEnd).toBe(false);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd worker && npx vitest run src/archive/ranged/rar-ranged.test.ts`
|
||
Expected: FAIL — module/exports not found.
|
||
|
||
- [ ] **Step 3: Write minimal implementation** (parsers only)
|
||
|
||
```typescript
|
||
// worker/src/archive/ranged/rar-ranged.ts
|
||
const RAR4_SIG = Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00]);
|
||
const RAR5_SIG = Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00]);
|
||
|
||
export function readVint(buf: Buffer, pos: number): { value: number; bytes: number } {
|
||
let value = 0, shift = 0, bytes = 0;
|
||
while (pos + bytes < buf.length) {
|
||
const b = buf[pos + bytes];
|
||
value += (b & 0x7f) * Math.pow(2, shift); // Math.pow keeps >32-bit sizes exact up to 2^53
|
||
bytes++;
|
||
if ((b & 0x80) === 0) return { value, bytes };
|
||
shift += 7;
|
||
if (shift > 63) break;
|
||
}
|
||
throw new RangeError("incomplete RAR vint");
|
||
}
|
||
|
||
export function detectRarSignature(buf: Buffer): { version: 4 | 5; sigLen: number } | null {
|
||
if (buf.length >= 8 && buf.subarray(0, 8).equals(RAR5_SIG)) return { version: 5, sigLen: 8 };
|
||
if (buf.length >= 7 && buf.subarray(0, 7).equals(RAR4_SIG)) return { version: 4, sigLen: 7 };
|
||
return null;
|
||
}
|
||
|
||
export interface BlockExtent { headerBytes: number; dataSize: number; isEnd: boolean }
|
||
|
||
// RAR5: CRC32(4) | HeaderSize(vint) | HeaderType(vint) | HeaderFlags(vint)
|
||
// [ExtraAreaSize(vint) if flags&0x0001] [DataSize(vint) if flags&0x0002] ...
|
||
export function parseRar5BlockExtent(buf: Buffer, pos: number): BlockExtent {
|
||
let p = pos + 4; // skip CRC32
|
||
const hs = readVint(buf, p); p += hs.bytes;
|
||
const headerBytes = 4 + hs.bytes + hs.value; // CRC + HeaderSize-vint + HeaderSize
|
||
const type = readVint(buf, p); p += type.bytes;
|
||
const flags = readVint(buf, p); p += flags.bytes;
|
||
if (flags.value & 0x0001) { const ea = readVint(buf, p); p += ea.bytes; } // extra area size (skip)
|
||
let dataSize = 0;
|
||
if (flags.value & 0x0002) { const ds = readVint(buf, p); p += ds.bytes; dataSize = ds.value; }
|
||
return { headerBytes, dataSize, isEnd: type.value === 5 };
|
||
}
|
||
|
||
// RAR4: HEAD_CRC(2) | HEAD_TYPE(1) | HEAD_FLAGS(2) | HEAD_SIZE(2) [ADD_SIZE(4) if flags&0x8000]
|
||
export function parseRar4BlockExtent(buf: Buffer, pos: number): BlockExtent {
|
||
const type = buf.readUInt8(pos + 2);
|
||
const flags = buf.readUInt16LE(pos + 3);
|
||
const headSize = buf.readUInt16LE(pos + 5);
|
||
const dataSize = (flags & 0x8000) ? buf.readUInt32LE(pos + 7) : 0;
|
||
return { headerBytes: headSize, dataSize, isEnd: type === 0x7b };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd worker && npx vitest run src/archive/ranged/rar-ranged.test.ts`
|
||
Expected: PASS (all).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add worker/src/archive/ranged/rar-ranged.ts worker/src/archive/ranged/rar-ranged.test.ts
|
||
git commit -m "feat(worker): RAR vint/signature/block-extent parsers"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: RAR ranged walk — single volume
|
||
|
||
**Files:**
|
||
- Modify: `worker/src/archive/ranged/rar-ranged.ts` (add walk + orchestrator)
|
||
- Test: `worker/src/archive/ranged/rar-ranged.test.ts` (add case)
|
||
|
||
**Interfaces:**
|
||
- Consumes: parsers (Task 5); `RangeReader`, `RangedPart` (Task 3); `listFromSparse`, `SparsePart` (Task 1); `readRarContents` from `../rar-reader.js`.
|
||
- Produces:
|
||
- `async function walkRarVolume(read: RangeReader, part: RangedPart, version: 4 | 5, sigLen: number): Promise<{ offset: number; bytes: Buffer }[] | null>` — sequential header harvest; returns null if a block is unparseable or the block count exceeds `MAX_RAR_BLOCKS = 50000`.
|
||
- `async function readRarListingRanged(parts: RangedPart[], read: RangeReader): Promise<FileEntry[] | null>` (single-part path in this task; multipart in Task 7).
|
||
|
||
- [ ] **Step 1: Write the failing test** — synthetic RAR5 volume driven by an in-memory `RangeReader`
|
||
|
||
```typescript
|
||
import { walkRarVolume, readRarListingRanged } from "./rar-ranged.js";
|
||
import type { RangeReader } from "./range-reader.js";
|
||
|
||
// 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
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd worker && npx vitest run src/archive/ranged/rar-ranged.test.ts`
|
||
Expected: FAIL — `walkRarVolume`/`readRarListingRanged` not exported.
|
||
|
||
- [ ] **Step 3: Write minimal implementation** (append to `rar-ranged.ts`)
|
||
|
||
```typescript
|
||
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);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd worker && npx vitest run src/archive/ranged/rar-ranged.test.ts`
|
||
Expected: PASS (all).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add worker/src/archive/ranged/rar-ranged.ts worker/src/archive/ranged/rar-ranged.test.ts
|
||
git commit -m "feat(worker): RAR ranged header-walk + single-part listing"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: RAR multipart walk
|
||
|
||
**Files:**
|
||
- Test: `worker/src/archive/ranged/rar-ranged.test.ts` (add multipart case)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `readRarListingRanged` (Task 6) — already loops over `parts`, walking each volume from its own signature and co-locating sparse files under their real names for `unrar` sibling discovery. This task adds a test to lock that behavior in; no new production code unless the test reveals a gap.
|
||
|
||
- [ ] **Step 1: Write the failing/【regression】 test** — two volumes, each with its own signature
|
||
|
||
```typescript
|
||
describe("readRarListingRanged (multipart)", () => {
|
||
it("walks each volume from its own signature and reconstructs all parts", async () => {
|
||
const vol = buildRar5Volume(); // reuse from Task 6 test
|
||
// Two volumes with identical structure; each RangeReader read is scoped by fileId.
|
||
const byId: Record<string, Buffer> = { p1: vol, p2: vol };
|
||
const reads: Record<string, number> = { p1: 0, p2: 0 };
|
||
const read: RangeReader = async (fileId, offset, length) => {
|
||
reads[fileId]++;
|
||
return byId[fileId].subarray(offset, offset + length);
|
||
};
|
||
const res = await readRarListingRanged(
|
||
[
|
||
{ fileId: "p1", fileSize: BigInt(vol.length), fileName: "x.part1.rar" },
|
||
{ fileId: "p2", fileSize: BigInt(vol.length), fileName: "x.part2.rar" },
|
||
],
|
||
read,
|
||
);
|
||
// Both volumes were walked (each read at least its signature + blocks).
|
||
expect(reads.p1).toBeGreaterThan(0);
|
||
expect(reads.p2).toBeGreaterThan(0);
|
||
expect(res === null || Array.isArray(res)).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test**
|
||
|
||
Run: `cd worker && npx vitest run src/archive/ranged/rar-ranged.test.ts`
|
||
Expected: PASS — `readRarListingRanged` already loops parts. If it does NOT pass (e.g. it read only `p1`), fix `readRarListingRanged` to iterate all `parts` (it should already), then re-run.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add worker/src/archive/ranged/rar-ranged.test.ts
|
||
git commit -m "test(worker): lock in RAR multipart per-volume walk"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Enable RAR in the dispatcher; deploy & live-verify
|
||
|
||
**Files:**
|
||
- Modify: `worker/src/provenance-backfill.ts` (route RAR + destination reads through the ranged readers)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `readRarListingRanged` (Task 6), `readSevenZListingRanged` (Task 3).
|
||
|
||
- [ ] **Step 1: Route RAR in the scanned dispatcher**
|
||
|
||
In `readScannedListingRanged` (added in Task 4), replace the `// RAR enabled in Task 8.` line so RAR routes to the walker:
|
||
|
||
```typescript
|
||
if (archiveType === "RAR") return readRarListingRanged(parts, read);
|
||
```
|
||
|
||
Add import to `provenance-backfill.ts`:
|
||
|
||
```typescript
|
||
import { readRarListingRanged } from "./archive/ranged/rar-ranged.js";
|
||
```
|
||
|
||
- [ ] **Step 2: Route 7z/RAR for the destination-copy read**
|
||
|
||
Find `readZipListingFromDestination` usage inside `resolveCandidateFingerprintEntries` (around line 113) and generalize it. Change the call:
|
||
|
||
```typescript
|
||
const destEntries = await readZipListingFromDestination(
|
||
client, candidate.destChannel.telegramId, candidate.destMessageIds, candidate.destMessageId,
|
||
);
|
||
```
|
||
|
||
to resolve the doc parts once and dispatch by the candidate's archive type:
|
||
|
||
```typescript
|
||
const destParts = await resolveDestParts(
|
||
client, candidate.destChannel.telegramId, candidate.destMessageIds, candidate.destMessageId,
|
||
);
|
||
let destEntries: FileEntry[] | null = null;
|
||
if (destParts) {
|
||
const read = tdlibRangeReader(client);
|
||
destEntries =
|
||
candidate.archiveType === "ZIP" ? await readScannedZipListing(client, destParts)
|
||
: candidate.archiveType === "SEVEN_Z" ? await readSevenZListingRanged(destParts, read)
|
||
: candidate.archiveType === "RAR" ? await readRarListingRanged(destParts, read)
|
||
: null;
|
||
}
|
||
```
|
||
|
||
Refactor `readZipListingFromDestination` into `resolveDestParts` returning `RangedPart[] | null` (it already resolves each message's document `id` + `size`; also capture the document `file_name` for `fileName`, falling back to the candidate’s stored `fileName` when TDLib omits it). `PlaceholderCandidate` must expose `archiveType` and `fileName` — extend the select in `findPlaceholderCandidates` (`worker/src/db/queries.ts`) and the `PlaceholderCandidate` type to include `archiveType` and `fileName` (both already columns on `packages`).
|
||
|
||
- [ ] **Step 3: Typecheck + tests + build**
|
||
|
||
```bash
|
||
cd worker && npx tsc --noEmit && npx vitest run
|
||
```
|
||
Expected: clean.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add worker/src/provenance-backfill.ts worker/src/db/queries.ts
|
||
git commit -m "feat(worker): enable RAR ranged listing + format-aware destination reads"
|
||
```
|
||
|
||
- [ ] **Step 5: Deploy locally (no push)**
|
||
|
||
```bash
|
||
cd /home/sam/Documents/DragonsStash
|
||
docker build -f worker/Dockerfile -t git.samagsteribbe.nl/admin/dragonsstash-worker:latest .
|
||
docker compose --project-name dragonsstash --project-directory /opt/stacks/DragonsStash \
|
||
-f /opt/stacks/DragonsStash/docker-compose.yml up -d --no-deps --force-recreate worker
|
||
```
|
||
|
||
- [ ] **Step 6: Live-verify RAR (incl. multipart) and the fallback**
|
||
|
||
```bash
|
||
docker logs -f --since 30s dragonsstash-worker 2>&1 | grep -iE "rar-ranged|Backfilled provenance|full-download fallback|over size cap"
|
||
```
|
||
|
||
DB checks:
|
||
```sql
|
||
-- RAR listings populating without full downloads:
|
||
SELECT count(*) FROM packages WHERE "contentHash" LIKE 'rebuild:%' AND "archiveType"='RAR' AND "fileCount">0;
|
||
-- Fallback/flag events (should be rare):
|
||
SELECT count(*) FROM system_notifications WHERE title LIKE 'Listing skipped%';
|
||
```
|
||
Spot-check: pick one backfilled multipart RAR, compare its `package_files` count to `unrar lt` run on a real download of the same archive. Confirm the 116 GB RAR (if it surfaces) is flagged, not downloaded.
|
||
|
||
**Done when:** RAR + 7z placeholders gain accurate `fileCount`/`package_files` via the ranged path, no multi-GB downloads occur except deliberate fallbacks under the size cap, and oversized stragglers are flagged.
|
||
|
||
---
|
||
|
||
## Task 4b: 7z encoded-header support (added 2026-07-27 after the Task 4 live spike)
|
||
|
||
The Task 4 deploy revealed the two-region (start+end) 7z reconstruction is rejected by `7z l`
|
||
for archives with an **encoded/LZMA-compressed header** — the packed header stream lives mid-file,
|
||
not at EOF. Fix: parse the encoded header's `PackInfo` and fetch that packed region as a third
|
||
sparse region. Full brief with exact code + tests: `.superpowers/sdd/task-4b-brief.md`. Adds
|
||
`read7zNumber` + `locate7zEncodedHeaderPack` to `sevenz-ranged.ts` and branches
|
||
`readSevenZListingRanged` on the next-header type (`0x01` plain → 2 regions; `0x17` encoded → 3
|
||
regions; else → null/fallback). Sequenced between Task 5 and Task 6; re-verified live at Task 8.
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
- **Spec coverage:** 7z ranged read (Tasks 2-4) ✓; RAR walk incl. multipart (Tasks 5-7) ✓; sparse+CLI reconstruction (Task 1) ✓; dispatcher + downstream unchanged (Tasks 4, 8) ✓; size-capped full-download fallback + notification (Task 4) ✓; scanned + destination reads format-aware (Tasks 4, 8) ✓; spike via 7z-first-behind-fallback (Task 4 gate) ✓; unit tests + live verification (throughout) ✓; local deploy recipe (Tasks 4, 8) ✓.
|
||
- **Placeholder scan:** no TBD/TODO; every code step has full code; commands have expected output. ✓
|
||
- **Type consistency:** `FileEntry`, `RangedPart` (`{fileId,fileSize,fileName}`), `RangeReader` signature, `SparsePart`, `BlockExtent`, `listFromSparse`/`readSevenZListingRanged`/`readRarListingRanged`/`walkRarVolume`/`fullDownloadListing` names/signatures are consistent across tasks. ✓
|