import fs from "node:fs"; import { spawnSync } from "node:child_process"; import { randomBytes } from "node:crypto"; import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import AdmZip from "adm-zip"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildExternalExtractArgs, buildExternalListArgs, cleanErrorText, collectArchiveCleanupTargets, extractPackageArchives, type ExtractArchiveFailureInfo, archiveFilenamePasswords, detectArchiveSignature, classifyExtractionError, ExtractionError, selectZipFallbackError, shouldSerialRetryParallelFailures, findArchiveCandidates, orderExtractorCandidatesForArchive, extractorCommandsShareIdentity, parseJvmPasswordAttemptLine, redactJvmDiagnosticLine, summarizeJvmPasswordAttempts, parseNativeExtractOutput, parseNativeArchiveEntryList, remapNativeSubstOutput, reconcileNativeExtractOutputs, resolveExtractorBackendModeForArchive, resolveExtractorBackendMode, shouldFallbackLegacyRarToJvm, shouldRunAlternativeNativeExtractor, shouldSuggestRedownloadAfterCrossBackendFailure, validateNativeArchiveEntryCandidates, validateNativeFlatArchiveEntryCandidates, } from "../src/main/extractor"; const tempDirs: string[] = []; const originalExtractBackend = process.env.RD_EXTRACT_BACKEND; const originalArchivePasswords = process.env.RD_ARCHIVE_PASSWORDS; const originalStatfs = fs.promises.statfs; const require = createRequire(import.meta.url); const rarCliPath = [ "C:\\Program Files\\WinRAR\\Rar.exe", "C:\\Program Files (x86)\\WinRAR\\Rar.exe" ].find((candidate) => fs.existsSync(candidate)) || ""; const javaAvailable = spawnSync("java", ["-version"], { stdio: "ignore" }).status === 0; const sevenZipAvailable = spawnSync("7z", ["i"], { stdio: "ignore" }).status === 0; function createEncryptedCorruptRarFixture(root: string, password: string, stem: string): string { const packageDir = path.join(root, "pkg"); const payloadPath = path.join(root, "payload.bin"); fs.mkdirSync(packageDir, { recursive: true }); fs.writeFileSync(payloadPath, randomBytes(256 * 1024)); const archivePath = path.join(packageDir, `${stem}.rar`); const created = spawnSync(rarCliPath, ["a", "-ma5", `-hp${password}`, "-v64k", "-idq", archivePath, payloadPath], { encoding: "utf8" }); if (created.status !== 0) { throw new Error(String(created.stderr || created.stdout || `Rar Exit ${created.status}`)); } const parts = fs.readdirSync(packageDir).filter((name) => new RegExp(`^${stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.part\\d+\\.rar$`, "i").test(name)).sort(); if (parts.length < 3) { throw new Error("RAR fixture has fewer than three volumes"); } const corruptPath = path.join(packageDir, parts[2]); const bytes = fs.readFileSync(corruptPath); bytes[Math.floor(bytes.length / 2)] ^= 0xff; fs.writeFileSync(corruptPath, bytes); return packageDir; } type ZipFixtureEntry = { name: string; directory?: boolean; content?: string }; type TargetTreeEntry = { path: string; type: "directory" | "file"; bytes?: string }; function readTargetTree(root: string): TargetTreeEntry[] { const entries: TargetTreeEntry[] = []; const visit = (directory: string): void => { for (const name of fs.readdirSync(directory).sort((left, right) => left.localeCompare(right))) { const absolutePath = path.join(directory, name); const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/"); const stat = fs.lstatSync(absolutePath); if (stat.isDirectory()) { entries.push({ path: relativePath, type: "directory" }); visit(absolutePath); } else { entries.push({ path: relativePath, type: "file", bytes: fs.readFileSync(absolutePath).toString("base64") }); } } }; visit(root); return entries; } function writeZipFixture(filePath: string, entries: readonly ZipFixtureEntry[]): void { const ZipFile = require("adm-zip/zipFile") as new (input: null, options: Record) => { setEntry: (entry: unknown) => void; compressToBuffer: () => Buffer; }; const ZipEntry = require("adm-zip/zipEntry") as new (options: Record) => { entryName: string; setData: (data: Buffer) => void; }; const utils = require("adm-zip/util") as { Constants: { NONE: number }; decoder: unknown; }; const options = { noSort: true, readEntries: false, method: utils.Constants.NONE, decoder: utils.decoder }; const zip = new ZipFile(null, options); for (const fixture of entries) { const entry = new ZipEntry(options); entry.entryName = fixture.directory && !fixture.name.endsWith("/") ? `${fixture.name}/` : fixture.name; entry.setData(Buffer.from(fixture.content || "")); zip.setEntry(entry); } fs.writeFileSync(filePath, zip.compressToBuffer()); } const archiveTargetCollisionCases = [ ["directory then same-name file", [{ name: "same", directory: true }, { name: "same", content: "file" }]], ["file then same-name directory", [{ name: "same", content: "file" }, { name: "same", directory: true }]], ["parent file then child file", [{ name: "same", content: "parent" }, { name: "same/child", content: "child" }]], ["child file then parent file", [{ name: "same/child", content: "child" }, { name: "same", content: "parent" }]], ["upper-case then lower-case file aliases", [{ name: "Name", content: "first" }, { name: "name", content: "second" }]], ["lower-case then upper-case file aliases", [{ name: "name", content: "first" }, { name: "Name", content: "second" }]], ["upper-case then lower-case directory aliases", [{ name: "Folder", directory: true }, { name: "folder", directory: true }]], ["lower-case then upper-case directory aliases", [{ name: "folder", directory: true }, { name: "Folder", directory: true }]], ["duplicate file targets in forward order", [{ name: "same", content: "first" }, { name: "same", content: "second" }]], ["duplicate file targets in reverse order", [{ name: "same", content: "second" }, { name: "same", content: "first" }]] ] as const satisfies ReadonlyArray; const rawPlanConflictModes = ["rename", "skip", "overwrite"] as const; beforeEach(() => { process.env.RD_EXTRACT_BACKEND = "legacy"; }); afterEach(() => { for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } if (originalExtractBackend === undefined) { delete process.env.RD_EXTRACT_BACKEND; } else { process.env.RD_EXTRACT_BACKEND = originalExtractBackend; } if (originalArchivePasswords === undefined) { delete process.env.RD_ARCHIVE_PASSWORDS; } else { process.env.RD_ARCHIVE_PASSWORDS = originalArchivePasswords; } (fs.promises as any).statfs = originalStatfs; }); describe("extractor", () => { it("maps external extractor args by conflict mode", () => { const overwriteArgs = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "C:\\target", "overwrite"); expect(overwriteArgs.slice(0, 4)).toEqual(["x", "-o+", "-p-", "-y"]); expect(overwriteArgs).toContain("-idc"); expect(overwriteArgs.some((value) => /^-mt\d+$/i.test(value))).toBe(true); expect(overwriteArgs[overwriteArgs.length - 2]).toBe("archive.rar"); expect(overwriteArgs[overwriteArgs.length - 1]).toBe("C:\\target\\"); const askArgs = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "C:\\target", "ask", "serienfans.org"); expect(askArgs.slice(0, 4)).toEqual(["x", "-o-", "-pserienfans.org", "-y"]); expect(askArgs).toContain("-idc"); expect(askArgs.some((value) => /^-mt\d+$/i.test(value))).toBe(true); expect(askArgs[askArgs.length - 2]).toBe("archive.rar"); expect(askArgs[askArgs.length - 1]).toBe("C:\\target\\"); const compatibilityArgs = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "C:\\target", "overwrite", "", false); expect(compatibilityArgs).not.toContain("-idc"); expect(compatibilityArgs.some((value) => /^-mt\d+$/i.test(value))).toBe(false); const unrarRename = buildExternalExtractArgs("unrar", "archive.rar", "C:\\target", "rename"); expect(unrarRename[0]).toBe("x"); expect(unrarRename[1]).toBe("-or"); expect(unrarRename[2]).toBe("-p-"); expect(unrarRename[3]).toBe("-y"); expect(unrarRename[unrarRename.length - 2]).toBe("archive.rar"); const rarCliArgs = buildExternalExtractArgs("Rar.exe", "archive.rar", "C:\\target", "overwrite", "serienjunkies.org"); expect(rarCliArgs.slice(0, 4)).toEqual(["x", "-o+", "-pserienjunkies.org", "-y"]); expect(rarCliArgs[rarCliArgs.length - 2]).toBe("archive.rar"); expect(rarCliArgs[rarCliArgs.length - 1]).toBe("C:\\target\\"); }); it.skipIf(process.platform !== "win32" || !rarCliPath || !sevenZipAvailable)("runs one five-candidate legacy pass for a deterministic multipart CRC failure", async () => { process.env.RD_EXTRACT_BACKEND = "auto"; const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-legacy-crc-pass-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); const payloadPath = path.join(root, "payload.bin"); fs.mkdirSync(packageDir, { recursive: true }); fs.writeFileSync(payloadPath, randomBytes(256 * 1024)); const archivePath = path.join(packageDir, "release.test.rar"); const created = spawnSync(rarCliPath, ["a", "-ma5", "-hpnot-in-candidate-list", "-v64k", "-idq", archivePath, payloadPath], { encoding: "utf8" }); expect(created.status).toBe(0); const parts = fs.readdirSync(packageDir).filter((name) => /^release\.test\.part\d+\.rar$/i.test(name)).sort(); expect(parts.length).toBeGreaterThanOrEqual(3); const logs: string[] = []; const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false, onLog: (_level, message) => logs.push(message) }); expect(result.failed).toBe(1); expect(logs.filter((message) => message.startsWith("Legacy-Extractor Start:"))).toHaveLength(1); expect(logs.filter((message) => /^Legacy-Passwort-Versuch \d\/5:/.test(message))).toHaveLength(5); expect(logs.some((message) => message.startsWith("Legacy-Fallback:"))).toBe(false); }, 30_000); it.skipIf(process.platform !== "win32" || !rarCliPath)("does not serially retry a deterministic CRC archive after another package archive succeeded", async () => { process.env.RD_EXTRACT_BACKEND = "legacy"; const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-parallel-crc-pass-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); const validPayload = path.join(root, "valid.bin"); const failedPayload = path.join(root, "failed.bin"); fs.mkdirSync(packageDir, { recursive: true }); fs.writeFileSync(validPayload, randomBytes(96 * 1024)); fs.writeFileSync(failedPayload, randomBytes(96 * 1024)); expect(spawnSync(rarCliPath, ["a", "-ma5", "-idq", path.join(packageDir, "a.valid.rar"), validPayload]).status).toBe(0); expect(spawnSync(rarCliPath, ["a", "-ma5", "-hpnot-in-candidate-list", "-idq", path.join(packageDir, "b.failed.rar"), failedPayload]).status).toBe(0); const logs: string[] = []; const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false, onLog: (_level, message) => logs.push(message) }); expect(result).toEqual(expect.objectContaining({ extracted: 1, failed: 1 })); expect(logs.filter((message) => message.startsWith("Legacy-Extractor Start: archive=b.failed.rar"))).toHaveLength(1); }, 30_000); it("deletes only successfully extracted archives", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); const validZipPath = path.join(packageDir, "ok.zip"); const invalidZipPath = path.join(packageDir, "bad.zip"); const zip = new AdmZip(); zip.addFile("release.txt", Buffer.from("ok")); zip.writeZip(validZipPath); fs.writeFileSync(invalidZipPath, "not-a-zip", "utf8"); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "delete", conflictMode: "overwrite", removeLinks: false, removeSamples: false }); expect(result.extracted).toBe(1); expect(result.failed).toBe(1); expect(fs.existsSync(validZipPath)).toBe(false); expect(fs.existsSync(invalidZipPath)).toBe(true); expect(fs.existsSync(path.join(targetDir, "release.txt"))).toBe(true); }); it("collects companion rar parts for cleanup", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); fs.mkdirSync(packageDir, { recursive: true }); const part1 = path.join(packageDir, "show.s01e01.part01.rar"); const part2 = path.join(packageDir, "show.s01e01.part02.rar"); const part3 = path.join(packageDir, "show.s01e01.part03.rar"); const other = path.join(packageDir, "other.s01e01.part01.rar"); fs.writeFileSync(part1, "a", "utf8"); fs.writeFileSync(part2, "b", "utf8"); fs.writeFileSync(part3, "c", "utf8"); fs.writeFileSync(other, "x", "utf8"); const targets = new Set(collectArchiveCleanupTargets(part1)); expect(targets.has(part1)).toBe(true); expect(targets.has(part2)).toBe(true); expect(targets.has(part3)).toBe(true); expect(targets.has(other)).toBe(false); }); it("collects split 7z companion parts for cleanup", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); fs.mkdirSync(packageDir, { recursive: true }); const part1 = path.join(packageDir, "release.7z.001"); const part2 = path.join(packageDir, "release.7z.002"); const part3 = path.join(packageDir, "release.7z.003"); const other = path.join(packageDir, "other.7z.001"); fs.writeFileSync(part1, "a", "utf8"); fs.writeFileSync(part2, "b", "utf8"); fs.writeFileSync(part3, "c", "utf8"); fs.writeFileSync(other, "x", "utf8"); const targets = new Set(collectArchiveCleanupTargets(part1)); expect(targets.has(part1)).toBe(true); expect(targets.has(part2)).toBe(true); expect(targets.has(part3)).toBe(true); expect(targets.has(other)).toBe(false); }); it("extracts archives in natural episode order", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); const zip10 = new AdmZip(); zip10.addFile("e10.txt", Buffer.from("10")); zip10.writeZip(path.join(packageDir, "Show.S01E10.zip")); const zip2 = new AdmZip(); zip2.addFile("e02.txt", Buffer.from("02")); zip2.writeZip(path.join(packageDir, "Show.S01E02.zip")); const zip1 = new AdmZip(); zip1.addFile("e01.txt", Buffer.from("01")); zip1.writeZip(path.join(packageDir, "Show.S01E01.zip")); const seenOrder: string[] = []; await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false, onProgress: (update) => { if (update.phase !== "extracting" || !update.archiveName) { return; } if (seenOrder[seenOrder.length - 1] === update.archiveName) { return; } seenOrder.push(update.archiveName); } }); expect(seenOrder.slice(0, 3)).toEqual([ "Show.S01E01.zip", "Show.S01E02.zip", "Show.S01E10.zip" ]); }); it("deletes split zip companion parts when cleanup is enabled", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); const zipPath = path.join(packageDir, "season.zip"); const z01Path = path.join(packageDir, "season.z01"); const z02Path = path.join(packageDir, "season.z02"); const otherPath = path.join(packageDir, "other.z01"); const zip = new AdmZip(); zip.addFile("episode.txt", Buffer.from("ok")); zip.writeZip(zipPath); fs.writeFileSync(z01Path, "part1", "utf8"); fs.writeFileSync(z02Path, "part2", "utf8"); fs.writeFileSync(otherPath, "keep", "utf8"); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "delete", conflictMode: "overwrite", removeLinks: false, removeSamples: false }); expect(result.extracted).toBe(1); expect(result.failed).toBe(0); expect(fs.existsSync(zipPath)).toBe(false); expect(fs.existsSync(z01Path)).toBe(false); expect(fs.existsSync(z02Path)).toBe(false); expect(fs.existsSync(otherPath)).toBe(true); expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true); }); it("removes empty package directory after archive cleanup", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); const zipPath = path.join(packageDir, "release.zip"); const zip = new AdmZip(); zip.addFile("video.mkv", Buffer.from("ok")); zip.writeZip(zipPath); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "delete", conflictMode: "overwrite", removeLinks: false, removeSamples: false }); expect(result.extracted).toBe(1); expect(result.failed).toBe(0); expect(fs.existsSync(packageDir)).toBe(false); expect(fs.existsSync(path.join(targetDir, "video.mkv"))).toBe(true); }); it("keeps package directory when non-archive files remain", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); const zipPath = path.join(packageDir, "release.zip"); const keepPath = path.join(packageDir, "notes.nfo"); const zip = new AdmZip(); zip.addFile("video.mkv", Buffer.from("ok")); zip.writeZip(zipPath); fs.writeFileSync(keepPath, "keep", "utf8"); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "delete", conflictMode: "overwrite", removeLinks: false, removeSamples: false }); expect(result.extracted).toBe(1); expect(result.failed).toBe(0); expect(fs.existsSync(packageDir)).toBe(true); expect(fs.existsSync(keepPath)).toBe(true); expect(fs.existsSync(path.join(targetDir, "video.mkv"))).toBe(true); }); it("reports extraction progress from 0 to 100", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); const zip1 = new AdmZip(); zip1.addFile("a.txt", Buffer.from("a")); zip1.writeZip(path.join(packageDir, "a.zip")); const zip2 = new AdmZip(); zip2.addFile("b.txt", Buffer.from("b")); zip2.writeZip(path.join(packageDir, "b.zip")); const updates: number[] = []; const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false, onProgress: (update) => { updates.push(update.percent); } }); expect(result.extracted).toBe(2); expect(result.failed).toBe(0); expect(updates[0]).toBe(0); expect(updates.some((value) => value > 0 && value < 100)).toBe(true); expect(updates[updates.length - 1]).toBe(100); }); it("treats ask conflict mode as skip in zip extraction", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true }); const zipPath = path.join(packageDir, "conflict.zip"); const zip = new AdmZip(); zip.addFile("same.txt", Buffer.from("new")); zip.writeZip(zipPath); const existingPath = path.join(targetDir, "same.txt"); fs.writeFileSync(existingPath, "old", "utf8"); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "ask", removeLinks: false, removeSamples: false }); expect(result.extracted).toBe(1); expect(result.failed).toBe(0); expect(fs.readFileSync(existingPath, "utf8")).toBe("old"); }); it("does not keep empty target dir when extraction fails", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); fs.writeFileSync(path.join(packageDir, "broken.zip"), "not-a-zip", "utf8"); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false }); expect(result.extracted).toBe(0); expect(result.failed).toBe(1); expect(fs.existsSync(targetDir)).toBe(false); }); it("resumes extraction from persisted progress file", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); const zipA = new AdmZip(); zipA.addFile("a.txt", Buffer.from("a")); zipA.writeZip(path.join(packageDir, "a.zip")); const zipB = new AdmZip(); zipB.addFile("b.txt", Buffer.from("b")); zipB.writeZip(path.join(packageDir, "b.zip")); fs.writeFileSync(path.join(packageDir, ".rd_extract_progress.json"), JSON.stringify({ completedArchives: ["a.zip"] }), "utf8"); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false }); expect(result.extracted).toBe(2); expect(result.failed).toBe(0); expect(fs.existsSync(path.join(targetDir, "b.txt"))).toBe(true); expect(fs.existsSync(path.join(packageDir, ".rd_extract_progress.json"))).toBe(false); }); it("aborts extraction immediately when abort signal is set", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); const zip = new AdmZip(); zip.addFile("file.txt", Buffer.from("x")); zip.writeZip(path.join(packageDir, "file.zip")); const controller = new AbortController(); controller.abort(); await expect(extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false, signal: controller.signal })).rejects.toThrow("aborted:extract"); }); it("handles missing package source directory without throwing", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg-missing"); const targetDir = path.join(root, "out"); fs.mkdirSync(targetDir, { recursive: true }); fs.writeFileSync(path.join(targetDir, "video.mkv"), "ok", "utf8"); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false }); expect(result.failed).toBe(0); expect(result.extracted).toBe(0); }); it("rejects zip entries with path traversal", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); const zip = new AdmZip(); zip.addFile("safe.txt", Buffer.from("safe")); zip.addFile("../escaped.txt", Buffer.from("malicious")); zip.writeZip(path.join(packageDir, "traversal.zip")); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false }); expect(result.extracted).toBe(1); expect(fs.existsSync(path.join(targetDir, "safe.txt"))).toBe(true); expect(fs.existsSync(path.join(root, "escaped.txt"))).toBe(false); }); it("builds external extract args for 7z-style extractor", () => { const args7z = buildExternalExtractArgs("7z.exe", "archive.7z", "C:\\target", "overwrite"); expect(args7z[0]).toBe("x"); expect(args7z).toContain("-y"); expect(args7z).toContain("-aoa"); expect(args7z).toContain("-p"); expect(args7z).toContain("archive.7z"); expect(args7z).toContain("-oC:\\target"); }); it("builds 7z args with skip conflict mode", () => { const args = buildExternalExtractArgs("7z", "archive.zip", "/out", "skip"); expect(args).toContain("-aos"); }); it("builds 7z args with rename conflict mode", () => { const args = buildExternalExtractArgs("7z", "archive.zip", "/out", "rename"); expect(args).toContain("-aou"); }); it("builds 7z args with password", () => { const args = buildExternalExtractArgs("7z", "archive.7z", "/out", "overwrite", "secretpass"); expect(args).toContain("-psecretpass"); }); it("builds WinRAR args with empty password uses -p-", () => { const args = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "/out", "overwrite", ""); expect(args).toContain("-p-"); }); it("builds WinRAR args with skip conflict mode uses -o-", () => { const args = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "/out", "skip"); expect(args[1]).toBe("-o-"); }); it("collects split zip companion parts for cleanup", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); fs.mkdirSync(packageDir, { recursive: true }); const mainZip = path.join(packageDir, "release.zip"); const z01 = path.join(packageDir, "release.z01"); const z02 = path.join(packageDir, "release.z02"); const otherZip = path.join(packageDir, "other.zip"); fs.writeFileSync(mainZip, "a", "utf8"); fs.writeFileSync(z01, "b", "utf8"); fs.writeFileSync(z02, "c", "utf8"); fs.writeFileSync(otherZip, "x", "utf8"); const targets = new Set(collectArchiveCleanupTargets(mainZip)); expect(targets.has(mainZip)).toBe(true); expect(targets.has(z01)).toBe(true); expect(targets.has(z02)).toBe(true); expect(targets.has(otherZip)).toBe(false); }); it("collects numbered split zip parts (.zip.001, .zip.002) for cleanup", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); fs.mkdirSync(packageDir, { recursive: true }); const part1 = path.join(packageDir, "movie.zip.001"); const part2 = path.join(packageDir, "movie.zip.002"); const part3 = path.join(packageDir, "movie.zip.003"); const mainZip = path.join(packageDir, "movie.zip"); const other = path.join(packageDir, "other.zip.001"); fs.writeFileSync(part1, "a", "utf8"); fs.writeFileSync(part2, "b", "utf8"); fs.writeFileSync(part3, "c", "utf8"); fs.writeFileSync(mainZip, "d", "utf8"); fs.writeFileSync(other, "x", "utf8"); const targets = new Set(collectArchiveCleanupTargets(part1)); expect(targets.has(part1)).toBe(true); expect(targets.has(part2)).toBe(true); expect(targets.has(part3)).toBe(true); expect(targets.has(mainZip)).toBe(true); expect(targets.has(other)).toBe(false); }); it("collects old-style rar split parts (.r00, .r01) for cleanup", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); fs.mkdirSync(packageDir, { recursive: true }); const mainRar = path.join(packageDir, "show.rar"); const r00 = path.join(packageDir, "show.r00"); const r01 = path.join(packageDir, "show.r01"); const r02 = path.join(packageDir, "show.r02"); fs.writeFileSync(mainRar, "a", "utf8"); fs.writeFileSync(r00, "b", "utf8"); fs.writeFileSync(r01, "c", "utf8"); fs.writeFileSync(r02, "d", "utf8"); const targets = new Set(collectArchiveCleanupTargets(mainRar)); expect(targets.has(mainRar)).toBe(true); expect(targets.has(r00)).toBe(true); expect(targets.has(r01)).toBe(true); expect(targets.has(r02)).toBe(true); }); it("preserves the original ZIP size guard error when no external extractor is available", () => { const internalError = new Error("ZIP-Eintrag zu groß für sichere Speicher-Extraktion"); const externalError = new ExtractionError("Kein nativer Entpacker gefunden", "no_extractor"); expect(selectZipFallbackError(internalError, externalError)).toBe(internalError); }); it("preserves the original ZIP size guard error for an unsupported external archive format", () => { const internalError = new Error("ZIP-Eintrag zu groß für sichere Speicher-Extraktion"); const externalError = new ExtractionError("Is not archive", "unsupported_format"); expect(selectZipFallbackError(internalError, externalError)).toBe(internalError); }); it("returns other external ZIP fallback errors unchanged", () => { const internalError = new Error("ZIP-Eintrag zu groß für sichere Speicher-Extraktion"); const externalError = new ExtractionError("CRC failed", "crc_error"); expect(selectZipFallbackError(internalError, externalError)).toBe(externalError); }); it.skipIf(process.platform !== "win32")("invalidates legacy basename-only resume state on Windows", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); const archivePath = path.join(packageDir, "episode.zip"); fs.writeFileSync(archivePath, "not-a-zip", "utf8"); fs.writeFileSync(path.join(packageDir, ".rd_extract_progress.json"), JSON.stringify({ completedArchives: ["EPISODE.ZIP"] }), "utf8"); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false }); expect(result.extracted).toBe(0); expect(result.failed).toBe(1); }); describe("disk space check", () => { it("aborts extraction when disk space is insufficient", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-diskspace-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true }); const zip = new AdmZip(); zip.addFile("test.txt", Buffer.alloc(1024, 0x41)); zip.writeZip(path.join(packageDir, "test.zip")); (fs.promises as any).statfs = async () => ({ bfree: 1, bsize: 1 }); await expect( extractPackageArchives({ packageDir, targetDir, cleanupMode: "none" as any, conflictMode: "overwrite" as any, removeLinks: false, removeSamples: false, }) ).rejects.toThrow(/Nicht genug Speicherplatz/); }); it("proceeds when disk space is sufficient", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-diskspace-ok-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true }); const zip = new AdmZip(); zip.addFile("test.txt", Buffer.alloc(1024, 0x41)); zip.writeZip(path.join(packageDir, "test.zip")); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none" as any, conflictMode: "overwrite" as any, removeLinks: false, removeSamples: false, }); expect(result.extracted).toBe(1); expect(result.failed).toBe(0); }); }); describe("nested extraction", () => { it("extracts archives found inside extracted output", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nested-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true }); const innerZip = new AdmZip(); innerZip.addFile("deep.txt", Buffer.from("deep content")); const outerZip = new AdmZip(); outerZip.addFile("inner.zip", innerZip.toBuffer()); outerZip.writeZip(path.join(packageDir, "outer.zip")); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none" as any, conflictMode: "overwrite" as any, removeLinks: false, removeSamples: false, }); expect(result.extracted).toBe(2); expect(result.failed).toBe(0); expect(fs.existsSync(path.join(targetDir, "deep.txt"))).toBe(true); }); it("does not extract blacklisted extensions like .iso", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nested-bl-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true }); const zip = new AdmZip(); zip.addFile("disc.iso", Buffer.alloc(64, 0)); zip.addFile("readme.txt", Buffer.from("hello")); zip.writeZip(path.join(packageDir, "package.zip")); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none" as any, conflictMode: "overwrite" as any, removeLinks: false, removeSamples: false, }); expect(result.extracted).toBe(1); expect(fs.existsSync(path.join(targetDir, "disc.iso"))).toBe(true); expect(fs.existsSync(path.join(targetDir, "readme.txt"))).toBe(true); }); }); describe("archiveFilenamePasswords", () => { it("extracts stem and spaced variant from archive name", () => { const result = archiveFilenamePasswords("MyRelease.S01E01.rar"); expect(result).toContain("MyRelease.S01E01"); expect(result).toContain("MyRelease S01E01"); }); it("strips multipart rar suffix", () => { const result = archiveFilenamePasswords("Show.S02E03.part01.rar"); expect(result).toContain("Show.S02E03"); expect(result).toContain("Show S02E03"); }); it("strips .zip.001 suffix", () => { const result = archiveFilenamePasswords("Movie.2024.zip.001"); expect(result).toContain("Movie.2024"); }); it("strips .tar.gz suffix", () => { const result = archiveFilenamePasswords("backup.tar.gz"); expect(result).toContain("backup"); }); it("returns empty array for empty input", () => { expect(archiveFilenamePasswords("")).toEqual([]); }); it("returns single entry when no dots/underscores", () => { const result = archiveFilenamePasswords("simple.zip"); expect(result).toEqual(["simple"]); }); it("replaces underscores with spaces", () => { const result = archiveFilenamePasswords("my_archive_name.7z"); expect(result).toContain("my_archive_name"); expect(result).toContain("my archive name"); }); }); describe(".rev cleanup", () => { it("collects .rev files for single RAR cleanup", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rev-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); fs.mkdirSync(packageDir, { recursive: true }); const mainRar = path.join(packageDir, "show.rar"); const rev = path.join(packageDir, "show.rev"); const r00 = path.join(packageDir, "show.r00"); fs.writeFileSync(mainRar, "a", "utf8"); fs.writeFileSync(rev, "b", "utf8"); fs.writeFileSync(r00, "c", "utf8"); const targets = new Set(collectArchiveCleanupTargets(mainRar)); expect(targets.has(mainRar)).toBe(true); expect(targets.has(rev)).toBe(true); expect(targets.has(r00)).toBe(true); }); it("collects .rev files for multipart RAR cleanup", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rev-mp-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); fs.mkdirSync(packageDir, { recursive: true }); const part1 = path.join(packageDir, "show.part01.rar"); const part2 = path.join(packageDir, "show.part02.rar"); const rev = path.join(packageDir, "show.rev"); fs.writeFileSync(part1, "a", "utf8"); fs.writeFileSync(part2, "b", "utf8"); fs.writeFileSync(rev, "c", "utf8"); const targets = new Set(collectArchiveCleanupTargets(part1)); expect(targets.has(part1)).toBe(true); expect(targets.has(part2)).toBe(true); expect(targets.has(rev)).toBe(true); }); }); describe("generic .001 split cleanup", () => { it("collects all numbered parts for generic splits", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-split-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); fs.mkdirSync(packageDir, { recursive: true }); const p001 = path.join(packageDir, "movie.001"); const p002 = path.join(packageDir, "movie.002"); const p003 = path.join(packageDir, "movie.003"); const other = path.join(packageDir, "other.001"); fs.writeFileSync(p001, "a", "utf8"); fs.writeFileSync(p002, "b", "utf8"); fs.writeFileSync(p003, "c", "utf8"); fs.writeFileSync(other, "x", "utf8"); const targets = new Set(collectArchiveCleanupTargets(p001)); expect(targets.has(p001)).toBe(true); expect(targets.has(p002)).toBe(true); expect(targets.has(p003)).toBe(true); expect(targets.has(other)).toBe(false); }); it("does NOT delete a non-archive .00x family that sits beside a real archive (no-signature data-loss guard)", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-split-noarch-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); const realZip = new AdmZip(); realZip.addFile("release.txt", Buffer.from("ok")); realZip.writeZip(path.join(packageDir, "movie.zip")); const d001 = path.join(packageDir, "mydata.001"); const d002 = path.join(packageDir, "mydata.002"); const d003 = path.join(packageDir, "mydata.003"); fs.writeFileSync(d001, "raw user split data, not an archive at all 0123456789", "utf8"); fs.writeFileSync(d002, "second raw chunk, also no archive magic bytes here", "utf8"); fs.writeFileSync(d003, "third raw chunk likewise plain content payload", "utf8"); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "delete", conflictMode: "overwrite", removeLinks: false, removeSamples: false }); expect(result.failed).toBe(0); expect(fs.existsSync(path.join(targetDir, "release.txt"))).toBe(true); expect(fs.existsSync(d001)).toBe(true); expect(fs.existsSync(d002)).toBe(true); expect(fs.existsSync(d003)).toBe(true); }); }); describe("detectArchiveSignature", () => { it("detects RAR signature", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-sig-")); tempDirs.push(root); const filePath = path.join(root, "test.rar"); fs.writeFileSync(filePath, Buffer.from("526172211a0700", "hex")); const sig = await detectArchiveSignature(filePath); expect(sig).toBe("rar"); }); it("detects ZIP signature", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-sig-")); tempDirs.push(root); const filePath = path.join(root, "test.zip"); fs.writeFileSync(filePath, Buffer.from("504b030414000000", "hex")); const sig = await detectArchiveSignature(filePath); expect(sig).toBe("zip"); }); it("detects 7z signature", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-sig-")); tempDirs.push(root); const filePath = path.join(root, "test.7z"); fs.writeFileSync(filePath, Buffer.from("377abcaf271c0004", "hex")); const sig = await detectArchiveSignature(filePath); expect(sig).toBe("7z"); }); it("returns null for non-archive files", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-sig-")); tempDirs.push(root); const filePath = path.join(root, "test.txt"); fs.writeFileSync(filePath, "Hello World", "utf8"); const sig = await detectArchiveSignature(filePath); expect(sig).toBeNull(); }); it("returns null for non-existent file", async () => { const sig = await detectArchiveSignature("/nonexistent/file.rar"); expect(sig).toBeNull(); }); }); describe("findArchiveCandidates extended formats", () => { it("finds .tar.gz files", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tar-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); fs.mkdirSync(packageDir, { recursive: true }); fs.writeFileSync(path.join(packageDir, "backup.tar.gz"), "data", "utf8"); fs.writeFileSync(path.join(packageDir, "readme.txt"), "info", "utf8"); const candidates = await findArchiveCandidates(packageDir); expect(candidates.map((c) => path.basename(c))).toContain("backup.tar.gz"); }); it("finds .tar.bz2 files", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tar-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); fs.mkdirSync(packageDir, { recursive: true }); fs.writeFileSync(path.join(packageDir, "archive.tar.bz2"), "data", "utf8"); const candidates = await findArchiveCandidates(packageDir); expect(candidates.map((c) => path.basename(c))).toContain("archive.tar.bz2"); }); it("finds generic .001 split files", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-split-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); fs.mkdirSync(packageDir, { recursive: true }); fs.writeFileSync(path.join(packageDir, "movie.001"), "data", "utf8"); fs.writeFileSync(path.join(packageDir, "movie.002"), "data", "utf8"); const candidates = await findArchiveCandidates(packageDir); const names = candidates.map((c) => path.basename(c)); expect(names).toContain("movie.001"); expect(names).not.toContain("movie.002"); }); it("does not duplicate .zip.001 as generic split", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dedup-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); fs.mkdirSync(packageDir, { recursive: true }); fs.writeFileSync(path.join(packageDir, "movie.zip.001"), "data", "utf8"); fs.writeFileSync(path.join(packageDir, "movie.zip.002"), "data", "utf8"); const candidates = await findArchiveCandidates(packageDir); const names = candidates.map((c) => path.basename(c)); expect(names.filter((n) => n === "movie.zip.001")).toHaveLength(1); }); it("ignores duplicate-suffixed multipart rar volumes as standalone candidates", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rar-dup-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); fs.mkdirSync(packageDir, { recursive: true }); fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part1.rar"), "data", "utf8"); fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part2.rar"), "data", "utf8"); fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part1 (1).rar"), "data", "utf8"); fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part2 (1).rar"), "data", "utf8"); fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part5 (1).rar"), "data", "utf8"); const candidates = await findArchiveCandidates(packageDir); const names = candidates.map((c) => path.basename(c)); expect(names).toContain("Sanctuary720-01x07.part1.rar"); expect(names).not.toContain("Sanctuary720-01x07.part1 (1).rar"); expect(names).not.toContain("Sanctuary720-01x07.part2 (1).rar"); expect(names).not.toContain("Sanctuary720-01x07.part5 (1).rar"); }); it("keeps single rar files with duplicate suffix as valid candidates", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-single-rar-dup-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); fs.mkdirSync(packageDir, { recursive: true }); fs.writeFileSync(path.join(packageDir, "Movie (1).rar"), "data", "utf8"); const candidates = await findArchiveCandidates(packageDir); expect(candidates.map((c) => path.basename(c))).toContain("Movie (1).rar"); }); }); describe("classifyExtractionError", () => { it("classifies CRC errors", () => { expect(classifyExtractionError("CRC failed for file.txt")).toBe("crc_error"); expect(classifyExtractionError("Checksum error in data")).toBe("crc_error"); expect(classifyExtractionError("7z-Fehler: CRCERROR")).toBe("crc_error"); expect(classifyExtractionError("7z-Fehler: DATAERROR")).toBe("crc_error"); expect(classifyExtractionError("CRC-Fehler in release.part3.rar")).toBe("crc_error"); expect(classifyExtractionError("Prüfsummenfehler der gepackten Daten in Volume C:\\release.part3.rar")).toBe("crc_error"); expect(classifyExtractionError("Pr�fsummenfehler der gepackten Daten in Volume C:\\release.part3.rar")).toBe("crc_error"); expect(classifyExtractionError("Prüfsummenfehler der gepackten Daten in Volume C:\\release.part3.rar")).toBe("crc_error"); }); it("classifies wrong password", () => { expect(classifyExtractionError("Wrong password")).toBe("wrong_password"); expect(classifyExtractionError("Falsches Passwort")).toBe("wrong_password"); expect(classifyExtractionError("Falsches Archiv-Passwort")).toBe("wrong_password"); expect(classifyExtractionError("Falsches Archiv Passwort")).toBe("wrong_password"); expect(classifyExtractionError("Falsches-Archiv-Passwort")).toBe("wrong_password"); }); it("classifies missing parts", () => { expect(classifyExtractionError("Missing volume: part2.rar")).toBe("missing_parts"); expect(classifyExtractionError("Unexpected end of archive")).toBe("missing_parts"); }); it("classifies unsupported format", () => { expect(classifyExtractionError("kein RAR-Archiv")).toBe("unsupported_format"); expect(classifyExtractionError("UNSUPPORTEDMETHOD")).toBe("unsupported_format"); }); it("classifies native 7-Zip Cannot open the file as archive errors as unsupported format", () => { expect(classifyExtractionError( new Error("Open ERROR: Cannot open the file as [zip] archive ERRORS: Is not archive") )).toBe("unsupported_format"); }); it("classifies disk full", () => { expect(classifyExtractionError("Nicht genug Speicherplatz")).toBe("disk_full"); expect(classifyExtractionError("No space left on device")).toBe("disk_full"); }); it("classifies timeout", () => { expect(classifyExtractionError("Entpacken Timeout nach 360s")).toBe("timeout"); }); it("classifies abort", () => { expect(classifyExtractionError("aborted:extract")).toBe("aborted"); }); it("classifies no extractor", () => { expect(classifyExtractionError("WinRAR/UnRAR nicht gefunden")).toBe("no_extractor"); }); it("prioritizes checksum errors over embedded wrong-password wording", () => { expect(classifyExtractionError("Checksum error in the encrypted file. Corrupt file or wrong password.")).toBe("crc_error"); }); it("returns unknown for unrecognized errors", () => { expect(classifyExtractionError("something weird happened")).toBe("unknown"); }); it("keeps important tail markers when long extractor output is trimmed", () => { const noisy = `Extracting from archive.rar ${"x".repeat(700)} Unexpected end of archive`; const cleaned = cleanErrorText(noisy); expect(cleaned).toContain("Unexpected end of archive"); expect(classifyExtractionError(cleaned)).toBe("missing_parts"); }); }); describe("shouldSerialRetryParallelFailures", () => { it("retries unknown failures that can result from parallel contention", () => { expect(shouldSerialRetryParallelFailures(1, ["unknown"])).toBe(true); expect(shouldSerialRetryParallelFailures(0, ["unknown", "unknown"])).toBe(true); }); it("does not retry deterministic archive failures after another archive succeeded", () => { expect(shouldSerialRetryParallelFailures(1, ["crc_error"])).toBe(false); expect(shouldSerialRetryParallelFailures(1, ["wrong_password"])).toBe(false); expect(shouldSerialRetryParallelFailures(1, ["unsupported_format"])).toBe(false); expect(shouldSerialRetryParallelFailures(0, ["missing_parts"])).toBe(false); expect(shouldSerialRetryParallelFailures(0, ["unsupported_format", "crc_error"])).toBe(false); }); }); describe("password discovery", () => { it("reports per-archive failures through onArchiveFailure", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-failure-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); fs.writeFileSync(path.join(packageDir, "broken.zip"), "not-a-zip", "utf8"); const failures: ExtractArchiveFailureInfo[] = []; const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false, onArchiveFailure: (failure) => { failures.push(failure); } }); expect(result.extracted).toBe(0); expect(result.failed).toBe(1); expect(failures).toHaveLength(1); expect(failures[0]?.archiveName).toBe("broken.zip"); expect(failures[0]?.category).toBe("unsupported_format"); expect(failures[0]?.suggestRedownload).toBe(false); }); it("extracts first archive serially before parallel pool when multiple passwords", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-pwdisc-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); for (const name of ["ep01.zip", "ep02.zip", "ep03.zip"]) { const zip = new AdmZip(); zip.addFile(`${name}.txt`, Buffer.from(name)); zip.writeZip(path.join(packageDir, name)); } const seenOrder: string[] = []; const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false, passwordList: "pw1|pw2|pw3", onProgress: (update) => { if (update.phase !== "extracting" || !update.archiveName) return; if (seenOrder[seenOrder.length - 1] !== update.archiveName) { seenOrder.push(update.archiveName); } } }); expect(result.extracted).toBe(3); expect(result.failed).toBe(0); expect(seenOrder[0]).toBe("ep01.zip"); }); it("skips discovery when only one password candidate", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-pwdisc-skip-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); for (const name of ["a.zip", "b.zip"]) { const zip = new AdmZip(); zip.addFile(`${name}.txt`, Buffer.from(name)); zip.writeZip(path.join(packageDir, name)); } const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false, }); expect(result.extracted).toBe(2); expect(result.failed).toBe(0); }); it("skips discovery when only one archive", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-pwdisc-one-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); const zip = new AdmZip(); zip.addFile("single.txt", Buffer.from("single")); zip.writeZip(path.join(packageDir, "only.zip")); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false, passwordList: "pw1|pw2|pw3" }); expect(result.extracted).toBe(1); expect(result.failed).toBe(0); }); }); describe("backend selection", () => { it("defaults to auto in production when no backend override is set", () => { expect(resolveExtractorBackendMode(undefined, false)).toBe("auto"); }); it("defaults to legacy in vitest when no backend override is set", () => { expect(resolveExtractorBackendMode(undefined, true)).toBe("legacy"); }); it("respects explicit backend overrides", () => { expect(resolveExtractorBackendMode("legacy", false)).toBe("legacy"); expect(resolveExtractorBackendMode("jvm", false)).toBe("jvm"); expect(resolveExtractorBackendMode("auto", false)).toBe("auto"); }); it("prefers legacy for rar archives in auto mode on Windows", () => { expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.part01.rar", undefined, false, "win32")).toBe("legacy"); expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.r00", undefined, false, "win32")).toBe("legacy"); }); it("falls back from legacy rar to jvm after partial-progress failure in auto mode on Windows", () => { expect( shouldFallbackLegacyRarToJvm( "C:\\Downloads\\episode.part01.rar", "auto", "legacy", "Error: Extracting from C:\\Downloads\\episode.part01.rar", 38, "win32" ) ).toBe(true); }); it("skips legacy rar to jvm fallback for explicit legacy mode and non-rar cases", () => { expect(shouldFallbackLegacyRarToJvm("C:\\Downloads\\episode.part01.rar", "legacy", "legacy", "checksum error", 38, "win32")).toBe(false); expect(shouldFallbackLegacyRarToJvm("C:\\Downloads\\episode.zip", "auto", "legacy", "unknown failure", 38, "win32")).toBe(false); expect(shouldFallbackLegacyRarToJvm("C:\\Downloads\\episode.part01.rar", "auto", "legacy", "timeout", 38, "win32")).toBe(false); }); it("keeps auto for non-rar archives and respects explicit overrides", () => { expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.zip", undefined, false, "win32")).toBe("auto"); expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.part01.rar", "jvm", false, "win32")).toBe("jvm"); expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.part01.rar", "legacy", false, "win32")).toBe("legacy"); }); }); describe("orderExtractorCandidatesForArchive", () => { it("prefers RAR-native CLIs over 7-Zip for rar archives", () => { const ordered = orderExtractorCandidatesForArchive( ["7z.exe", "Rar.exe", "UnRAR.exe", "WinRAR.exe"], "C:\\Downloads\\archive.part01.rar" ); expect(ordered.slice(0, 3)).toEqual(["Rar.exe", "UnRAR.exe", "WinRAR.exe"]); expect(ordered[3]).toBe("7z.exe"); }); it("keeps 7-Zip first for non-rar archives", () => { const ordered = orderExtractorCandidatesForArchive( ["UnRAR.exe", "7z.exe", "WinRAR.exe"], "C:\\Downloads\\archive.zip" ); expect(ordered[0]).toBe("7z.exe"); }); it("prefers the remembered command within the matching archive class", () => { const ordered = orderExtractorCandidatesForArchive( ["UnRAR.exe", "WinRAR.exe", "7z.exe"], "C:\\Downloads\\archive.part01.rar", "WinRAR.exe" ); expect(ordered[0]).toBe("WinRAR.exe"); expect(ordered[1]).toBe("UnRAR.exe"); }); }); describe("extractorCommandsShareIdentity", () => { it("deduplicates aliases of the same native extraction engine", () => { expect(extractorCommandsShareIdentity("Rar.exe", "UnRAR.exe", "win32")).toBe(true); expect(extractorCommandsShareIdentity("C:\\Program Files\\WinRAR\\Rar.exe", "rar", "win32")).toBe(true); expect(extractorCommandsShareIdentity("7z.exe", "7za", "win32")).toBe(true); expect(extractorCommandsShareIdentity("Rar.exe", "7z.exe", "win32")).toBe(false); }); it("budgets automatic RAR recovery to one native engine before JVM", () => { expect(shouldRunAlternativeNativeExtractor("Rar.exe", "C:\\release.part1.rar", "auto", "legacy", "win32")).toBe(false); expect(shouldRunAlternativeNativeExtractor("Rar.exe", "C:\\release.part1.rar", "jvm", "jvm", "win32")).toBe(false); expect(shouldRunAlternativeNativeExtractor("Rar.exe", "C:\\release.part1.rar", "legacy", "legacy", "win32")).toBe(true); expect(shouldRunAlternativeNativeExtractor("7z.exe", "C:\\release.zip", "auto", "auto", "win32")).toBe(true); }); it("runs one deduplicated serial recovery pass after parallel unknown failures", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-serial-recovery-once-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); for (const name of ["a.zip", "b.zip", "c.zip"]) { writeZipFixture(path.join(packageDir, name), [{ name: `${name}.txt`, content: name }]); } const attempts = new Map(); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false, scheduleArchive: async (archivePath, execute) => { const archiveName = path.basename(archivePath); attempts.set(archiveName, (attempts.get(archiveName) || 0) + 1); return execute(new AbortController().signal); }, onOutput: (event) => { const archiveName = path.basename(event.archivePath); if (event.state === "opened" && (archiveName !== "b.zip" || attempts.get(archiveName) === 1)) { throw new Error(`transient-${archiveName}`); } } }); expect(result).toEqual(expect.objectContaining({ extracted: 1, failed: 2 })); expect(Object.fromEntries(attempts)).toEqual({ "a.zip": 1, "b.zip": 2, "c.zip": 2 }); }); it.skipIf(process.platform !== "win32" || !rarCliPath)("isolates throwing Legacy password-log callbacks without retrying archives", async () => { process.env.RD_EXTRACT_BACKEND = "legacy"; const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-legacy-callback-isolation-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); for (const name of ["a", "b"]) { const inputPath = path.join(root, `${name}.txt`); fs.writeFileSync(inputPath, `${name} payload`, "utf8"); expect(spawnSync(rarCliPath, ["a", "-ma5", "-idq", path.join(packageDir, `${name}.rar`), inputPath]).status).toBe(0); } const attempts = new Map(); const failures: ExtractArchiveFailureInfo[] = []; const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false, scheduleArchive: async (archivePath, execute) => { const archiveName = path.basename(archivePath); attempts.set(archiveName, (attempts.get(archiveName) || 0) + 1); return execute(new AbortController().signal); }, onArchiveFailure: (failure) => failures.push(failure), onLog: (_level, message) => { if (message.startsWith("Passwort-Versuch ")) { throw new Error("observer failed"); } } }); expect(result).toEqual(expect.objectContaining({ extracted: 2, failed: 0 })); expect(Object.fromEntries(attempts)).toEqual({ "a.rar": 1, "b.rar": 1 }); expect(failures).toHaveLength(0); }, 30_000); }); describe("shouldSuggestRedownloadAfterCrossBackendFailure", () => { it.skipIf(process.platform !== "win32" || !rarCliPath || !javaAvailable)("reports redownload only after real Legacy and JVM CRC failures exhaust candidates", async () => { process.env.RD_EXTRACT_BACKEND = "auto"; process.env.RD_ARCHIVE_PASSWORDS = ""; const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-cross-backend-crc-exhausted-")); tempDirs.push(root); const packageDir = createEncryptedCorruptRarFixture(root, "serienjunkies.org", "cross-backend-exhausted"); const failures: ExtractArchiveFailureInfo[] = []; const result = await extractPackageArchives({ packageDir, targetDir: path.join(root, "out"), cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false, passwordList: "", onArchiveFailure: (failure) => failures.push(failure) }); expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 })); expect(failures).toHaveLength(1); expect(failures[0]).toEqual(expect.objectContaining({ category: "crc_error", suggestRedownload: true })); expect(failures[0]?.jvmFailureReason).toMatch(/CRCERROR|DATAERROR/); }, 30_000); it.skipIf(process.platform !== "win32" || !rarCliPath || !javaAvailable)("keeps real Cross-Backend CRC recovery disabled when JVM stops before the final candidate", async () => { process.env.RD_EXTRACT_BACKEND = "auto"; process.env.RD_ARCHIVE_PASSWORDS = ""; const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-cross-backend-crc-not-exhausted-")); tempDirs.push(root); const actualPassword = "cross-backend-early-secret"; const packageDir = createEncryptedCorruptRarFixture(root, actualPassword, "cross-backend-not-exhausted"); const failures: ExtractArchiveFailureInfo[] = []; const result = await extractPackageArchives({ packageDir, targetDir: path.join(root, "out"), cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false, passwordList: actualPassword, onArchiveFailure: (failure) => failures.push(failure) }); expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 })); expect(failures).toHaveLength(1); expect(failures[0]).toEqual(expect.objectContaining({ category: "crc_error", suggestRedownload: false })); expect(failures[0]?.jvmFailureReason).toMatch(/CRCERROR|DATAERROR/); }, 30_000); it("suggests recovery when both backends report CRC failure after every password candidate", () => { expect(shouldSuggestRedownloadAfterCrossBackendFailure("crc_error", "crc_error", true)).toBe(true); }); it.each([ ["wrong_password", "crc_error", true], ["crc_error", "wrong_password", true], ["crc_error", "unsupported_format", true], ["crc_error", "crc_error", false] ] as const)("does not suggest recovery for legacy=%s jvm=%s exhausted=%s", (legacyCategory, jvmCategory, exhausted) => { expect(shouldSuggestRedownloadAfterCrossBackendFailure(legacyCategory, jvmCategory, exhausted)).toBe(false); }); }); describe("parseJvmPasswordAttemptLine", () => { it("accepts only bounded attempt metadata without a password field", () => { expect(parseJvmPasswordAttemptLine("RD_PASSWORD_ATTEMPT 2 5")).toEqual({ attempt: 2, total: 5 }); expect(parseJvmPasswordAttemptLine("RD_PASSWORD_ATTEMPT 0 5")).toBeNull(); expect(parseJvmPasswordAttemptLine("RD_PASSWORD_ATTEMPT 2 5 secret")).toBeNull(); }); it("derives exhaustion only from a valid final JVM attempt", () => { expect(summarizeJvmPasswordAttempts(1, 3, false)).toEqual({ attempts: 1, total: 3, exhausted: false }); expect(summarizeJvmPasswordAttempts(3, 3, false)).toEqual({ attempts: 3, total: 3, exhausted: true }); expect(summarizeJvmPasswordAttempts(3, 3, true)).toEqual({ attempts: 3, total: 3, exhausted: false }); expect(summarizeJvmPasswordAttempts(4, 3, false)).toEqual({ attempts: 0, total: 0, exhausted: false }); }); it("redacts successful password payloads from JVM diagnostics", () => { expect(redactJvmDiagnosticLine("RD_PASSWORD c2VjcmV0")).toBe("RD_PASSWORD "); expect(redactJvmDiagnosticLine("RD_PASSWORD_ATTEMPT 2 5")).toBe("RD_PASSWORD_ATTEMPT 2 5"); expect(redactJvmDiagnosticLine("RD_ERROR CRCERROR")).toBe("RD_ERROR CRCERROR"); }); }); describe("direct output scope", () => { it.each([ ["overwrite", "new", "overwritten", ["episode.mkv"]], ["ask", "old", "skipped", []], ["rename", "old", "renamed", ["episode (1).mkv"]] ] as const)("emits exact internal ZIP outputs for %s conflicts", async (conflictMode, originalContent, disposition, outputNames) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-direct-${conflictMode}-`)); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true }); fs.writeFileSync(path.join(targetDir, "episode.mkv"), "old"); const archivePath = path.join(packageDir, "release.zip"); const zip = new AdmZip(); zip.addFile("episode.mkv", Buffer.from("new")); zip.writeZip(archivePath); const events: import("../src/main/extractor").ExtractOutputEvent[] = []; const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode, removeLinks: false, removeSamples: false, onOutput: (event) => events.push(event) }); expect(fs.readFileSync(path.join(targetDir, "episode.mkv"), "utf8")).toBe(originalContent); expect(events.map((event) => event.state)).toEqual(disposition === "skipped" ? ["complete"] : ["opened", "complete"]); expect(events[events.length - 1]).toEqual(expect.objectContaining({ version: 1, archivePath: path.resolve(archivePath), entryPath: "episode.mkv", outputPath: path.join(targetDir, outputNames[0] || "episode.mkv"), state: "complete", disposition })); expect(result.outputFiles.map((filePath) => path.basename(filePath))).toEqual([...outputNames]); }); it("extracts only nested archives produced by the package in a shared root", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-direct-nested-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "shared"); fs.mkdirSync(packageDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true }); const foreign = new AdmZip(); foreign.addFile("foreign.txt", Buffer.from("foreign")); foreign.writeZip(path.join(targetDir, "foreign.zip")); const nested = new AdmZip(); nested.addFile("owned.txt", Buffer.from("owned")); const outer = new AdmZip(); outer.addFile("owned.zip", nested.toBuffer()); outer.writeZip(path.join(packageDir, "outer.zip")); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false }); expect(result.extracted).toBe(2); expect(fs.existsSync(path.join(targetDir, "owned.txt"))).toBe(true); expect(fs.existsSync(path.join(targetDir, "foreign.txt"))).toBe(false); }); it("uses an explicit archive list without traversing a shared package root", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-explicit-archives-")); tempDirs.push(root); const packageDir = path.join(root, "shared"); const foreignDir = path.join(packageDir, "foreign-package"); const targetDir = path.join(root, "out"); fs.mkdirSync(foreignDir, { recursive: true }); const ownedArchive = path.join(packageDir, "owned.zip"); const foreignArchive = path.join(foreignDir, "foreign.zip"); const owned = new AdmZip(); owned.addFile("owned.txt", Buffer.from("owned")); owned.writeZip(ownedArchive); const foreign = new AdmZip(); foreign.addFile("foreign.txt", Buffer.from("foreign")); foreign.writeZip(foreignArchive); const readdirSpy = vi.spyOn(fs.promises, "readdir"); let sharedRootTraversals = 0; try { const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false, onlyArchives: new Set([path.resolve(ownedArchive).toLowerCase()]) }); const sharedRoot = path.resolve(packageDir); sharedRootTraversals = readdirSpy.mock.calls.filter(([directory]) => { const candidate = path.resolve(String(directory)); return candidate === sharedRoot || candidate.startsWith(`${sharedRoot}${path.sep}`); }).length; expect(result.extracted).toBe(1); expect(fs.readFileSync(path.join(targetDir, "owned.txt"), "utf8")).toBe("owned"); expect(fs.existsSync(path.join(targetDir, "foreign.txt"))).toBe(false); expect(sharedRootTraversals).toBe(0); } finally { readdirSpy.mockRestore(); } }); it("delegates top-level and nested archive jobs through one scheduler", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-global-extract-scheduler-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); const first = new AdmZip(); first.addFile("first.txt", Buffer.from("first")); first.writeZip(path.join(packageDir, "first.zip")); const nested = new AdmZip(); nested.addFile("nested.txt", Buffer.from("nested")); const second = new AdmZip(); second.addFile("owned.zip", nested.toBuffer()); second.writeZip(path.join(packageDir, "second.zip")); const scheduled: string[] = []; const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false, scheduleArchive: async (archivePath, execute) => { scheduled.push(path.basename(archivePath)); return execute(new AbortController().signal); } }); expect(result.failed).toBe(0); expect(scheduled).toEqual(["first.zip", "second.zip", "owned.zip"]); expect(fs.readFileSync(path.join(targetDir, "nested.txt"), "utf8")).toBe("nested"); }); it("resumes same-basename archives by relative path and invalidates changed multipart fingerprints", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-v2-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); const firstDir = path.join(packageDir, "first"); const secondDir = path.join(packageDir, "second"); fs.mkdirSync(firstDir, { recursive: true }); fs.mkdirSync(secondDir, { recursive: true }); const firstArchive = path.join(firstDir, "release.zip"); const secondArchive = path.join(secondDir, "release.zip"); const companionPath = path.join(firstDir, "release.sfv"); const firstZip = new AdmZip(); firstZip.addFile("first.txt", Buffer.from("first")); firstZip.writeZip(firstArchive); fs.writeFileSync(companionPath, "part-a"); const secondZip = new AdmZip(); secondZip.addFile("second.txt", Buffer.from("second")); secondZip.writeZip(secondArchive); const firstResult = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false, onlyArchives: new Set([path.resolve(firstArchive).toLowerCase()]) }); expect(firstResult.extracted).toBe(1); expect(firstResult.failed).toBe(0); expect(fs.existsSync(path.join(packageDir, ".rd_extract_progress.json"))).toBe(true); fs.writeFileSync(companionPath, "part-b-changed"); const emittedArchives: string[] = []; const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false, onOutput: (event) => emittedArchives.push(event.archivePath) }); expect(result.extracted).toBe(2); expect(emittedArchives).toContain(path.resolve(firstArchive)); expect(emittedArchives).toContain(path.resolve(secondArchive)); expect(fs.existsSync(path.join(targetDir, "second.txt"))).toBe(true); }); it("fails closed for an unknown resume-state version", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-unknown-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true }); fs.writeFileSync(path.join(packageDir, "broken.zip"), "not-a-zip"); fs.writeFileSync(path.join(targetDir, "foreign.txt"), "foreign"); fs.writeFileSync(path.join(packageDir, ".rd_extract_progress.json"), JSON.stringify({ version: 99, completedArchives: ["broken.zip"] })); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false }); expect(result.extracted).toBe(0); expect(result.failed).toBe(1); }); it("retains concrete completed outputs when a later entry aborts", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-abort-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); const archivePath = path.join(packageDir, "release.zip"); const zip = new AdmZip(); zip.addFile("first.txt", Buffer.from("first")); zip.addFile("second.txt", Buffer.from("second")); zip.writeZip(archivePath); const controller = new AbortController(); const events: import("../src/main/extractor").ExtractOutputEvent[] = []; await expect(extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false, signal: controller.signal, onOutput: (event) => { events.push(event); controller.abort(); } })).rejects.toThrow("aborted:extract"); expect(events.map((event) => event.state)).toEqual(["opened", "complete"]); expect(events[1]).toEqual(expect.objectContaining({ state: "complete", outputPath: path.join(targetDir, "first.txt") })); expect(fs.existsSync(path.join(targetDir, "first.txt"))).toBe(true); expect(fs.existsSync(path.join(targetDir, "second.txt"))).toBe(false); }); it("emits opened before writing and complete only after the internal ZIP write", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-lifecycle-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); const archivePath = path.join(packageDir, "release.zip"); const zip = new AdmZip(); zip.addFile("episode.mkv", Buffer.from("video")); zip.writeZip(archivePath); const states: string[] = []; const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false, onOutput: (event) => states.push(event.state) }); expect(result.failed).toBe(0); expect(states).toEqual(["opened", "complete"]); expect(fs.readFileSync(path.join(targetDir, "episode.mkv"), "utf8")).toBe("video"); }); it("rejects an internal ZIP target behind a junction before changing it", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-junction-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); const realDir = path.join(targetDir, "real"); const linkedDir = path.join(targetDir, "linked"); fs.mkdirSync(packageDir, { recursive: true }); fs.mkdirSync(realDir, { recursive: true }); try { fs.symlinkSync(realDir, linkedDir, process.platform === "win32" ? "junction" : "dir"); } catch { return; } const protectedPath = path.join(realDir, "protected.txt"); fs.writeFileSync(protectedPath, "foreign"); const zip = new AdmZip(); zip.addFile("linked/protected.txt", Buffer.from("package")); zip.writeZip(path.join(packageDir, "release.zip")); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false }); expect(result.extracted).toBe(0); expect(result.failed).toBe(1); expect(fs.readFileSync(protectedPath, "utf8")).toBe("foreign"); }); it("aborts an internal ZIP entry callback failure before opening the target", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-callback-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); const zip = new AdmZip(); zip.addFile("episode.mkv", Buffer.from("video")); zip.writeZip(path.join(packageDir, "release.zip")); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false, onOutput: () => { throw new Error("output-callback-failed"); } }); expect(result.extracted).toBe(0); expect(result.failed).toBe(1); expect(result.lastError).toContain("output-callback-failed"); expect(fs.existsSync(path.join(targetDir, "episode.mkv"))).toBe(false); }); it("strictly parses native output paths and fails closed for ambiguous rename output", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-output-")); tempDirs.push(root); const targetDir = path.join(root, "out"); fs.mkdirSync(targetDir, { recursive: true }); const archivePath = path.join(root, "archive.7z"); const exactPath = path.join(targetDir, "folder", "episode.mkv"); fs.mkdirSync(path.dirname(exactPath), { recursive: true }); fs.writeFileSync(exactPath, "video"); expect(parseNativeExtractOutput("7z.exe", "- folder\\episode.mkv", archivePath, targetDir, "overwrite")).toEqual([ expect.objectContaining({ entryPath: "folder/episode.mkv", outputPath: exactPath, disposition: "overwritten" }) ]); expect(parseNativeExtractOutput("UnRAR.exe", `Extracting ${exactPath} OK`, archivePath, targetDir, "overwrite")).toEqual([ expect.objectContaining({ entryPath: "folder/episode.mkv", outputPath: exactPath }) ]); expect(parseNativeExtractOutput("7z.exe", "- ..\\foreign.mkv", archivePath, targetDir, "overwrite")).toEqual([]); const renamedPath = path.join(targetDir, "episode (1).mkv"); fs.writeFileSync(path.join(targetDir, "episode.mkv"), "foreign"); fs.writeFileSync(renamedPath, "owned"); expect(parseNativeExtractOutput("7z.exe", "- episode.mkv", archivePath, targetDir, "rename")).toEqual([]); expect(parseNativeExtractOutput("7z.exe", "- episode (1).mkv", archivePath, targetDir, "rename")).toEqual([ expect.objectContaining({ outputPath: renamedPath, disposition: "renamed" }) ]); }); it("preserves a native renamed filename when remapping a subst output", () => { const targetDir = "C:\\Downloads\\Extracted"; const event = remapNativeSubstOutput({ version: 1, archivePath: "C:\\Downloads\\release.rar", entryPath: "episode.mkv", outputPath: "Z:\\episode(2).mkv", state: "complete", disposition: "renamed" }, "Z:\\", targetDir); expect(event.outputPath).toBe(path.resolve(targetDir, "episode(2).mkv")); expect(event.entryPath).toBe("episode.mkv"); }); it("keeps native 7-Zip output directories when a previous RAR requested flat mode", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-seven-flat-")); tempDirs.push(root); const targetDir = path.join(root, "out"); const outputPath = path.join(targetDir, "folder", "episode.mkv"); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, "video"); const events = reconcileNativeExtractOutputs( "7z.exe", [{ entryPath: "folder/episode.mkv", isDirectory: false }], path.join(root, "release.7z"), targetDir, "overwrite", new Set(), true ); expect(events).toEqual([ expect.objectContaining({ entryPath: "folder/episode.mkv", outputPath }) ]); }); it("rejects colliding RAR flat-mode basenames before extraction", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-rar-flat-collision-")); tempDirs.push(root); const targetDir = path.join(root, "out"); fs.mkdirSync(targetDir, { recursive: true }); expect(() => validateNativeFlatArchiveEntryCandidates("Rar.exe", [ "folder-a/episode.mkv", "folder-b/episode.mkv" ], targetDir)).toThrow(/Kollision/i); }); it.each(["Entpacke", "Extrayendo", "Extraction"])("parses verified native RAR candidates without depending on the %s locale verb", (verb) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-locale-")); tempDirs.push(root); const targetDir = path.join(root, "out"); const outputPath = path.join(targetDir, "episode.mkv"); fs.mkdirSync(targetDir, { recursive: true }); fs.writeFileSync(outputPath, "video"); expect(parseNativeExtractOutput("UnRAR.exe", `${verb} ${outputPath} OK`, path.join(root, "archive.rar"), targetDir, "overwrite")).toEqual([ expect.objectContaining({ outputPath, entryPath: "episode.mkv" }) ]); }); it("reconciles verified native outputs when WinRAR emits no parseable completion line", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-reconcile-")); tempDirs.push(root); const targetDir = path.join(root, "out"); const archivePath = path.join(root, "release.part1.rar"); const outputPath = path.join(targetDir, "folder", "episode.mkv"); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, "video"); const events = reconcileNativeExtractOutputs( "Rar.exe", [{ entryPath: "folder/", isDirectory: true }, { entryPath: "folder/episode.mkv", isDirectory: false }], archivePath, targetDir, "overwrite", new Set() ); expect(events).toEqual([{ version: 1, archivePath: path.resolve(archivePath), entryPath: "folder/episode.mkv", outputPath, state: "complete", disposition: "written" }]); }); it.each([ ["Rar.exe", "episode(1).mkv", "episode(2).mkv"], ["7z.exe", "episode_1.mkv", "episode_2.mkv"] ])("reconciles the newly renamed %s output without claiming an older collision", (command, firstRenamedName, newRenamedName) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-rename-reconcile-")); tempDirs.push(root); const targetDir = path.join(root, "out"); const archivePath = path.join(root, "release.rar"); const basePath = path.join(targetDir, "episode.mkv"); const firstRenamedPath = path.join(targetDir, firstRenamedName); const newRenamedPath = path.join(targetDir, newRenamedName); fs.mkdirSync(targetDir, { recursive: true }); fs.writeFileSync(basePath, "foreign-base"); fs.writeFileSync(firstRenamedPath, "foreign-renamed"); const existingBefore = new Set([basePath, firstRenamedPath].map((filePath) => ( process.platform === "win32" ? path.resolve(filePath).toLowerCase() : path.resolve(filePath) ))); fs.writeFileSync(newRenamedPath, "owned"); const events = reconcileNativeExtractOutputs( command, [{ entryPath: "episode.mkv", isDirectory: false }], archivePath, targetDir, "rename", existingBefore ); expect(events).toEqual([ expect.objectContaining({ entryPath: "episode.mkv", outputPath: newRenamedPath, state: "complete", disposition: "renamed" }) ]); }); it.each([ ["file.mkv:stream", "file.mkv"], ["name.", "name"], ["name ", "name"], ["CON", "safe-base.txt"], ["aux.txt", "safe-base.txt"], ["folder/LPT1.mkv", "safe-base.txt"] ] as const)("rejects Win32-unsafe internal ZIP entry %s before changing its alias", async (entryName, baseName) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-win32-entry-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true }); const basePath = path.join(targetDir, baseName); fs.writeFileSync(basePath, "foreign"); const zip = new AdmZip(); zip.addFile(entryName, Buffer.from("package")); zip.writeZip(path.join(packageDir, "release.zip")); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false }); expect(result.extracted).toBe(0); expect(result.failed).toBe(1); expect(fs.readFileSync(basePath, "utf8")).toBe("foreign"); }); it.each(["file.mkv:stream", "name.", "name ", "CON", "aux.txt", "folder/LPT1.mkv"])("rejects native preflight entry %s before extraction", (entryName) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-preflight-")); tempDirs.push(root); const targetDir = path.join(root, "out"); fs.mkdirSync(targetDir, { recursive: true }); expect(validateNativeArchiveEntryCandidates).toBeTypeOf("function"); expect(() => validateNativeArchiveEntryCandidates([entryName], targetDir)).toThrow(/Ausgabepfad|entry/i); }); it("uses deterministic native list commands and parses entry-only output", () => { expect(buildExternalListArgs("7z.exe", "archive.7z")).toEqual(["l", "-slt", "-sccUTF-8", "-p", "archive.7z"]); expect(buildExternalListArgs("UnRAR.exe", "archive.rar")).toEqual(["lb", "-p-", "-y", "archive.rar"]); expect(parseNativeArchiveEntryList("7z.exe", [ "Path = archive.7z", "Type = 7z", "----------", "Path = folder/episode.mkv", "Size = 10" ].join("\n"))).toEqual(["folder/episode.mkv"]); expect(parseNativeArchiveEntryList("UnRAR.exe", "folder/episode.mkv\r\nsubtitle.srt\r\n")).toEqual([ "folder/episode.mkv", "subtitle.srt" ]); expect(parseNativeArchiveEntryList("7z.exe", [ "----------", "Path = folder", "Folder = +", "Path = folder/episode.mkv", "Folder = -" ].join("\n"))).toEqual(["folder/", "folder/episode.mkv"]); }); it("classifies bare RAR directory names through the attribute-filtered directory listing", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-rar-directory-list-")); tempDirs.push(root); const allEntries = [ "Doona.S01E02.German.DL.720p.WEB.x264-WvF\\doona.s01e02.german.dl.720p.web.x264-wvf.mkv", "Doona.S01E02.German.DL.720p.WEB.x264-WvF" ].join("\r\n"); const directoryEntries = "Doona.S01E02.German.DL.720p.WEB.x264-WvF\r\n"; expect(buildExternalListArgs("Rar.exe", "archive.rar", "", true)).toEqual([ "lb", "-e+d", "-p-", "-y", "archive.rar" ]); const parsed = parseNativeArchiveEntryList("Rar.exe", allEntries, directoryEntries); expect(parsed).toEqual([ "Doona.S01E02.German.DL.720p.WEB.x264-WvF\\doona.s01e02.german.dl.720p.web.x264-wvf.mkv", "Doona.S01E02.German.DL.720p.WEB.x264-WvF/" ]); expect(() => validateNativeArchiveEntryCandidates(parsed, root)).not.toThrow(); expect(() => validateNativeArchiveEntryCandidates( parseNativeArchiveEntryList("Rar.exe", allEntries), root )).toThrow(/Datei ist Vorfahr/i); expect(() => validateNativeArchiveEntryCandidates( parseNativeArchiveEntryList("Rar.exe", "same\r\nsame\r\n", "same\r\n"), root )).toThrow(/mehrfaches|typwidriges/i); expect(() => validateNativeArchiveEntryCandidates( parseNativeArchiveEntryList("Rar.exe", "same\r\nsame\r\n", "same\r\nsame\r\n"), root )).not.toThrow(); }); it("preflights every internal ZIP entry before overwriting an earlier safe target", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-zip-full-preflight-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true }); const safePath = path.join(targetDir, "00-safe.txt"); const aliasPath = path.join(targetDir, "zz-name"); fs.writeFileSync(safePath, "foreign-safe"); fs.writeFileSync(aliasPath, "foreign-alias"); const zip = new AdmZip(); zip.addFile("00-safe.txt", Buffer.from("package-safe")); zip.addFile("zz-name.", Buffer.from("package-invalid")); zip.writeZip(path.join(packageDir, "release.zip")); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, removeSamples: false }); expect(result.extracted).toBe(0); expect(result.failed).toBe(1); expect(fs.readFileSync(safePath, "utf8")).toBe("foreign-safe"); expect(fs.readFileSync(aliasPath, "utf8")).toBe("foreign-alias"); }); it.each(rawPlanConflictModes.flatMap((conflictMode) => archiveTargetCollisionCases.map(([label, entries]) => [conflictMode, label, entries] as const)))("rejects internal ZIP %s %s before any target mutation", async (conflictMode, _label, entries) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-zip-target-plan-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true }); const sentinelPath = path.join(targetDir, "sentinel.txt"); fs.writeFileSync(sentinelPath, "foreign"); const before = readTargetTree(targetDir); writeZipFixture(path.join(packageDir, "collision.zip"), entries); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode, removeLinks: false, removeSamples: false }); expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 })); expect(readTargetTree(targetDir)).toEqual(before); }); it.each(rawPlanConflictModes.flatMap((conflictMode) => archiveTargetCollisionCases.map(([label, entries]) => [conflictMode, label, entries] as const)))("rejects native preflight before %s conflict handling for %s", (_conflictMode, _label, entries) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-target-plan-")); tempDirs.push(root); const targetDir = path.join(root, "out"); fs.mkdirSync(targetDir, { recursive: true }); fs.writeFileSync(path.join(targetDir, "sentinel.bin"), Buffer.from([0, 1, 2, 255])); const before = readTargetTree(targetDir); const candidates = entries.map((entry) => "directory" in entry && entry.directory ? `${entry.name}/` : entry.name); expect(() => validateNativeArchiveEntryCandidates(candidates, targetDir)).toThrow(/target|ziel|kollision/i); expect(readTargetTree(targetDir)).toEqual(before); }); it.each(rawPlanConflictModes)("allows safely identical duplicate directory targets before %s conflict handling", async (conflictMode) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-identical-directory-plan-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); const targetDir = path.join(root, "out"); fs.mkdirSync(packageDir, { recursive: true }); writeZipFixture(path.join(packageDir, "directories.zip"), [ { name: "same", directory: true }, { name: "same", directory: true }, { name: "same/child.txt", content: "child" } ]); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode, removeLinks: false, removeSamples: false }); expect(result).toEqual(expect.objectContaining({ extracted: 1, failed: 0 })); expect(fs.statSync(path.join(targetDir, "same")).isDirectory()).toBe(true); expect(fs.readFileSync(path.join(targetDir, "same", "child.txt"), "utf8")).toBe("child"); expect(() => validateNativeArchiveEntryCandidates(["same/", "same/"], targetDir)).not.toThrow(); }); it("preserves raw RAR list trailing whitespace and dots for validation", () => { const entries = parseNativeArchiveEntryList("UnRAR.exe", "safe.mkv\r\nname \r\nname.\r\n"); expect(entries).toEqual(["safe.mkv", "name ", "name."]); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-raw-list-")); tempDirs.push(root); expect(() => validateNativeArchiveEntryCandidates(entries, root)).toThrow(/Ausgabepfad|entry/i); }); }); });