Corroborate CRC failures across extraction backends, retry only implicated multipart volumes, and distinguish corruption, missing volumes, I/O failures, and wrong passwords across native, Zip4j, and JBinding paths. Make disk retries generation-safe, preserve selective run scopes and cooldowns, protect shared output files during cleanup, validate manual extraction batches atomically, and restore interrupted integrity work safely. Prioritize active package operations in the UI, strengthen extraction IPC validation, compile the JVM sidecar before release builds, and verify shipped JVM resources byte-for-byte in every Windows artifact.
2369 lines
98 KiB
TypeScript
2369 lines
98 KiB
TypeScript
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<string, unknown>) => {
|
|
setEntry: (entry: unknown) => void;
|
|
compressToBuffer: () => Buffer;
|
|
};
|
|
const ZipEntry = require("adm-zip/zipEntry") as new (options: Record<string, unknown>) => {
|
|
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<readonly [string, readonly ZipFixtureEntry[]]>;
|
|
|
|
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<string, number>();
|
|
|
|
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<string, number>();
|
|
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 <redacted>");
|
|
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);
|
|
});
|
|
|
|
});
|
|
});
|