Use WinRAR's directory-attribute listing as a counted type source so bare folder entries remain valid while file-directory aliases are still rejected. Emit only removed for successfully deleted JVM partial outputs so asynchronous consumers preserve the original archive error instead of reporting a stale missing file.
2071 lines
83 KiB
TypeScript
2071 lines
83 KiB
TypeScript
import fs from "node:fs";
|
|
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,
|
|
parseNativeExtractOutput,
|
|
parseNativeArchiveEntryList,
|
|
remapNativeSubstOutput,
|
|
reconcileNativeExtractOutputs,
|
|
resolveExtractorBackendModeForArchive,
|
|
resolveExtractorBackendMode,
|
|
shouldFallbackLegacyRarToJvm,
|
|
validateNativeArchiveEntryCandidates,
|
|
validateNativeFlatArchiveEntryCandidates,
|
|
} from "../src/main/extractor";
|
|
|
|
const tempDirs: string[] = [];
|
|
const originalExtractBackend = process.env.RD_EXTRACT_BACKEND;
|
|
const originalStatfs = fs.promises.statfs;
|
|
const require = createRequire(import.meta.url);
|
|
|
|
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;
|
|
}
|
|
(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("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");
|
|
});
|
|
|
|
it("classifies wrong password", () => {
|
|
expect(classifyExtractionError("Wrong password")).toBe("wrong_password");
|
|
expect(classifyExtractionError("Falsches 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("keeps serial recovery enabled after mixed parallel results", () => {
|
|
expect(shouldSerialRetryParallelFailures(1, ["wrong_password"])).toBe(true);
|
|
expect(shouldSerialRetryParallelFailures(2, ["missing_parts"])).toBe(true);
|
|
});
|
|
|
|
it("only retries a total parallel wipe-out for contention-like failures", () => {
|
|
expect(shouldSerialRetryParallelFailures(0, ["crc_error", "wrong_password", "unknown"])).toBe(true);
|
|
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("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);
|
|
});
|
|
|
|
});
|
|
});
|