Build a typed normalized target plan for internal ZIP, native list preflight, Zip4j, and SevenZipJBinding before any output directory or file is mutated. Reject file-directory aliases, file ancestors, case-insensitive aliases, and duplicate file targets while allowing identical directory declarations. Preserve linear archive planning and ship the rebuilt Java runtime classes.
651 lines
24 KiB
TypeScript
651 lines
24 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 };
|
|
|
|
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" }]],
|
|
["case-insensitive file aliases", [{ name: "Name", content: "first" }, { name: "name", content: "second" }]],
|
|
["duplicate file targets", [{ name: "same", content: "first" }, { name: "same", content: "second" }]]
|
|
] as const satisfies ReadonlyArray<readonly [string, readonly ZipFixtureEntry[]]>;
|
|
|
|
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 %s partial output before removing a failed file", (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[0]).toBe("opened");
|
|
expect(states).toContain("partial");
|
|
expect(states[states.length - 1]).toBe("removed");
|
|
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) => jvmTargetCollisionCases.map(([label, entries]) => [backend, label, entries] as const)))(
|
|
"rejects %s %s before any target mutation",
|
|
(backend, _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 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",
|
|
"overwrite",
|
|
"--backend",
|
|
backend
|
|
], { encoding: "utf8" });
|
|
|
|
expect(run.status).not.toBe(0);
|
|
expect(fs.readdirSync(targetDir)).toEqual(["sentinel.txt"]);
|
|
expect(fs.readFileSync(sentinelPath, "utf8")).toBe("foreign");
|
|
}
|
|
);
|
|
|
|
it.each(["7zjbinding", "zip4j"])("allows safely identical duplicate directory targets in %s", (backend) => {
|
|
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",
|
|
"overwrite",
|
|
"--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("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");
|
|
});
|
|
});
|