From 1859aaf539e606601a47272d20c777850a34b26c Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 22 Aug 2026 15:53:33 +0200 Subject: [PATCH] fix: preflight complete archives before mutation Plan and validate every internal ZIP, Zip4j, and SevenZipJBinding entry and final conflict target before any archive output mutates the filesystem. Preserve raw RAR list entry whitespace for strict validation and keep any non-matching package owner marker immutable while direct scoped outputs continue without marker reissuance. --- .../JBindExtractorMain$Backend.class | Bin 2310 -> 2310 bytes ...dExtractorMain$BulkExtractCallback$1.class | Bin 1996 -> 1996 bytes ...indExtractorMain$BulkExtractCallback.class | Bin 7032 -> 7032 bytes .../JBindExtractorMain$ConflictMode.class | Bin 2059 -> 2059 bytes ...JBindExtractorMain$ExtractionRequest.class | Bin 2539 -> 2539 bytes .../JBindExtractorMain$OutputTarget.class | Bin 884 -> 884 bytes .../JBindExtractorMain$ProgressTracker.class | Bin 1387 -> 1387 bytes ...ExtractorMain$SevenZipArchiveContext.class | Bin 1663 -> 1663 bytes ...ExtractorMain$SevenZipVolumeCallback.class | Bin 3667 -> 3667 bytes ...ExtractorMain$WrongPasswordException.class | Bin 458 -> 458 bytes .../extractor/JBindExtractorMain.class | Bin 27081 -> 27379 bytes .../extractor/JBindExtractorMain.java | 37 +++-- src/main/download-manager.ts | 6 +- src/main/extractor.ts | 131 ++++++++++++------ tests/download-manager.test.ts | 56 ++++++++ tests/extractor-jvm.test.ts | 41 ++++++ tests/extractor.test.ts | 39 ++++++ 17 files changed, 252 insertions(+), 58 deletions(-) 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 cd1adfd0374f0f9d51e2106fc65e531a1cd5229a..eac6eac06e484fa07eb5cd3b4d400b24094c6337 100644 GIT binary patch delta 96 zcmZn@Y7^RUl#Q`#@-a4jAo-2WhI1kV8}lRvZsy68t=U(|PGb;Zp3Wf4JcB`zc@~2W y^K1qO=D7@>%<~w0nHNmrZ~$s@@=jk%tIo4H}KHTw$LW(E=F76w`7Rt81pb_N^f w4h9G2E(TBLZU$fG-bow|KuwMuHbAnDBa*j*fuFgOL5{hKL6^CF@)Hgv0M&dLDF6Tf 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 2f0051b1f6ae66119335f7a1465087f7eaf42101..6e2d9e5fe9f5fb3b8e51eceb0c948975bcf74efb 100644 GIT binary patch delta 55 zcmX@Ze};dIsvgk8#vluZ*u$VGPvluhTu$VAdvzRe>vzRkPuvjo8 Lvsg~9XZHmFS!@cU delta 55 zcmX@Ze};d^@R~}X7A0Nq11S8z zf*(K-X5rSY=&n`kwDO+Sv|O@2eRPp%zMXx}NZ9|ZQ3Dmx@@ zM>N_oO?JXvJLQ3$(QfDT*f09+f@k)dwEbbs{xV@%X6>K5<2QT%-J~DCCUdJ-aeXu6 z_j)N6AQj@Ph(I@ps+hP+xUW*$?N8wUOT_*W6JB1FHr2z4?s>Vo?`p!`*$KS)SJ6e^9c6eSv_LL+WFyiqY4 ne8#BKI5nCGijzFjG*g;kMe{CG-sY{lv+~-2k;oGGB1_lNOMh0CV$p)kun0HK0<^&ns K2PSuN8UX-RW*>V1 delta 126 zcmaDY{91U!H+J5o3@pqm7`T~NG6*rRn#|0h#kh2`9)}K)jONf`Un;=CV8mcNIgvwj z@?s7_FyDm16wJTCAqZ6U2&{vbQ;QX()@rgKr>GGB1_lNOMh0CV$p)kun3qmY<^&ns K2PSuN8UX+dOCE6m 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 24e233e2688d338fb30eb5a0c9eec803690d3594..4494fee342cf1a2e43808b497528ea72297a6db1 100644 GIT binary patch delta 47 ycmeyu_JwW32`0g#3@psY7&w`aGw?B=U=U_LIr%1&7UO}*EX*KMA52CuO923Ey$&@1 delta 47 ycmeyu_JwW32`0f!3@pr>8914@Fz_*NWe{fGHu)x#7USB8D1g4YI1YH%W01E`F022hO T02BnR02u_X05=4$lSKt4KYkla delta 113 zcmV-%0FM9b3hN56#RCz000abi00{(q01^a!02c&)lhy+(1%Uts1cH+W1Zf?G00snx z00#t!01*U<038I103`&B06qkb07C?hlZ*sD36TH@1d;#_1e24~1YH%D01E_{022h5 T02Bn802u_E05=4jlSKt4-RT*F 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 23e29c6c8d69f668f329e6bb3c3834fe12b3e7ab..0e095f664cc668315b43e209f7801383bbca9bbd 100644 GIT binary patch delta 101 zcmV-r0Gj{*4F3$UMg)IG&I!S z%Ww|63`I)?-6(}zAD&*=3$NRLwvX8@lXgeZ?uqPy*dE=9f1G$?+@1;S#r6HG+ACxB zM&8~>?}M^^QnxP}_RW_4(6V3l?TD=cZ1n$}oX I!(~$2|E^OmeEjR{6B=j!0sD} zTT)_iiCbJYi@|`dPo8GlOpoeu-zm}wT{>e>=ah6oRhMBq-ppRnu4@wAgopUBa!W>c z4Cub~9x3XHDLpf-7v}ZKvffzLJL~$OuFr6sTZ{A+`jhcU-=UV2BmIO%vR~6LdHvDt zh}3P2SyXLiong)$EIP}QV>XM-UE!%T%Sm`_{6exL!2@g|t%A`lAxX6r0 ILr;F;A3n=3`v3p{ 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 2c816889543ee6ed433c86a9f8d9a6931d899727..6d0f56e54db7cfe2c6a19adf91c19f5ce57e4348 100644 GIT binary patch delta 17 YcmX@be2RI)YDU&p23D4~$r~7@05;kMZU6uP delta 17 YcmX@be2RI)YDU%~23D5h$r~7@05#ABNB{r; diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class index 420ccd3c71bbc04bdb558ff22bd94f4fc95a41c4..978c9ca4d09dafcc8e347f41536c9a869224bf37 100644 GIT binary patch delta 3962 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 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); + }); + }); });