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) => { setEntry: (entry: unknown) => void; compressToBuffer: () => Buffer; }; const ZipEntry = require("adm-zip/zipEntry") as new (options: Record) => { 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; 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 %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) => 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(); 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"); }); });