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 cd1adfd..eac6eac 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 2f0051b..6e2d9e5 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 cb762dc..67f8bec 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 d294ccd..c5ca777 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 0e1aa02..d710ce1 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 24e233e..4494fee 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 e94c4ec..f146309 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 23e29c6..0e095f6 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 82aef6f..29bffbd 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$WrongPasswordException.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class index 2c81688..6d0f56e 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 420ccd3..978c9ca 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 2144cf2..6982c05 100644 --- a/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java +++ b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java @@ -259,10 +259,24 @@ public final class JBindExtractorMain { encrypted = encrypted || header.isEncrypted(); totalUnits += safeSize(header.getUncompressedSize()); } - ProgressTracker progress = new ProgressTracker(totalUnits); - progress.emitStart(); - - Set reserved = new HashSet(); + ProgressTracker progress = new ProgressTracker(totalUnits); + progress.emitStart(); + + Set preflightReserved = new HashSet(); + for (FileHeader header : fileHeaders) { + if (header == null) { + continue; + } + String entryName = normalizeEntryName(header.getFileName(), "file"); + if (header.isDirectory()) { + File dir = resolveDirectory(request.targetDir, entryName); + preflightReserved.add(pathKey(dir)); + } else { + resolveOutputFile(request.targetDir, entryName, request.conflictMode, preflightReserved); + } + } + + Set reserved = new HashSet(); for (FileHeader header : fileHeaders) { if (header == null) { continue; @@ -382,6 +396,7 @@ public final class JBindExtractorMain { List fileSizes = new ArrayList(); List entryNames = new ArrayList(); List dispositions = new ArrayList(); + List outputDirectories = new ArrayList(); Set reserved = new HashSet(); for (int i = 0; i < itemCount; i++) { @@ -391,9 +406,8 @@ public final class JBindExtractorMain { if (Boolean.TRUE.equals(isFolder)) { File dir = resolveDirectory(request.targetDir, entryName); - ensureDirectory(dir); - rejectLinkedPath(request.targetDir, dir); - reserved.add(pathKey(dir)); + outputDirectories.add(dir); + reserved.add(pathKey(dir)); continue; } @@ -418,7 +432,12 @@ public final class JBindExtractorMain { fileSizes.add(itemSize); entryNames.add(entryName); dispositions.add(outputTarget.disposition); - } + } + + for (File directory : outputDirectories) { + ensureDirectory(directory); + rejectLinkedPath(request.targetDir, directory); + } if (fileIndices.isEmpty()) { @@ -605,7 +624,7 @@ public final class JBindExtractorMain { if (conflictMode == ConflictMode.OVERWRITE) { if (base.exists()) { - if (!base.isFile() || !base.delete()) { + if (!base.isFile()) { throw new IOException("Konnte Datei nicht uberschreiben: " + base.getAbsolutePath()); } } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index e83c645..aed2b92 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -4517,16 +4517,12 @@ export class DownloadManager extends EventEmitter { } catch { return false; } - const rawExistingMarker = await this.readPackageOutputOwnerMarker(pkg, false); const nonMarkerEntries = entries.filter((entry) => entry.name !== PACKAGE_OUTPUT_OWNER_MARKER); if (nonMarkerEntries.length > 0) { return false; } if (entries.some((entry) => entry.name === PACKAGE_OUTPUT_OWNER_MARKER)) { - if (!rawExistingMarker || rawExistingMarker.packageId !== pkg.id) { - return false; - } - await fs.promises.rm(this.packageOutputOwnerMarkerPath(pkg), { force: true }); + return false; } const marker: PackageOutputOwnerMarker = { version: 1, diff --git a/src/main/extractor.ts b/src/main/extractor.ts index 38ae4e6..c0b3a52 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -2257,7 +2257,7 @@ export function buildExternalListArgs(command: string, archivePath: string, pass export function parseNativeArchiveEntryList(command: string, output: string): string[] { const lines = String(output || "").split(/\r?\n/); if (isRarNativeCommand(command)) { - return lines.map((line) => line.trim()).filter(Boolean); + return lines.filter((line) => line.length > 0); } const entries: string[] = []; let inEntries = false; @@ -2988,22 +2988,31 @@ async function extractZipArchive( const zip = new AdmZip(archivePath); const entries = zip.getEntries(); const resolvedTarget = path.resolve(targetDir); - const usedOutputs = new Set(); - const renameCounters = new Map(); - - for (const entry of entries) { + const plannedOutputs = new Set(); + const renameCounters = new Map(); + const directoryPlans: Array<{ entryPath: string; outputPath: string }> = []; + const filePlans: Array<{ + entry: (typeof entries)[number]; + entryPath: string; + outputPath: string; + outputKey: string; + disposition: ExtractOutputEvent["disposition"]; + uncompressedSize: number; + compressedSize: number; + }> = []; + + 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) { - logger.warn(`ZIP-Eintrag übersprungen (Path Traversal): ${entry.entryName}`); - continue; - } + 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}`); + } if (entry.isDirectory) { - validateTarget?.(entry.entryName.replace(/\\/g, "/").replace(/\/$/, "") || "directory", baseOutputPath); - await fs.promises.mkdir(baseOutputPath, { recursive: true }); - validateTarget?.(entry.entryName.replace(/\\/g, "/").replace(/\/$/, "") || "directory", baseOutputPath); + const entryPath = entry.entryName.replace(/\\/g, "/").replace(/\/$/, "") || "directory"; + validateTarget?.(entryPath, baseOutputPath); + directoryPlans.push({ entryPath, outputPath: baseOutputPath }); continue; } @@ -3044,16 +3053,19 @@ async function extractZipArchive( let outputKey = pathSetKey(outputPath); let disposition: ExtractOutputEvent["disposition"] = "written"; - const outputExists = usedOutputs.has(outputKey) || await fs.promises.access(outputPath).then(() => true, () => false); + const outputExists = plannedOutputs.has(outputKey) || await fs.promises.access(outputPath).then(() => true, () => false); if (outputExists) { if (mode === "skip") { - onOutput?.({ - version: 1, - archivePath: path.resolve(archivePath), - entryPath: entry.entryName.replace(/\\/g, "/"), + const entryPath = entry.entryName.replace(/\\/g, "/"); + validateTarget?.(entryPath, baseOutputPath); + filePlans.push({ + entry, + entryPath, outputPath: baseOutputPath, - state: "complete", - disposition: "skipped" + outputKey, + disposition: "skipped", + uncompressedSize, + compressedSize }); continue; } @@ -3066,7 +3078,7 @@ async function extractZipArchive( while (n <= 10000) { candidate = path.join(parsed.dir, `${parsed.name} (${n})${parsed.ext}`); candidateKey = pathSetKey(candidate); - if (!usedOutputs.has(candidateKey) && !(await fs.promises.access(candidate).then(() => true, () => false))) { + if (!plannedOutputs.has(candidateKey) && !(await fs.promises.access(candidate).then(() => true, () => false))) { break; } n += 1; @@ -3084,45 +3096,76 @@ async function extractZipArchive( } else { disposition = "overwritten"; } - } - + } + + const normalizedEntryPath = entry.entryName.replace(/\\/g, "/"); + validateTarget?.(normalizedEntryPath, outputPath); + plannedOutputs.add(outputKey); + filePlans.push({ + entry, + entryPath: normalizedEntryPath, + outputPath, + outputKey, + disposition, + uncompressedSize, + compressedSize + }); + } + + for (const directoryPlan of directoryPlans) { if (signal?.aborted) { throw new Error("aborted:extract"); } - const normalizedEntryPath = entry.entryName.replace(/\\/g, "/"); - validateTarget?.(normalizedEntryPath, outputPath); + await fs.promises.mkdir(directoryPlan.outputPath, { recursive: true }); + validateTarget?.(directoryPlan.entryPath, directoryPlan.outputPath); + } + + for (const plan of filePlans) { + if (signal?.aborted) { + throw new Error("aborted:extract"); + } + if (plan.disposition === "skipped") { + onOutput?.({ + version: 1, + archivePath: path.resolve(archivePath), + entryPath: plan.entryPath, + outputPath: plan.outputPath, + state: "complete", + disposition: "skipped" + }); + continue; + } onOutput?.({ version: 1, archivePath: path.resolve(archivePath), - entryPath: normalizedEntryPath, - outputPath, + entryPath: plan.entryPath, + outputPath: plan.outputPath, state: "opened", - disposition + disposition: plan.disposition }); - await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); - validateTarget?.(normalizedEntryPath, outputPath); - const data = entry.getData(); + await fs.promises.mkdir(path.dirname(plan.outputPath), { recursive: true }); + validateTarget?.(plan.entryPath, plan.outputPath); + const data = plan.entry.getData(); if (data.length > memoryLimitBytes) { const entryMb = Math.ceil(data.length / (1024 * 1024)); const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024)); throw new Error(`ZIP-Eintrag zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`); } - const maxDeclaredSize = Math.max(uncompressedSize, compressedSize); - if (maxDeclaredSize > 0 && data.length > maxDeclaredSize * 20) { - throw new Error(`ZIP-Eintrag verdächtig groß nach Entpacken (${entry.entryName})`); - } + const maxDeclaredSize = Math.max(plan.uncompressedSize, plan.compressedSize); + if (maxDeclaredSize > 0 && data.length > maxDeclaredSize * 20) { + throw new Error(`ZIP-Eintrag verdächtig groß nach Entpacken (${plan.entry.entryName})`); + } try { - await fs.promises.writeFile(outputPath, data); - usedOutputs.add(outputKey); + await fs.promises.writeFile(plan.outputPath, data); } catch (error) { - if (await fs.promises.access(outputPath).then(() => true, () => false)) { + if (await fs.promises.access(plan.outputPath).then(() => true, () => false)) { onOutput?.({ version: 1, archivePath: path.resolve(archivePath), - entryPath: entry.entryName.replace(/\\/g, "/"), - outputPath, + entryPath: plan.entryPath, + outputPath: plan.outputPath, state: "partial", - disposition + disposition: plan.disposition }); } throw error; @@ -3130,10 +3173,10 @@ async function extractZipArchive( onOutput?.({ version: 1, archivePath: path.resolve(archivePath), - entryPath: normalizedEntryPath, - outputPath, + entryPath: plan.entryPath, + outputPath: plan.outputPath, state: "complete", - disposition + disposition: plan.disposition }); } } diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 1893b58..306aa92 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -13581,6 +13581,62 @@ describe("download manager", () => { expect(session.packages[packageId].outputRecords).toEqual([]); }); + it.each([ + ["stale-generation", 2, 1, "11111111-1111-4111-8111-111111111111", "11111111-1111-4111-8111-111111111111"], + ["stale-owner", 1, 1, "22222222-2222-4222-8222-222222222222", "33333333-3333-4333-8333-333333333333"] + ] as const)("keeps a %s marker unchanged while direct scoped output continues", async (_label, currentGeneration, markerGeneration, currentOwner, markerOwner) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-owner-stale-empty-")); + tempDirs.push(root); + const packageName = "stale-empty-package"; + const extractDir = path.join(root, "extract", packageName); + fs.mkdirSync(extractDir, { recursive: true }); + const packageId = "stale-empty-package-id"; + const markerPath = path.join(extractDir, ".rd-package-output-owner-v1.json"); + const markerContent = JSON.stringify({ + version: 1, + packageId, + generation: markerGeneration, + ownerId: markerOwner + }); + fs.writeFileSync(markerPath, markerContent); + const session = emptySession(); + const pkg: PackageEntry = { + id: packageId, + name: packageName, + outputDir: path.join(root, "downloads", packageName), + extractDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + outputOwnerId: currentOwner, + outputOwnerGeneration: currentGeneration, + resultGeneration: currentGeneration, + createdAt: 1_000, + updatedAt: 1_000 + }; + session.packageOrder = [packageId]; + session.packages[packageId] = pkg; + const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state"))); + + await (manager as any).runWithPackageOutputProvenance(pkg, async (targetDir: string, scope: any) => { + const outputPath = path.join(targetDir, "owned.mkv"); + fs.writeFileSync(outputPath, "owned"); + scope.add({ + version: 1, + archivePath: path.join(pkg.outputDir, "archive.rar"), + entryPath: "owned.mkv", + outputPath, + state: "complete", + disposition: "written" + }); + }); + + expect(fs.readFileSync(markerPath, "utf8")).toBe(markerContent); + expect(fs.readFileSync(path.join(extractDir, "owned.mkv"), "utf8")).toBe("owned"); + expect(pkg.outputRecords).toEqual([expect.objectContaining({ entryPath: "owned.mkv" })]); + }); + it("does NOT move bonus files from Extras subdirectory to flat library", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); diff --git a/tests/extractor-jvm.test.ts b/tests/extractor-jvm.test.ts index 81f2152..5b93d88 100644 --- a/tests/extractor-jvm.test.ts +++ b/tests/extractor-jvm.test.ts @@ -360,6 +360,47 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b } 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("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 e5db277..06dd718 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -1661,5 +1661,44 @@ describe("extractor", () => { ]); }); + it("preflights every internal ZIP entry before overwriting an earlier safe target", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-zip-full-preflight-")); + 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 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 zip = new AdmZip(); + zip.addFile("00-safe.txt", Buffer.from("package-safe")); + zip.addFile("zz-name.", Buffer.from("package-invalid")); + zip.writeZip(path.join(packageDir, "release.zip")); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(0); + expect(result.failed).toBe(1); + expect(fs.readFileSync(safePath, "utf8")).toBe("foreign-safe"); + expect(fs.readFileSync(aliasPath, "utf8")).toBe("foreign-alias"); + }); + + 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."]); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-raw-list-")); + tempDirs.push(root); + expect(() => validateNativeArchiveEntryCandidates(entries, root)).toThrow(/Ausgabepfad|entry/i); + }); + }); });