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 775b7f4..750b665 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 e51f9c4..041ec55 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 87f5ffd..1276d62 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 e162c6d..40c0181 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 01a4c08..7797e1e 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 b682b5f..680b937 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 037d8a7..fd3441f 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$RawArchivePlanEntry.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanEntry.class new file mode 100644 index 0000000..4d195dc Binary files /dev/null and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanEntry.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanInvariant.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanInvariant.class new file mode 100644 index 0000000..d9d0e5e Binary files /dev/null and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanInvariant.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanNode.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanNode.class new file mode 100644 index 0000000..197684c Binary files /dev/null and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$RawArchivePlanNode.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 654938a..ed167b3 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 0d105b0..0762d88 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 index 8da7bd2..e8d7757 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$TargetPlanInvariant.class 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 index c9c72de..4f2b161 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$TargetPlanNode.class 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 e130b7d..a7ec7a5 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 9b637d0..e5e2ec3 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 cafb013..f30094c 100644 --- a/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java +++ b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java @@ -34,9 +34,10 @@ import java.nio.file.Path; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.Base64; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -246,12 +247,26 @@ public final class JBindExtractorMain { } List fileHeaders = zipFile.getFileHeaders(); - if (fileHeaders == null) { - fileHeaders = new ArrayList(); - } - - long totalUnits = 0; - boolean encrypted = false; + if (fileHeaders == null) { + fileHeaders = new ArrayList(); + } + + RawArchivePlanInvariant rawPlan = new RawArchivePlanInvariant(); + for (FileHeader header : fileHeaders) { + if (header == null) { + continue; + } + String entryName = normalizeEntryName(header.getFileName(), "file"); + if (header.isDirectory()) { + resolveDirectory(request.targetDir, entryName); + } else { + secureResolve(request.targetDir, entryName); + } + rawPlan.add(entryName, header.isDirectory()); + } + + long totalUnits = 0; + boolean encrypted = false; for (FileHeader header : fileHeaders) { if (header == null || header.isDirectory()) { continue; @@ -259,43 +274,47 @@ public final class JBindExtractorMain { encrypted = encrypted || header.isEncrypted(); totalUnits += safeSize(header.getUncompressedSize()); } - ProgressTracker progress = new ProgressTracker(totalUnits); - progress.emitStart(); - - Set preflightReserved = new HashSet(); + Set reserved = new HashSet(); TargetPlanInvariant targetPlan = new TargetPlanInvariant(); + Map plannedEntryNames = new IdentityHashMap(); + Map plannedDirectories = new IdentityHashMap(); + Map plannedOutputs = new IdentityHashMap(); for (FileHeader header : fileHeaders) { if (header == null) { continue; } String entryName = normalizeEntryName(header.getFileName(), "file"); + plannedEntryNames.put(header, entryName); if (header.isDirectory()) { File dir = resolveDirectory(request.targetDir, entryName); targetPlan.add(dir, true); - preflightReserved.add(pathKey(dir)); + reserved.add(pathKey(dir)); + plannedDirectories.put(header, dir); } else { - OutputTarget outputTarget = resolveOutputFile(request.targetDir, entryName, request.conflictMode, preflightReserved); + OutputTarget outputTarget = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved); targetPlan.add(outputTarget.reportedFile, false); + plannedOutputs.put(header, outputTarget); } } - Set reserved = new HashSet(); - for (FileHeader header : fileHeaders) { - if (header == null) { - continue; - } - - String entryName = normalizeEntryName(header.getFileName(), "file"); + ProgressTracker progress = new ProgressTracker(totalUnits); + progress.emitStart(); + + for (FileHeader header : fileHeaders) { + if (header == null) { + continue; + } + + String entryName = plannedEntryNames.get(header); if (header.isDirectory()) { - File dir = resolveDirectory(request.targetDir, entryName); + File dir = plannedDirectories.get(header); ensureDirectory(dir); rejectLinkedPath(request.targetDir, dir); - reserved.add(pathKey(dir)); - continue; - } - - long itemUnits = safeSize(header.getUncompressedSize()); - OutputTarget outputTarget = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved); + continue; + } + + long itemUnits = safeSize(header.getUncompressedSize()); + OutputTarget outputTarget = plannedOutputs.get(header); File output = outputTarget.file; if (output == null) { emitOutput(request.archiveFile, entryName, outputTarget.reportedFile, "complete", outputTarget.disposition); @@ -387,26 +406,43 @@ public final class JBindExtractorMain { try { context = openSevenZipArchive(request.archiveFile, password); IInArchive archive = context.archive; - int itemCount = archive.getNumberOfItems(); + int itemCount = archive.getNumberOfItems(); if (itemCount <= 0) { throw new IOException("Archiv enthalt keine Eintrage oder konnte nicht gelesen werden: " + request.archiveFile.getAbsolutePath()); } - long totalUnits = 0; - boolean encrypted = false; + List rawEntryNames = new ArrayList(); + List rawEntryDirectories = new ArrayList(); + RawArchivePlanInvariant rawPlan = new RawArchivePlanInvariant(); + for (int i = 0; i < itemCount; i++) { + Boolean isFolder = (Boolean) archive.getProperty(i, PropID.IS_FOLDER); + String entryPath = (String) archive.getProperty(i, PropID.PATH); + String entryName = normalizeEntryName(entryPath, "item-" + i); + if (Boolean.TRUE.equals(isFolder)) { + resolveDirectory(request.targetDir, entryName); + } else { + secureResolve(request.targetDir, entryName); + } + rawPlan.add(entryName, Boolean.TRUE.equals(isFolder)); + rawEntryNames.add(entryName); + rawEntryDirectories.add(Boolean.valueOf(Boolean.TRUE.equals(isFolder))); + } + + long totalUnits = 0; + boolean encrypted = false; List fileIndices = new ArrayList(); List outputFiles = new ArrayList(); + List reportedFiles = new ArrayList(); List fileSizes = new ArrayList(); List entryNames = new ArrayList(); List dispositions = new ArrayList(); List outputDirectories = new ArrayList(); Set reserved = new HashSet(); TargetPlanInvariant targetPlan = new TargetPlanInvariant(); - - for (int i = 0; i < itemCount; i++) { - Boolean isFolder = (Boolean) archive.getProperty(i, PropID.IS_FOLDER); - String entryPath = (String) archive.getProperty(i, PropID.PATH); - String entryName = normalizeEntryName(entryPath, "item-" + i); + + for (int i = 0; i < itemCount; i++) { + Boolean isFolder = rawEntryDirectories.get(i); + String entryName = rawEntryNames.get(i); if (Boolean.TRUE.equals(isFolder)) { File dir = resolveDirectory(request.targetDir, entryName); @@ -430,16 +466,20 @@ public final class JBindExtractorMain { 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); - } fileIndices.add(i); outputFiles.add(output); + reportedFiles.add(outputTarget.reportedFile); fileSizes.add(itemSize); entryNames.add(entryName); dispositions.add(outputTarget.disposition); } + for (int i = 0; i < outputFiles.size(); i++) { + if (outputFiles.get(i) == null) { + emitOutput(request.archiveFile, entryNames.get(i), reportedFiles.get(i), "complete", dispositions.get(i)); + } + } + for (File directory : outputDirectories) { ensureDirectory(directory); rejectLinkedPath(request.targetDir, directory); @@ -811,6 +851,73 @@ public final class JBindExtractorMain { return value; } + private static final class RawArchivePlanInvariant { + private final RawArchivePlanNode root = new RawArchivePlanNode(""); + + void add(String relativeTarget, boolean directory) throws IOException { + String normalizedSpelling = relativeTarget == null ? "" : relativeTarget.replace('\\', '/'); + while (normalizedSpelling.endsWith("/")) { + normalizedSpelling = normalizedSpelling.substring(0, normalizedSpelling.length() - 1); + } + if (normalizedSpelling.length() == 0) { + throw new IOException("Raw-Archivplan-Kollision: leeres Ziel"); + } + String[] segments = normalizedSpelling.split("/", -1); + StringBuilder canonicalKey = new StringBuilder(); + RawArchivePlanNode node = root; + for (String segment : segments) { + if (node.entry != null && !node.entry.directory) { + throw new IOException("Raw-Archivplan-Kollision: Datei ist Vorfahr von " + normalizedSpelling); + } + String canonicalSegment = segment.toLowerCase(Locale.ROOT); + if (canonicalKey.length() > 0) { + canonicalKey.append('/'); + } + canonicalKey.append(canonicalSegment); + RawArchivePlanNode child = node.children.get(canonicalSegment); + if (child == null) { + child = new RawArchivePlanNode(segment); + node.children.put(canonicalSegment, child); + } else if (!child.segmentSpelling.equals(segment)) { + throw new IOException("Raw-Archivplan-Kollision: Windows-Case-Alias " + normalizedSpelling); + } + node = child; + } + if (node.entry != null) { + if (node.entry.directory && directory && node.entry.normalizedSpelling.equals(normalizedSpelling)) { + return; + } + throw new IOException("Raw-Archivplan-Kollision: mehrfaches oder typwidriges Ziel " + node.entry.canonicalWindowsKey); + } + if (!directory && !node.children.isEmpty()) { + throw new IOException("Raw-Archivplan-Kollision: Datei ist Vorfahr eines anderen Ziels " + normalizedSpelling); + } + node.entry = new RawArchivePlanEntry(canonicalKey.toString(), normalizedSpelling, directory); + } + } + + private static final class RawArchivePlanNode { + private final String segmentSpelling; + private final Map children = new HashMap(); + private RawArchivePlanEntry entry; + + private RawArchivePlanNode(String segmentSpelling) { + this.segmentSpelling = segmentSpelling; + } + } + + private static final class RawArchivePlanEntry { + private final String canonicalWindowsKey; + private final String normalizedSpelling; + private final boolean directory; + + private RawArchivePlanEntry(String canonicalWindowsKey, String normalizedSpelling, boolean directory) { + this.canonicalWindowsKey = canonicalWindowsKey; + this.normalizedSpelling = normalizedSpelling; + this.directory = directory; + } + } + private static final class TargetPlanInvariant { private final TargetPlanNode root = new TargetPlanNode(); diff --git a/src/main/extractor.ts b/src/main/extractor.ts index 91c68fe..3b601ea 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -297,6 +297,58 @@ export function pathSetKey(filePath: string): string { type TargetPlanKind = "file" | "directory"; +type RawArchivePlanEntry = { + canonicalWindowsKey: string; + normalizedSpelling: string; + kind: TargetPlanKind; +}; + +type RawArchivePlanNode = { + segmentSpelling: string; + entry?: RawArchivePlanEntry; + children: Map; +}; + +class RawArchivePlanInvariant { + private readonly root: RawArchivePlanNode = { segmentSpelling: "", children: new Map() }; + + public add(relativeTarget: string, kind: TargetPlanKind): void { + const normalizedSpelling = String(relativeTarget || "").replace(/\\/g, "/").replace(/\/+$/, ""); + const segments = normalizedSpelling.split("/").filter(Boolean); + if (segments.length === 0) { + throw new Error("Raw-Archivplan-Kollision: leeres Ziel"); + } + let node = this.root; + const canonicalSegments: string[] = []; + for (const segment of segments) { + if (node.entry?.kind === "file") { + throw new Error(`Raw-Archivplan-Kollision: Datei ist Vorfahr von ${normalizedSpelling}`); + } + const canonicalSegment = segment.toLowerCase(); + canonicalSegments.push(canonicalSegment); + let child = node.children.get(canonicalSegment); + if (!child) { + child = { segmentSpelling: segment, children: new Map() }; + node.children.set(canonicalSegment, child); + } else if (child.segmentSpelling !== segment) { + throw new Error(`Raw-Archivplan-Kollision: Windows-Case-Alias ${normalizedSpelling}`); + } + node = child; + } + const canonicalWindowsKey = canonicalSegments.join("/"); + if (node.entry) { + if (node.entry.kind === "directory" && kind === "directory" && node.entry.normalizedSpelling === normalizedSpelling) { + return; + } + throw new Error(`Raw-Archivplan-Kollision: mehrfaches oder typwidriges Ziel ${node.entry.canonicalWindowsKey}`); + } + if (kind === "file" && node.children.size > 0) { + throw new Error(`Raw-Archivplan-Kollision: Datei ist Vorfahr eines anderen Ziels ${normalizedSpelling}`); + } + node.entry = { canonicalWindowsKey, normalizedSpelling, kind }; + } +} + type TargetPlanNode = { kind?: TargetPlanKind; children: Map; @@ -2348,7 +2400,7 @@ export function validateNativeArchiveEntryCandidates(entries: readonly string[], function validateNativeArchiveTargetPlan(entries: readonly NativeArchiveEntryCandidate[], targetDir: string): void { const scope = new PackageOutputScope([targetDir]); - const targetPlan = new TargetPlanInvariant(); + const rawPlan = new RawArchivePlanInvariant(); for (const candidate of entries) { const entryPath = String(candidate.entryPath || "").replace(/\\/g, "/").replace(/\/$/, ""); if (!entryPath) { @@ -2356,7 +2408,7 @@ function validateNativeArchiveTargetPlan(entries: readonly NativeArchiveEntryCan } const outputPath = path.resolve(targetDir, ...entryPath.split("/")); scope.validateTarget(entryPath, outputPath); - targetPlan.add(outputPath, candidate.isDirectory ? "directory" : "file"); + rawPlan.add(entryPath, candidate.isDirectory ? "directory" : "file"); } } @@ -3058,8 +3110,23 @@ async function extractZipArchive( const mode = effectiveConflictMode(conflictMode); const memoryLimitBytes = zipEntryMemoryLimitBytes(); const zip = new AdmZip(archivePath); - const entries = zip.getEntries(); - const resolvedTarget = path.resolve(targetDir); + const entries = zip.getEntries(); + const resolvedTarget = path.resolve(targetDir); + const rawPlan = new RawArchivePlanInvariant(); + + for (const entry of entries) { + if (signal?.aborted) { + throw new Error("aborted:extract"); + } + const baseOutputPath = path.resolve(targetDir, entry.entryName); + if (!baseOutputPath.startsWith(resolvedTarget + path.sep) && baseOutputPath !== resolvedTarget) { + throw new Error(`ZIP-Eintrag Path Traversal blockiert: ${entry.entryName}`); + } + const entryPath = entry.entryName.replace(/\\/g, "/").replace(/\/$/, ""); + validateTarget?.(entryPath || "directory", baseOutputPath); + rawPlan.add(entryPath, entry.isDirectory ? "directory" : "file"); + } + const plannedOutputs = new Set(); const targetPlan = new TargetPlanInvariant(); const renameCounters = new Map(); diff --git a/tests/extractor-jvm.test.ts b/tests/extractor-jvm.test.ts index cc620d0..89751d8 100644 --- a/tests/extractor-jvm.test.ts +++ b/tests/extractor-jvm.test.ts @@ -13,6 +13,27 @@ 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; @@ -47,9 +68,15 @@ const jvmTargetCollisionCases = [ ["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" }]] + ["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" }); @@ -444,15 +471,16 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b 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) => { + 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"); @@ -472,18 +500,17 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b "--target", targetDir, "--conflict", - "overwrite", + conflictMode, "--backend", backend ], { encoding: "utf8" }); expect(run.status).not.toBe(0); - expect(fs.readdirSync(targetDir)).toEqual(["sentinel.txt"]); - expect(fs.readFileSync(sentinelPath, "utf8")).toBe("foreign"); + expect(readTargetTree(targetDir)).toEqual(before); } ); - it.each(["7zjbinding", "zip4j"])("allows safely identical duplicate directory targets in %s", (backend) => { + 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"); @@ -509,7 +536,7 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b "--target", targetDir, "--conflict", - "overwrite", + conflictMode, "--backend", backend ], { encoding: "utf8" }); @@ -518,8 +545,50 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b 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 () => { + + 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-")); diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index d32dd7d..e34b3ce 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -34,6 +34,27 @@ 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; @@ -68,9 +89,15 @@ const archiveTargetCollisionCases = [ ["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" }]] + ["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; beforeEach(() => { process.env.RD_EXTRACT_BACKEND = "legacy"; @@ -1741,7 +1768,7 @@ describe("extractor", () => { expect(fs.readFileSync(aliasPath, "utf8")).toBe("foreign-alias"); }); - it.each(archiveTargetCollisionCases)("rejects internal ZIP %s before any target mutation", async (_label, entries) => { + it.each(rawPlanConflictModes.flatMap((conflictMode) => archiveTargetCollisionCases.map(([label, entries]) => [conflictMode, label, entries] as const)))("rejects internal ZIP %s %s before any target mutation", async (conflictMode, _label, entries) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-zip-target-plan-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); @@ -1750,34 +1777,36 @@ describe("extractor", () => { fs.mkdirSync(targetDir, { recursive: true }); const sentinelPath = path.join(targetDir, "sentinel.txt"); fs.writeFileSync(sentinelPath, "foreign"); + const before = readTargetTree(targetDir); writeZipFixture(path.join(packageDir, "collision.zip"), entries); const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", - conflictMode: "overwrite", + conflictMode, 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"); + expect(readTargetTree(targetDir)).toEqual(before); }); - it.each(archiveTargetCollisionCases)("rejects native preflight %s", (_label, entries) => { + it.each(rawPlanConflictModes.flatMap((conflictMode) => archiveTargetCollisionCases.map(([label, entries]) => [conflictMode, label, entries] as const)))("rejects native preflight before %s conflict handling for %s", (_conflictMode, _label, entries) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-target-plan-")); tempDirs.push(root); const targetDir = path.join(root, "out"); fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync(path.join(targetDir, "sentinel.bin"), Buffer.from([0, 1, 2, 255])); + const before = readTargetTree(targetDir); const candidates = entries.map((entry) => "directory" in entry && entry.directory ? `${entry.name}/` : entry.name); expect(() => validateNativeArchiveEntryCandidates(candidates, targetDir)).toThrow(/target|ziel|kollision/i); - expect(fs.readdirSync(targetDir)).toEqual([]); + expect(readTargetTree(targetDir)).toEqual(before); }); - it("allows safely identical duplicate directory targets", async () => { + it.each(rawPlanConflictModes)("allows safely identical duplicate directory targets before %s conflict handling", async (conflictMode) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-identical-directory-plan-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); @@ -1793,7 +1822,7 @@ describe("extractor", () => { packageDir, targetDir, cleanupMode: "none", - conflictMode: "overwrite", + conflictMode, removeLinks: false, removeSamples: false });