Use the validated native archive plan to recognize exact files after successful WinRAR and 7-Zip runs even when progress output contains no parseable completion lines. Preserve extractor-specific rename targets across subst mappings, keep 7-Zip directory paths when RAR flat mode is active, and reject colliding RAR flat targets before extraction.
2039 lines
81 KiB
TypeScript
2039 lines
81 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("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);
|
|
});
|
|
|
|
});
|
|
});
|