diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class index eac6eac..775b7f4 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback$1.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback$1.class index 6e2d9e5..e51f9c4 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback$1.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback$1.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback.class index 67f8bec..87f5ffd 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class index c5ca777..e162c6d 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class index d710ce1..01a4c08 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$OutputTarget.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$OutputTarget.class index 4494fee..b682b5f 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$OutputTarget.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$OutputTarget.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class index f146309..037d8a7 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class index 0e095f6..654938a 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class index 29bffbd..0d105b0 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$TargetPlanInvariant.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$TargetPlanInvariant.class new file mode 100644 index 0000000..8da7bd2 Binary files /dev/null and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$TargetPlanInvariant.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$TargetPlanNode.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$TargetPlanNode.class new file mode 100644 index 0000000..c9c72de Binary files /dev/null and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$TargetPlanNode.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class index 6d0f56e..e130b7d 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class index 978c9ca..9b637d0 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class differ diff --git a/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java index 6982c05..cafb013 100644 --- a/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java +++ b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java @@ -263,6 +263,7 @@ public final class JBindExtractorMain { progress.emitStart(); Set preflightReserved = new HashSet(); + TargetPlanInvariant targetPlan = new TargetPlanInvariant(); for (FileHeader header : fileHeaders) { if (header == null) { continue; @@ -270,9 +271,11 @@ public final class JBindExtractorMain { String entryName = normalizeEntryName(header.getFileName(), "file"); if (header.isDirectory()) { File dir = resolveDirectory(request.targetDir, entryName); + targetPlan.add(dir, true); preflightReserved.add(pathKey(dir)); } else { - resolveOutputFile(request.targetDir, entryName, request.conflictMode, preflightReserved); + OutputTarget outputTarget = resolveOutputFile(request.targetDir, entryName, request.conflictMode, preflightReserved); + targetPlan.add(outputTarget.reportedFile, false); } } @@ -397,7 +400,8 @@ public final class JBindExtractorMain { List entryNames = new ArrayList(); List dispositions = new ArrayList(); List outputDirectories = new ArrayList(); - Set reserved = new HashSet(); + Set reserved = new HashSet(); + TargetPlanInvariant targetPlan = new TargetPlanInvariant(); for (int i = 0; i < itemCount; i++) { Boolean isFolder = (Boolean) archive.getProperty(i, PropID.IS_FOLDER); @@ -406,6 +410,7 @@ public final class JBindExtractorMain { if (Boolean.TRUE.equals(isFolder)) { File dir = resolveDirectory(request.targetDir, entryName); + targetPlan.add(dir, true); outputDirectories.add(dir); reserved.add(pathKey(dir)); continue; @@ -423,6 +428,7 @@ public final class JBindExtractorMain { totalUnits += itemSize; OutputTarget outputTarget = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved); + targetPlan.add(outputTarget.reportedFile, false); File output = outputTarget.file; if (output == null) { emitOutput(request.archiveFile, entryName, outputTarget.reportedFile, "complete", outputTarget.disposition); @@ -797,13 +803,54 @@ public final class JBindExtractorMain { } } - private static String pathKey(File file) { - String value = file.getAbsolutePath(); - if (isWindows()) { - value = value.toLowerCase(Locale.ROOT); - } - return value; - } + private static String pathKey(File file) { + String value = file.toPath().toAbsolutePath().normalize().toString(); + if (isWindows()) { + value = value.toLowerCase(Locale.ROOT); + } + return value; + } + + private static final class TargetPlanInvariant { + private final TargetPlanNode root = new TargetPlanNode(); + + void add(File file, boolean directory) throws IOException { + String key = pathKey(file).replace('\\', '/'); + String[] segments = key.split("/"); + TargetPlanNode node = root; + for (String segment : segments) { + if (segment.length() == 0) { + continue; + } + if (node.file) { + throw new IOException("Target-Plan-Kollision: Datei ist Vorfahr von " + file.getAbsolutePath()); + } + TargetPlanNode child = node.children.get(segment); + if (child == null) { + child = new TargetPlanNode(); + node.children.put(segment, child); + } + node = child; + } + if (node.file || node.directory) { + if (directory && node.directory && !node.file) { + return; + } + throw new IOException("Target-Plan-Kollision: mehrfaches oder typwidriges Ziel " + file.getAbsolutePath()); + } + if (!directory && !node.children.isEmpty()) { + throw new IOException("Target-Plan-Kollision: Datei ist Vorfahr eines anderen Ziels " + file.getAbsolutePath()); + } + node.directory = directory; + node.file = !directory; + } + } + + private static final class TargetPlanNode { + private final Map children = new HashMap(); + private boolean file; + private boolean directory; + } private static boolean isWindows() { String osName = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); diff --git a/src/main/extractor.ts b/src/main/extractor.ts index c0b3a52..91c68fe 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -291,9 +291,47 @@ export async function detectArchiveSignature(filePath: string): Promise; +}; + +class TargetPlanInvariant { + private readonly root: TargetPlanNode = { children: new Map() }; + + public add(outputPath: string, kind: TargetPlanKind): void { + const key = pathSetKey(path.resolve(outputPath)).replace(/\\/g, "/"); + const segments = key.split("/").filter(Boolean); + let node = this.root; + for (const segment of segments) { + if (node.kind === "file") { + throw new Error(`Target-Plan-Kollision: Datei ist Vorfahr von ${outputPath}`); + } + let child = node.children.get(segment); + if (!child) { + child = { children: new Map() }; + node.children.set(segment, child); + } + node = child; + } + if (node.kind) { + if (node.kind === "directory" && kind === "directory") { + return; + } + throw new Error(`Target-Plan-Kollision: mehrfaches oder typwidriges Ziel ${outputPath}`); + } + if (kind === "file" && node.children.size > 0) { + throw new Error(`Target-Plan-Kollision: Datei ist Vorfahr eines anderen Ziels ${outputPath}`); + } + node.kind = kind; + } +} function stripDuplicateSuffixBeforeExtension(fileName: string): string { return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, ""); @@ -2255,12 +2293,30 @@ export function buildExternalListArgs(command: string, archivePath: string, pass } export function parseNativeArchiveEntryList(command: string, output: string): string[] { + return parseNativeArchiveEntryCandidates(command, output).map((entry) => ( + entry.isDirectory && !/[\\/]$/.test(entry.entryPath) ? `${entry.entryPath}/` : entry.entryPath + )); +} + +type NativeArchiveEntryCandidate = { entryPath: string; isDirectory: boolean }; + +function parseNativeArchiveEntryCandidates(command: string, output: string): NativeArchiveEntryCandidate[] { const lines = String(output || "").split(/\r?\n/); if (isRarNativeCommand(command)) { - return lines.filter((line) => line.length > 0); + return lines.filter((line) => line.length > 0).map((entryPath) => ({ + entryPath, + isDirectory: /[\\/]$/.test(entryPath) + })); } - const entries: string[] = []; + const entries: NativeArchiveEntryCandidate[] = []; let inEntries = false; + let current: NativeArchiveEntryCandidate | null = null; + const commitCurrent = (): void => { + if (current) { + entries.push(current); + current = null; + } + }; for (const line of lines) { if (/^-{8,}\s*$/.test(line.trim())) { inEntries = true; @@ -2271,21 +2327,36 @@ export function parseNativeArchiveEntryList(command: string, output: string): st } const match = line.match(/^Path = (.*)$/); if (match?.[1]) { - entries.push(match[1]); + commitCurrent(); + current = { entryPath: match[1], isDirectory: /[\\/]$/.test(match[1]) }; + continue; + } + if (current && /^Folder = \+\s*$/.test(line)) { + current.isDirectory = true; } } + commitCurrent(); return entries; } export function validateNativeArchiveEntryCandidates(entries: readonly string[], targetDir: string): void { + validateNativeArchiveTargetPlan(entries.map((entryPath) => ({ + entryPath, + isDirectory: /[\\/]$/.test(entryPath) + })), targetDir); +} + +function validateNativeArchiveTargetPlan(entries: readonly NativeArchiveEntryCandidate[], targetDir: string): void { const scope = new PackageOutputScope([targetDir]); - for (const rawEntry of entries) { - const entryPath = String(rawEntry || "").replace(/\\/g, "/").replace(/\/$/, ""); + const targetPlan = new TargetPlanInvariant(); + for (const candidate of entries) { + const entryPath = String(candidate.entryPath || "").replace(/\\/g, "/").replace(/\/$/, ""); if (!entryPath) { continue; } const outputPath = path.resolve(targetDir, ...entryPath.split("/")); scope.validateTarget(entryPath, outputPath); + targetPlan.add(outputPath, candidate.isDirectory ? "directory" : "file"); } } @@ -2359,11 +2430,11 @@ async function runNativeEntryPreflight( return result; } try { - const entries = parseNativeArchiveEntryList(command, chunks.join("")); + const entries = parseNativeArchiveEntryCandidates(command, chunks.join("")); if (entries.length === 0) { throw new Error("Native Archivliste enthält keine validierbaren Einträge"); } - validateNativeArchiveEntryCandidates(entries, targetDir); + validateNativeArchiveTargetPlan(entries, targetDir); return result; } catch (error) { return { @@ -2943,6 +3014,7 @@ function isZipSafetyGuardError(error: unknown): boolean { || text.includes("zip-eintrag verdaechtig gross") || text.includes("symbolischer link") || text.includes("reparse point") + || text.includes("target-plan-kollision") || text.includes("extract_output_callback_failed"); } @@ -2989,6 +3061,7 @@ async function extractZipArchive( const entries = zip.getEntries(); const resolvedTarget = path.resolve(targetDir); const plannedOutputs = new Set(); + const targetPlan = new TargetPlanInvariant(); const renameCounters = new Map(); const directoryPlans: Array<{ entryPath: string; outputPath: string }> = []; const filePlans: Array<{ @@ -3012,6 +3085,8 @@ async function extractZipArchive( if (entry.isDirectory) { const entryPath = entry.entryName.replace(/\\/g, "/").replace(/\/$/, "") || "directory"; validateTarget?.(entryPath, baseOutputPath); + targetPlan.add(baseOutputPath, "directory"); + plannedOutputs.add(pathSetKey(baseOutputPath)); directoryPlans.push({ entryPath, outputPath: baseOutputPath }); continue; } @@ -3058,6 +3133,7 @@ async function extractZipArchive( if (mode === "skip") { const entryPath = entry.entryName.replace(/\\/g, "/"); validateTarget?.(entryPath, baseOutputPath); + targetPlan.add(baseOutputPath, "file"); filePlans.push({ entry, entryPath, @@ -3100,6 +3176,7 @@ async function extractZipArchive( const normalizedEntryPath = entry.entryName.replace(/\\/g, "/"); validateTarget?.(normalizedEntryPath, outputPath); + targetPlan.add(outputPath, "file"); plannedOutputs.add(outputKey); filePlans.push({ entry, diff --git a/tests/extractor-jvm.test.ts b/tests/extractor-jvm.test.ts index 5b93d88..cc620d0 100644 --- a/tests/extractor-jvm.test.ts +++ b/tests/extractor-jvm.test.ts @@ -1,4 +1,5 @@ -import fs from "node:fs"; +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"; @@ -7,7 +8,48 @@ import { afterEach, describe, expect, it } from "vitest"; import { extractPackageArchives } from "../src/main/extractor"; const tempDirs: string[] = []; -const originalBackend = process.env.RD_EXTRACT_BACKEND; +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) => { + 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" }]], + ["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; function hasJavaRuntime(): boolean { const result = spawnSync("java", ["-version"], { stdio: "ignore" }); @@ -401,6 +443,81 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b 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"; diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index 06dd718..d32dd7d 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -1,5 +1,6 @@ -import fs from "node:fs"; -import os from "node:os"; +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 } from "vitest"; @@ -29,6 +30,47 @@ import { 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 }; + +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 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" }]], + ["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; beforeEach(() => { process.env.RD_EXTRACT_BACKEND = "legacy"; @@ -1659,6 +1701,13 @@ describe("extractor", () => { "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 () => { @@ -1692,6 +1741,69 @@ describe("extractor", () => { expect(fs.readFileSync(aliasPath, "utf8")).toBe("foreign-alias"); }); + it.each(archiveTargetCollisionCases)("rejects internal ZIP %s before any target mutation", async (_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"); + writeZipFixture(path.join(packageDir, "collision.zip"), entries); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 })); + expect(fs.readdirSync(targetDir)).toEqual(["sentinel.txt"]); + expect(fs.readFileSync(sentinelPath, "utf8")).toBe("foreign"); + }); + + it.each(archiveTargetCollisionCases)("rejects native preflight %s", (_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 }); + const candidates = entries.map((entry) => "directory" in entry && entry.directory ? `${entry.name}/` : entry.name); + + expect(() => validateNativeArchiveEntryCandidates(candidates, targetDir)).toThrow(/target|ziel|kollision/i); + expect(fs.readdirSync(targetDir)).toEqual([]); + }); + + it("allows safely identical duplicate directory targets", async () => { + 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: "overwrite", + 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."]);