From 378b5e2a9e6993d7f71c9c143ff2a9c21a068f14 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 22 Aug 2026 21:11:58 +0200 Subject: [PATCH] fix: reconcile native extraction outputs Use the validated native archive plan to recognize exact files after successful WinRAR and 7-Zip runs even when progress output contains no parseable completion lines. Preserve extractor-specific rename targets across subst mappings, keep 7-Zip directory paths when RAR flat mode is active, and reject colliding RAR flat targets before extraction. --- src/main/extractor.ts | 197 +++++++++++++++++++++++++++++++++++++--- tests/extractor.test.ts | 123 ++++++++++++++++++++++++- 2 files changed, 304 insertions(+), 16 deletions(-) diff --git a/src/main/extractor.ts b/src/main/extractor.ts index 9c11709..c0e90ee 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -2412,6 +2412,8 @@ export function parseNativeArchiveEntryList(command: string, output: string): st type NativeArchiveEntryCandidate = { entryPath: string; isDirectory: boolean }; +type NativeEntryPreflightResult = ExtractSpawnResult & { entries: NativeArchiveEntryCandidate[] }; + function parseNativeArchiveEntryCandidates(command: string, output: string): NativeArchiveEntryCandidate[] { const lines = String(output || "").split(/\r?\n/); if (isRarNativeCommand(command)) { @@ -2472,6 +2474,34 @@ function validateNativeArchiveTargetPlan(entries: readonly NativeArchiveEntryCan } } +function nativeArchiveEntriesForOutputMode( + command: string, + entries: readonly NativeArchiveEntryCandidate[], + flatMode: boolean +): NativeArchiveEntryCandidate[] { + if (!flatMode || !isRarNativeCommand(command)) { + return [...entries]; + } + return entries + .filter((entry) => !entry.isDirectory) + .map((entry) => ({ + entryPath: path.posix.basename(entry.entryPath.replace(/\\/g, "/").replace(/\/$/, "")), + isDirectory: false + })); +} + +export function validateNativeFlatArchiveEntryCandidates( + command: string, + entries: readonly string[], + targetDir: string +): void { + const candidates = entries.map((entryPath) => ({ + entryPath, + isDirectory: /[\\/]$/.test(entryPath) + })); + validateNativeArchiveTargetPlan(nativeArchiveEntriesForOutputMode(command, candidates, true), targetDir); +} + function dispatchJvmOutputEvent( state: JvmParseState, onOutput: ((event: ExtractOutputEvent) => void) | undefined, @@ -2528,8 +2558,9 @@ async function runNativeEntryPreflight( targetDir: string, password: string, signal: AbortSignal | undefined, - timeoutMs: number -): Promise { + timeoutMs: number, + flatMode: boolean +): Promise { const chunks: string[] = []; const result = await runExtractCommand( command, @@ -2539,7 +2570,7 @@ async function runNativeEntryPreflight( timeoutMs ); if (!result.ok) { - return result; + return { ...result, entries: [] }; } try { const entries = parseNativeArchiveEntryCandidates(command, chunks.join("")); @@ -2547,18 +2578,126 @@ async function runNativeEntryPreflight( throw new Error("Native Archivliste enthält keine validierbaren Einträge"); } validateNativeArchiveTargetPlan(entries, targetDir); - return result; + if (flatMode && isRarNativeCommand(command)) { + validateNativeArchiveTargetPlan(nativeArchiveEntriesForOutputMode(command, entries, true), targetDir); + } + return { ...result, entries }; } catch (error) { return { ok: false, missingCommand: false, aborted: false, timedOut: false, - errorText: cleanErrorText(String(error)) + errorText: cleanErrorText(String(error)), + entries: [] }; } } +function existingNativeOutputKeys( + command: string, + entries: readonly NativeArchiveEntryCandidate[], + targetDir: string, + conflictMode: ConflictMode, + flatMode: boolean +): Set { + const keys = new Set(); + const mode = effectiveConflictMode(conflictMode); + const extractsFlat = flatMode && isRarNativeCommand(command); + for (const candidate of entries) { + if (candidate.isDirectory) { + continue; + } + const archiveEntryPath = candidate.entryPath.replace(/\\/g, "/").replace(/\/$/, ""); + const entryPath = extractsFlat ? path.posix.basename(archiveEntryPath) : archiveEntryPath; + const outputPath = path.resolve(targetDir, ...entryPath.split("/")); + try { + const stat = fs.lstatSync(outputPath); + if (stat.isFile() && !stat.isSymbolicLink()) { + keys.add(pathSetKey(outputPath)); + if (mode === "rename") { + const pattern = nativeRenameOutputPattern(command, path.basename(outputPath)); + for (const entry of fs.readdirSync(path.dirname(outputPath), { withFileTypes: true })) { + if (entry.isFile() && pattern.test(entry.name)) { + keys.add(pathSetKey(path.join(path.dirname(outputPath), entry.name))); + } + } + } + } + } catch { + } + } + return keys; +} + +function nativeRenameOutputPattern(command: string, fileName: string): RegExp { + const parsed = path.parse(fileName); + const suffix = extractorCommandKind(command) === "seven_zip" ? "_\\d+" : "\\(\\d+\\)"; + return new RegExp(`^${escapeRegex(parsed.name)}${suffix}${escapeRegex(parsed.ext)}$`, "i"); +} + +export function reconcileNativeExtractOutputs( + command: string, + entries: readonly { entryPath: string; isDirectory: boolean }[], + archivePath: string, + targetDir: string, + conflictMode: ConflictMode, + existingBefore: ReadonlySet, + flatMode = false +): ExtractOutputEvent[] { + validateNativeArchiveTargetPlan(entries, targetDir); + const mode = effectiveConflictMode(conflictMode); + const extractsFlat = flatMode && isRarNativeCommand(command); + const scope = new PackageOutputScope([targetDir]); + const events: ExtractOutputEvent[] = []; + for (const candidate of entries) { + if (candidate.isDirectory) { + continue; + } + const archiveEntryPath = candidate.entryPath.replace(/\\/g, "/").replace(/\/$/, ""); + const entryPath = extractsFlat ? path.posix.basename(archiveEntryPath) : archiveEntryPath; + const baseOutputPath = path.resolve(targetDir, ...entryPath.split("/")); + const existed = existingBefore.has(pathSetKey(baseOutputPath)); + let outputPath = baseOutputPath; + if (mode === "rename" && existed) { + const pattern = nativeRenameOutputPattern(command, path.basename(baseOutputPath)); + let renamedCandidates: string[] = []; + try { + renamedCandidates = fs.readdirSync(path.dirname(baseOutputPath), { withFileTypes: true }) + .filter((entry) => entry.isFile() && pattern.test(entry.name)) + .map((entry) => path.join(path.dirname(baseOutputPath), entry.name)) + .filter((candidatePath) => !existingBefore.has(pathSetKey(candidatePath))); + } catch { + } + if (renamedCandidates.length !== 1) { + continue; + } + [outputPath] = renamedCandidates; + } + const event: ExtractOutputEvent = { + version: 1, + archivePath: path.resolve(archivePath), + entryPath, + outputPath, + state: "complete", + disposition: mode === "rename" && existed + ? "renamed" + : mode === "skip" && existed + ? "skipped" + : mode === "overwrite" && existed + ? "overwritten" + : "written" + }; + try { + if (event.disposition === "skipped" || scope.add(event)) { + events.push(event); + } + } catch { + } + } + return events; +} + export function parseNativeExtractOutput( command: string, line: string, @@ -2614,6 +2753,24 @@ export function parseNativeExtractOutput( } } +export function remapNativeSubstOutput( + event: ExtractOutputEvent, + effectiveTargetDir: string, + targetDir: string +): ExtractOutputEvent { + const relativePath = path.relative(path.resolve(effectiveTargetDir), path.resolve(event.outputPath)); + if (!relativePath + || relativePath === ".." + || relativePath.startsWith(`..${path.sep}`) + || path.isAbsolute(relativePath)) { + throw new Error(`Ungültiger subst-Ausgabepfad: ${event.outputPath}`); + } + return { + ...event, + outputPath: path.resolve(targetDir, relativePath) + }; +} + function failDaemonOutputCallback(req: DaemonRequest): void { if (daemonCurrentRequest !== req || !req.parseState.outputError || req.terminationStarted) { return; @@ -2695,12 +2852,20 @@ async function runExternalExtractInner( const summarizeResultError = (errorText: string): string => cleanErrorText(errorText); let createErrorText = ""; let createErrorPassword = ""; - const runNativeAttempt = async (args: string[], password: string): Promise => { - const preflight = await runNativeEntryPreflight(command, archivePath, targetDir, password, signal, timeoutMs); + const runNativeAttempt = async (args: string[], password: string, flatMode = false): Promise => { + const preflight = await runNativeEntryPreflight(command, archivePath, targetDir, password, signal, timeoutMs, flatMode); if (!preflight.ok) { return preflight; } - const outputs = createNativeOutputCollector(command, archivePath, targetDir, conflictMode, onOutput); + const existingBefore = existingNativeOutputKeys(command, preflight.entries, targetDir, conflictMode, flatMode); + const reportedOutputs = new Set(); + const emitNativeOutput = (event: ExtractOutputEvent): void => { + if (event.state === "complete" && event.disposition !== "skipped") { + reportedOutputs.add(pathSetKey(event.outputPath)); + } + onOutput?.(event); + }; + const outputs = createNativeOutputCollector(command, archivePath, targetDir, conflictMode, emitNativeOutput); const result = await runExtractCommand(command, args, (chunk) => { outputs.push(chunk); const parsed = parseProgressPercent(chunk); @@ -2714,6 +2879,13 @@ async function runExternalExtractInner( } }, signal, timeoutMs); outputs.finish(result.ok ? "complete" : "partial"); + if (result.ok) { + for (const event of reconcileNativeExtractOutputs(command, preflight.entries, archivePath, targetDir, conflictMode, existingBefore, flatMode)) { + if (!reportedOutputs.has(pathSetKey(event.outputPath))) { + onOutput?.(event); + } + } + } return result; }; @@ -2726,7 +2898,7 @@ async function runExternalExtractInner( onLog?.("INFO", `Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, password=`); logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=)`); const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true); - const result = await runNativeAttempt(args, password); + const result = await runNativeAttempt(args, password, true); logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: ok=${result.ok}, bestPercent=${bestPercent}`); onLog?.("INFO", `Flach-Extraktion Ergebnis ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}`); if (result.ok) { if (flatModeResult) flatModeResult.needed = true; onArchiveProgress?.(100); return password; } @@ -2824,7 +2996,7 @@ async function runExternalExtractInner( logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=)`); onLog?.("INFO", `Flach-Extraktion Versuch ${passwordAttempt}/${flatPasswords.length}: archive=${path.basename(archivePath)}, password=`); const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true); - const result = await runNativeAttempt(args, password); + const result = await runNativeAttempt(args, password, true); logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: ok=${result.ok}, bestPercent=${bestPercent}`); onLog?.("INFO", `Flach-Extraktion Ergebnis ${passwordAttempt}/${flatPasswords.length}: archive=${path.basename(archivePath)}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}`); if (result.ok) { if (flatModeResult) flatModeResult.needed = true; onArchiveProgress?.(100); return password; } @@ -2935,10 +3107,7 @@ async function runExternalExtract( onLog?.("INFO", `Legacy-Zielpfad unveraendert: archive=${archiveName}, effectiveTargetDir=${effectiveTargetDir}`); } const legacyOnOutput = subst && onOutput - ? (event: ExtractOutputEvent): void => onOutput({ - ...event, - outputPath: path.resolve(targetDir, ...event.entryPath.split("/")) - }) + ? (event: ExtractOutputEvent): void => onOutput(remapNativeSubstOutput(event, effectiveTargetDir, targetDir)) : onOutput; const command = await resolveExtractorCommand(archivePath); diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index 0225d1a..5208a06 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -21,11 +21,14 @@ import { orderExtractorCandidatesForArchive, parseNativeExtractOutput, parseNativeArchiveEntryList, - resolveExtractorBackendModeForArchive, + remapNativeSubstOutput, + reconcileNativeExtractOutputs, + resolveExtractorBackendModeForArchive, resolveExtractorBackendMode, shouldFallbackLegacyRarToJvm, validateNativeArchiveEntryCandidates, -} from "../src/main/extractor"; + validateNativeFlatArchiveEntryCandidates, +} from "../src/main/extractor"; const tempDirs: string[] = []; const originalExtractBackend = process.env.RD_EXTRACT_BACKEND; @@ -1731,6 +1734,56 @@ describe("extractor", () => { ]); }); + it("preserves a native renamed filename when remapping a subst output", () => { + const targetDir = "C:\\Downloads\\Extracted"; + const event = remapNativeSubstOutput({ + version: 1, + archivePath: "C:\\Downloads\\release.rar", + entryPath: "episode.mkv", + outputPath: "Z:\\episode(2).mkv", + state: "complete", + disposition: "renamed" + }, "Z:\\", targetDir); + + expect(event.outputPath).toBe(path.resolve(targetDir, "episode(2).mkv")); + expect(event.entryPath).toBe("episode.mkv"); + }); + + it("keeps native 7-Zip output directories when a previous RAR requested flat mode", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-seven-flat-")); + tempDirs.push(root); + const targetDir = path.join(root, "out"); + const outputPath = path.join(targetDir, "folder", "episode.mkv"); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, "video"); + + const events = reconcileNativeExtractOutputs( + "7z.exe", + [{ entryPath: "folder/episode.mkv", isDirectory: false }], + path.join(root, "release.7z"), + targetDir, + "overwrite", + new Set(), + true + ); + + expect(events).toEqual([ + expect.objectContaining({ entryPath: "folder/episode.mkv", outputPath }) + ]); + }); + + it("rejects colliding RAR flat-mode basenames before extraction", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-rar-flat-collision-")); + tempDirs.push(root); + const targetDir = path.join(root, "out"); + fs.mkdirSync(targetDir, { recursive: true }); + + expect(() => validateNativeFlatArchiveEntryCandidates("Rar.exe", [ + "folder-a/episode.mkv", + "folder-b/episode.mkv" + ], targetDir)).toThrow(/Kollision/i); + }); + it.each(["Entpacke", "Extrayendo", "Extraction"])("parses verified native RAR candidates without depending on the %s locale verb", (verb) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-locale-")); tempDirs.push(root); @@ -1744,6 +1797,72 @@ describe("extractor", () => { ]); }); + it("reconciles verified native outputs when WinRAR emits no parseable completion line", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-reconcile-")); + tempDirs.push(root); + const targetDir = path.join(root, "out"); + const archivePath = path.join(root, "release.part1.rar"); + const outputPath = path.join(targetDir, "folder", "episode.mkv"); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, "video"); + + const events = reconcileNativeExtractOutputs( + "Rar.exe", + [{ entryPath: "folder/", isDirectory: true }, { entryPath: "folder/episode.mkv", isDirectory: false }], + archivePath, + targetDir, + "overwrite", + new Set() + ); + + expect(events).toEqual([{ + version: 1, + archivePath: path.resolve(archivePath), + entryPath: "folder/episode.mkv", + outputPath, + state: "complete", + disposition: "written" + }]); + }); + + it.each([ + ["Rar.exe", "episode(1).mkv", "episode(2).mkv"], + ["7z.exe", "episode_1.mkv", "episode_2.mkv"] + ])("reconciles the newly renamed %s output without claiming an older collision", (command, firstRenamedName, newRenamedName) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-rename-reconcile-")); + tempDirs.push(root); + const targetDir = path.join(root, "out"); + const archivePath = path.join(root, "release.rar"); + const basePath = path.join(targetDir, "episode.mkv"); + const firstRenamedPath = path.join(targetDir, firstRenamedName); + const newRenamedPath = path.join(targetDir, newRenamedName); + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync(basePath, "foreign-base"); + fs.writeFileSync(firstRenamedPath, "foreign-renamed"); + const existingBefore = new Set([basePath, firstRenamedPath].map((filePath) => ( + process.platform === "win32" ? path.resolve(filePath).toLowerCase() : path.resolve(filePath) + ))); + fs.writeFileSync(newRenamedPath, "owned"); + + const events = reconcileNativeExtractOutputs( + command, + [{ entryPath: "episode.mkv", isDirectory: false }], + archivePath, + targetDir, + "rename", + existingBefore + ); + + expect(events).toEqual([ + expect.objectContaining({ + entryPath: "episode.mkv", + outputPath: newRenamedPath, + state: "complete", + disposition: "renamed" + }) + ]); + }); + it.each([ ["file.mkv:stream", "file.mkv"], ["name.", "name"],