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.
746 lines
28 KiB
TypeScript
746 lines
28 KiB
TypeScript
import fs from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
import AdmZip from "adm-zip";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { extractPackageArchives } from "../src/main/extractor";
|
|
|
|
const tempDirs: string[] = [];
|
|
const originalBackend = process.env.RD_EXTRACT_BACKEND;
|
|
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 jvmTargetCollisionCases = [
|
|
["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;
|
|
|
|
function hasJavaRuntime(): boolean {
|
|
const result = spawnSync("java", ["-version"], { stdio: "ignore" });
|
|
return result.status === 0;
|
|
}
|
|
|
|
function hasJvmExtractorRuntime(): boolean {
|
|
const root = path.join(process.cwd(), "resources", "extractor-jvm");
|
|
const classesMain = path.join(root, "classes", "com", "sucukdeluxe", "extractor", "JBindExtractorMain.class");
|
|
const requiredLibs = [
|
|
path.join(root, "lib", "sevenzipjbinding.jar"),
|
|
path.join(root, "lib", "sevenzipjbinding-all-platforms.jar"),
|
|
path.join(root, "lib", "zip4j.jar")
|
|
];
|
|
return fs.existsSync(classesMain) && requiredLibs.every((libPath) => fs.existsSync(libPath));
|
|
}
|
|
|
|
function corruptFirstZipPayload(zipPath: string): void {
|
|
const bytes = fs.readFileSync(zipPath);
|
|
const signature = bytes.indexOf(Buffer.from([0x50, 0x4b, 0x03, 0x04]));
|
|
if (signature < 0) {
|
|
throw new Error("local ZIP header missing");
|
|
}
|
|
const compressedSize = bytes.readUInt32LE(signature + 18);
|
|
const nameLength = bytes.readUInt16LE(signature + 26);
|
|
const extraLength = bytes.readUInt16LE(signature + 28);
|
|
const dataOffset = signature + 30 + nameLength + extraLength;
|
|
if (compressedSize < 2 || dataOffset + compressedSize > bytes.length) {
|
|
throw new Error("ZIP payload missing");
|
|
}
|
|
bytes[dataOffset + Math.floor(compressedSize / 2)] ^= 0xff;
|
|
fs.writeFileSync(zipPath, bytes);
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
if (originalBackend === undefined) {
|
|
delete process.env.RD_EXTRACT_BACKEND;
|
|
} else {
|
|
process.env.RD_EXTRACT_BACKEND = originalBackend;
|
|
}
|
|
});
|
|
|
|
describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm backend", () => {
|
|
it("extracts zip archives through SevenZipJBinding backend", async () => {
|
|
process.env.RD_EXTRACT_BACKEND = "jvm";
|
|
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-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("episode.txt", Buffer.from("ok"));
|
|
zip.writeZip(zipPath);
|
|
const events: import("../src/main/extractor").ExtractOutputEvent[] = [];
|
|
|
|
const result = await extractPackageArchives({
|
|
packageDir,
|
|
targetDir,
|
|
cleanupMode: "none",
|
|
conflictMode: "overwrite",
|
|
removeLinks: false,
|
|
removeSamples: false,
|
|
onOutput: (event) => events.push(event)
|
|
});
|
|
|
|
expect(result.extracted).toBe(1);
|
|
expect(result.failed).toBe(0);
|
|
expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true);
|
|
expect(events).toEqual([
|
|
expect.objectContaining({
|
|
version: 1,
|
|
archivePath: path.resolve(zipPath),
|
|
entryPath: "episode.txt",
|
|
outputPath: path.join(targetDir, "episode.txt"),
|
|
state: "opened",
|
|
disposition: "written"
|
|
}),
|
|
expect.objectContaining({
|
|
version: 1,
|
|
archivePath: path.resolve(zipPath),
|
|
entryPath: "episode.txt",
|
|
outputPath: path.join(targetDir, "episode.txt"),
|
|
state: "complete",
|
|
disposition: "written"
|
|
})
|
|
]);
|
|
expect(result.outputFiles).toEqual([path.join(targetDir, "episode.txt")]);
|
|
});
|
|
|
|
it.each(["7zjbinding", "zip4j"])("emits versioned Base64 output lines from %s", (backend) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-output-${backend}-`));
|
|
tempDirs.push(root);
|
|
const targetDir = path.join(root, "out");
|
|
const zipPath = path.join(root, "release.zip");
|
|
const zip = new AdmZip();
|
|
zip.addFile("folder/episode.txt", Buffer.from("ok"));
|
|
zip.writeZip(zipPath);
|
|
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
|
|
const classPath = [
|
|
path.join(runtimeRoot, "classes"),
|
|
path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"),
|
|
path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"),
|
|
path.join(runtimeRoot, "lib", "zip4j.jar")
|
|
].join(path.delimiter);
|
|
|
|
const run = spawnSync("java", [
|
|
"-cp",
|
|
classPath,
|
|
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
|
"--archive",
|
|
zipPath,
|
|
"--target",
|
|
targetDir,
|
|
"--conflict",
|
|
"overwrite",
|
|
"--backend",
|
|
backend
|
|
], { encoding: "utf8" });
|
|
|
|
expect(run.status).toBe(0);
|
|
const outputLines = String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_OUTPUT "));
|
|
expect(outputLines.map((line) => line.split(" ")[2])).toEqual(["opened", "complete"]);
|
|
const fields = outputLines[1].split(" ");
|
|
expect(fields.slice(0, 5)).toEqual(["RD_OUTPUT", "1", "complete", "written", fields[4]]);
|
|
expect(Buffer.from(fields[4], "base64").toString("utf8")).toBe(path.resolve(zipPath));
|
|
expect(Buffer.from(fields[5], "base64").toString("utf8")).toBe("folder/episode.txt");
|
|
expect(Buffer.from(fields[6], "base64").toString("utf8")).toBe(path.join(targetDir, "folder", "episode.txt"));
|
|
});
|
|
|
|
it.each(["7zjbinding", "zip4j"])("rejects %s output behind a junction before opening it", (backend) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-junction-${backend}-`));
|
|
tempDirs.push(root);
|
|
const targetDir = path.join(root, "out");
|
|
const realDir = path.join(targetDir, "real");
|
|
const linkedDir = path.join(targetDir, "linked");
|
|
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 zipPath = path.join(root, "release.zip");
|
|
const zip = new AdmZip();
|
|
zip.addFile("linked/protected.txt", Buffer.from("package"));
|
|
zip.writeZip(zipPath);
|
|
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
|
|
const classPath = [
|
|
path.join(runtimeRoot, "classes"),
|
|
path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"),
|
|
path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"),
|
|
path.join(runtimeRoot, "lib", "zip4j.jar")
|
|
].join(path.delimiter);
|
|
|
|
const run = spawnSync("java", [
|
|
"-cp",
|
|
classPath,
|
|
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
|
"--archive",
|
|
zipPath,
|
|
"--target",
|
|
targetDir,
|
|
"--conflict",
|
|
"overwrite",
|
|
"--backend",
|
|
backend
|
|
], { encoding: "utf8" });
|
|
|
|
expect(run.status).not.toBe(0);
|
|
expect(fs.readFileSync(protectedPath, "utf8")).toBe("foreign");
|
|
});
|
|
|
|
it("turns JVM output callback failures into a controlled archive failure and keeps the next request usable", async () => {
|
|
process.env.RD_EXTRACT_BACKEND = "jvm";
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-callback-"));
|
|
tempDirs.push(root);
|
|
const firstPackage = path.join(root, "first-pkg");
|
|
const secondPackage = path.join(root, "second-pkg");
|
|
fs.mkdirSync(firstPackage, { recursive: true });
|
|
fs.mkdirSync(secondPackage, { recursive: true });
|
|
const firstZip = new AdmZip();
|
|
firstZip.addFile("first.txt", Buffer.from("first"));
|
|
firstZip.writeZip(path.join(firstPackage, "first.zip"));
|
|
const secondZip = new AdmZip();
|
|
secondZip.addFile("second.txt", Buffer.from("second"));
|
|
secondZip.writeZip(path.join(secondPackage, "second.zip"));
|
|
|
|
const first = await extractPackageArchives({
|
|
packageDir: firstPackage,
|
|
targetDir: path.join(root, "first-out"),
|
|
cleanupMode: "none",
|
|
conflictMode: "overwrite",
|
|
removeLinks: false,
|
|
removeSamples: false,
|
|
onOutput: () => {
|
|
throw new Error("jvm-output-callback-failed");
|
|
}
|
|
});
|
|
const second = await extractPackageArchives({
|
|
packageDir: secondPackage,
|
|
targetDir: path.join(root, "second-out"),
|
|
cleanupMode: "none",
|
|
conflictMode: "overwrite",
|
|
removeLinks: false,
|
|
removeSamples: false
|
|
});
|
|
|
|
expect(first.extracted).toBe(0);
|
|
expect(first.failed).toBe(1);
|
|
expect(first.lastError).toContain("jvm-output-callback-failed");
|
|
expect(second).toEqual(expect.objectContaining({ extracted: 1, failed: 0 }));
|
|
}, 10000);
|
|
|
|
it.each(["7zjbinding", "zip4j"])("reports only removed after deleting a failed %s output", (backend) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-partial-${backend}-`));
|
|
tempDirs.push(root);
|
|
const targetDir = path.join(root, "out");
|
|
const zipPath = path.join(root, "corrupt.zip");
|
|
const zip = new AdmZip();
|
|
zip.addFile("episode.bin", Buffer.from("payload-".repeat(20_000)));
|
|
zip.writeZip(zipPath);
|
|
corruptFirstZipPayload(zipPath);
|
|
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
|
|
const classPath = [
|
|
path.join(runtimeRoot, "classes"),
|
|
path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"),
|
|
path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"),
|
|
path.join(runtimeRoot, "lib", "zip4j.jar")
|
|
].join(path.delimiter);
|
|
|
|
const run = spawnSync("java", [
|
|
"-cp",
|
|
classPath,
|
|
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
|
"--archive",
|
|
zipPath,
|
|
"--target",
|
|
targetDir,
|
|
"--conflict",
|
|
"overwrite",
|
|
"--backend",
|
|
backend
|
|
], { encoding: "utf8" });
|
|
const states = String(run.stdout)
|
|
.split(/\r?\n/)
|
|
.filter((line) => line.startsWith("RD_OUTPUT "))
|
|
.map((line) => line.split(" ")[2]);
|
|
|
|
expect(run.status).not.toBe(0);
|
|
expect(states).toEqual(["opened", "removed"]);
|
|
expect(fs.existsSync(path.join(targetDir, "episode.bin"))).toBe(false);
|
|
});
|
|
|
|
it("preserves the real JVM archive error when a failed output is removed before Node consumes the event", async () => {
|
|
process.env.RD_EXTRACT_BACKEND = "jvm";
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-removed-output-error-"));
|
|
tempDirs.push(root);
|
|
const packageDir = path.join(root, "pkg");
|
|
const targetDir = path.join(root, "out");
|
|
const zipPath = path.join(packageDir, "corrupt.zip");
|
|
fs.mkdirSync(packageDir, { recursive: true });
|
|
const zip = new AdmZip();
|
|
zip.addFile("episode.bin", Buffer.from("payload-".repeat(20_000)));
|
|
zip.writeZip(zipPath);
|
|
corruptFirstZipPayload(zipPath);
|
|
|
|
const result = await extractPackageArchives({
|
|
packageDir,
|
|
targetDir,
|
|
cleanupMode: "none",
|
|
conflictMode: "overwrite",
|
|
removeLinks: false,
|
|
removeSamples: false
|
|
});
|
|
|
|
expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 }));
|
|
expect(result.lastError).not.toContain("Gemeldete Extract-Ausgabe existiert nicht");
|
|
expect(result.lastError).toMatch(/crc|data|checksum|zip|archive/i);
|
|
expect(fs.existsSync(path.join(targetDir, "episode.bin"))).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
["7zjbinding", "file.mkv:stream", "file.mkv"],
|
|
["7zjbinding", "name.", "name"],
|
|
["7zjbinding", "name ", "name"],
|
|
["7zjbinding", "CON", "safe-base.txt"],
|
|
["7zjbinding", "aux.txt", "safe-base.txt"],
|
|
["7zjbinding", "folder/LPT1.mkv", "safe-base.txt"],
|
|
["zip4j", "file.mkv:stream", "file.mkv"],
|
|
["zip4j", "name.", "name"],
|
|
["zip4j", "name ", "name"],
|
|
["zip4j", "CON", "safe-base.txt"],
|
|
["zip4j", "aux.txt", "safe-base.txt"],
|
|
["zip4j", "folder/LPT1.mkv", "safe-base.txt"]
|
|
] as const)("rejects %s Win32-unsafe entry %s before changing its alias", (backend, entryName, baseName) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-win32-${backend}-`));
|
|
tempDirs.push(root);
|
|
const targetDir = path.join(root, "out");
|
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
const basePath = path.join(targetDir, baseName);
|
|
fs.writeFileSync(basePath, "foreign");
|
|
const zipPath = path.join(root, "unsafe.zip");
|
|
const zip = new AdmZip();
|
|
zip.addFile(entryName, Buffer.from("package"));
|
|
zip.writeZip(zipPath);
|
|
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
|
|
const classPath = [
|
|
path.join(runtimeRoot, "classes"),
|
|
path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"),
|
|
path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"),
|
|
path.join(runtimeRoot, "lib", "zip4j.jar")
|
|
].join(path.delimiter);
|
|
|
|
const run = spawnSync("java", [
|
|
"-cp",
|
|
classPath,
|
|
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
|
"--archive",
|
|
zipPath,
|
|
"--target",
|
|
targetDir,
|
|
"--conflict",
|
|
"overwrite",
|
|
"--backend",
|
|
backend
|
|
], { encoding: "utf8" });
|
|
|
|
expect(run.status).not.toBe(0);
|
|
expect(fs.readFileSync(basePath, "utf8")).toBe("foreign");
|
|
});
|
|
|
|
it("reconciles a real aborted JVM opened output to partial or removed", async () => {
|
|
process.env.RD_EXTRACT_BACKEND = "jvm";
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-abort-output-"));
|
|
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.bin", Buffer.alloc(8 * 1024 * 1024, 7));
|
|
zip.writeZip(path.join(packageDir, "large.zip"));
|
|
const controller = new AbortController();
|
|
const events: import("../src/main/extractor").ExtractOutputEvent[] = [];
|
|
|
|
const extraction = extractPackageArchives({
|
|
packageDir,
|
|
targetDir,
|
|
cleanupMode: "none",
|
|
conflictMode: "overwrite",
|
|
removeLinks: false,
|
|
removeSamples: false,
|
|
signal: controller.signal,
|
|
onOutput: (event) => {
|
|
events.push(event);
|
|
if (event.state === "opened") {
|
|
controller.abort();
|
|
}
|
|
}
|
|
});
|
|
|
|
await expect(extraction).rejects.toThrow("aborted:extract");
|
|
expect(events[0]?.state).toBe("opened");
|
|
expect(["partial", "removed"]).toContain(events[events.length - 1]?.state);
|
|
const outputPath = path.join(targetDir, "episode.bin");
|
|
if (events[events.length - 1]?.state === "partial") {
|
|
expect(fs.statSync(outputPath).isFile()).toBe(true);
|
|
} else {
|
|
expect(fs.existsSync(outputPath)).toBe(false);
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
}, 10000);
|
|
|
|
it.each(["7zjbinding", "zip4j"])("preflights every %s entry before overwriting an earlier safe target", (backend) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-full-preflight-${backend}-`));
|
|
tempDirs.push(root);
|
|
const targetDir = path.join(root, "out");
|
|
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 zipPath = path.join(root, "preflight.zip");
|
|
const zip = new AdmZip();
|
|
zip.addFile("00-safe.txt", Buffer.from("package-safe"));
|
|
zip.addFile("zz-name.", Buffer.from("package-invalid"));
|
|
zip.writeZip(zipPath);
|
|
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
|
|
const classPath = [
|
|
path.join(runtimeRoot, "classes"),
|
|
path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"),
|
|
path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"),
|
|
path.join(runtimeRoot, "lib", "zip4j.jar")
|
|
].join(path.delimiter);
|
|
|
|
const run = spawnSync("java", [
|
|
"-cp",
|
|
classPath,
|
|
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
|
"--archive",
|
|
zipPath,
|
|
"--target",
|
|
targetDir,
|
|
"--conflict",
|
|
"overwrite",
|
|
"--backend",
|
|
backend
|
|
], { encoding: "utf8" });
|
|
|
|
expect(run.status).not.toBe(0);
|
|
expect(fs.readFileSync(safePath, "utf8")).toBe("foreign-safe");
|
|
expect(fs.readFileSync(aliasPath, "utf8")).toBe("foreign-alias");
|
|
});
|
|
|
|
it.each(["7zjbinding", "zip4j"].flatMap((backend) => rawPlanConflictModes.flatMap((conflictMode) => jvmTargetCollisionCases.map(([label, entries]) => [backend, conflictMode, label, entries] as const))))(
|
|
"rejects %s %s %s before any target mutation",
|
|
(backend, conflictMode, _label, entries) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-target-plan-${backend}-`));
|
|
tempDirs.push(root);
|
|
const targetDir = path.join(root, "out");
|
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
const sentinelPath = path.join(targetDir, "sentinel.txt");
|
|
fs.writeFileSync(sentinelPath, "foreign");
|
|
const before = readTargetTree(targetDir);
|
|
const zipPath = path.join(root, "collision.zip");
|
|
writeZipFixture(zipPath, entries);
|
|
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
|
|
const classPath = [
|
|
path.join(runtimeRoot, "classes"),
|
|
path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"),
|
|
path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"),
|
|
path.join(runtimeRoot, "lib", "zip4j.jar")
|
|
].join(path.delimiter);
|
|
|
|
const run = spawnSync("java", [
|
|
"-cp",
|
|
classPath,
|
|
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
|
"--archive",
|
|
zipPath,
|
|
"--target",
|
|
targetDir,
|
|
"--conflict",
|
|
conflictMode,
|
|
"--backend",
|
|
backend
|
|
], { encoding: "utf8" });
|
|
|
|
expect(run.status).not.toBe(0);
|
|
expect(readTargetTree(targetDir)).toEqual(before);
|
|
}
|
|
);
|
|
|
|
it.each(["7zjbinding", "zip4j"].flatMap((backend) => rawPlanConflictModes.map((conflictMode) => [backend, conflictMode] as const)))("allows safely identical duplicate directory targets in %s with %s", (backend, conflictMode) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-identical-directory-${backend}-`));
|
|
tempDirs.push(root);
|
|
const targetDir = path.join(root, "out");
|
|
const zipPath = path.join(root, "directories.zip");
|
|
writeZipFixture(zipPath, [
|
|
{ name: "same", directory: true },
|
|
{ name: "same", directory: true }
|
|
]);
|
|
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
|
|
const classPath = [
|
|
path.join(runtimeRoot, "classes"),
|
|
path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"),
|
|
path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"),
|
|
path.join(runtimeRoot, "lib", "zip4j.jar")
|
|
].join(path.delimiter);
|
|
|
|
const run = spawnSync("java", [
|
|
"-cp",
|
|
classPath,
|
|
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
|
"--archive",
|
|
zipPath,
|
|
"--target",
|
|
targetDir,
|
|
"--conflict",
|
|
conflictMode,
|
|
"--backend",
|
|
backend
|
|
], { encoding: "utf8" });
|
|
|
|
expect(run.status).toBe(0);
|
|
expect(fs.readdirSync(targetDir)).toEqual(["same"]);
|
|
expect(fs.statSync(path.join(targetDir, "same")).isDirectory()).toBe(true);
|
|
});
|
|
|
|
it.each(["7zjbinding", "zip4j"].flatMap((backend) => rawPlanConflictModes.map((conflictMode) => [backend, conflictMode] as const)))(
|
|
"preserves existing-target %s behavior for %s",
|
|
(backend, conflictMode) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-existing-target-${backend}-${conflictMode}-`));
|
|
tempDirs.push(root);
|
|
const targetDir = path.join(root, "out");
|
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
fs.writeFileSync(path.join(targetDir, "same.txt"), "old");
|
|
const zipPath = path.join(root, "existing-target.zip");
|
|
writeZipFixture(zipPath, [{ name: "same.txt", content: "new" }]);
|
|
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
|
|
const classPath = [
|
|
path.join(runtimeRoot, "classes"),
|
|
path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"),
|
|
path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"),
|
|
path.join(runtimeRoot, "lib", "zip4j.jar")
|
|
].join(path.delimiter);
|
|
|
|
const run = spawnSync("java", [
|
|
"-cp",
|
|
classPath,
|
|
"com.sucukdeluxe.extractor.JBindExtractorMain",
|
|
"--archive",
|
|
zipPath,
|
|
"--target",
|
|
targetDir,
|
|
"--conflict",
|
|
conflictMode,
|
|
"--backend",
|
|
backend
|
|
], { encoding: "utf8" });
|
|
|
|
expect(run.status).toBe(0);
|
|
expect(fs.readFileSync(path.join(targetDir, "same.txt"), "utf8")).toBe(conflictMode === "overwrite" ? "new" : "old");
|
|
if (conflictMode === "rename") {
|
|
expect(fs.readFileSync(path.join(targetDir, "same (1).txt"), "utf8")).toBe("new");
|
|
} else {
|
|
expect(fs.existsSync(path.join(targetDir, "same (1).txt"))).toBe(false);
|
|
}
|
|
}
|
|
);
|
|
|
|
it("emits progress callbacks with archiveName and percent", async () => {
|
|
process.env.RD_EXTRACT_BACKEND = "jvm";
|
|
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-progress-"));
|
|
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, "progress-test.zip");
|
|
const zip = new AdmZip();
|
|
zip.addFile("file1.txt", Buffer.from("Hello World ".repeat(100)));
|
|
zip.addFile("file2.txt", Buffer.from("Another file ".repeat(100)));
|
|
zip.writeZip(zipPath);
|
|
|
|
const progressUpdates: Array<{
|
|
archiveName: string;
|
|
percent: number;
|
|
phase: string;
|
|
archivePercent?: number;
|
|
}> = [];
|
|
|
|
const result = await extractPackageArchives({
|
|
packageDir,
|
|
targetDir,
|
|
cleanupMode: "none",
|
|
conflictMode: "overwrite",
|
|
removeLinks: false,
|
|
removeSamples: false,
|
|
onProgress: (update) => {
|
|
progressUpdates.push({
|
|
archiveName: update.archiveName,
|
|
percent: update.percent,
|
|
phase: update.phase,
|
|
archivePercent: update.archivePercent,
|
|
});
|
|
},
|
|
});
|
|
|
|
expect(result.extracted).toBe(1);
|
|
expect(result.failed).toBe(0);
|
|
|
|
const phases = new Set(progressUpdates.map((u) => u.phase));
|
|
expect(phases.has("preparing")).toBe(true);
|
|
expect(phases.has("extracting")).toBe(true);
|
|
|
|
const extracting = progressUpdates.filter((u) => u.phase === "extracting" && u.archiveName === "progress-test.zip");
|
|
expect(extracting.length).toBeGreaterThan(0);
|
|
|
|
const lastExtracting = extracting[extracting.length - 1];
|
|
expect(lastExtracting.archivePercent).toBe(100);
|
|
|
|
expect(fs.existsSync(path.join(targetDir, "file1.txt"))).toBe(true);
|
|
expect(fs.existsSync(path.join(targetDir, "file2.txt"))).toBe(true);
|
|
});
|
|
|
|
it("extracts multiple archives sequentially with progress for each", async () => {
|
|
process.env.RD_EXTRACT_BACKEND = "jvm";
|
|
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-multi-"));
|
|
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("episode01.txt", Buffer.from("ep1 content"));
|
|
zip1.writeZip(path.join(packageDir, "archive1.zip"));
|
|
|
|
const zip2 = new AdmZip();
|
|
zip2.addFile("episode02.txt", Buffer.from("ep2 content"));
|
|
zip2.writeZip(path.join(packageDir, "archive2.zip"));
|
|
|
|
const archiveNames = new Set<string>();
|
|
|
|
const result = await extractPackageArchives({
|
|
packageDir,
|
|
targetDir,
|
|
cleanupMode: "none",
|
|
conflictMode: "overwrite",
|
|
removeLinks: false,
|
|
removeSamples: false,
|
|
onProgress: (update) => {
|
|
if (update.phase === "extracting" && update.archiveName) {
|
|
archiveNames.add(update.archiveName);
|
|
}
|
|
},
|
|
});
|
|
|
|
expect(result.extracted).toBe(2);
|
|
expect(result.failed).toBe(0);
|
|
expect(archiveNames.has("archive1.zip")).toBe(true);
|
|
expect(archiveNames.has("archive2.zip")).toBe(true);
|
|
expect(fs.existsSync(path.join(targetDir, "episode01.txt"))).toBe(true);
|
|
expect(fs.existsSync(path.join(targetDir, "episode02.txt"))).toBe(true);
|
|
});
|
|
|
|
it("respects ask/skip conflict mode in jvm backend", async () => {
|
|
process.env.RD_EXTRACT_BACKEND = "jvm";
|
|
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-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");
|
|
});
|
|
});
|