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 fe9414c..e8fcab3 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 f4414fb..45b8522 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 9b29d83..9b4c635 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 80669bf..771c8fe 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 d06ab5e..7a3b3e4 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 new file mode 100644 index 0000000..e1b2fc9 Binary files /dev/null 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 471ebab..cfe8652 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 507bea2..e85dfd4 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 b282d77..c3d2f41 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 12a9fe1..c2bc826 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 63dbc49..894704b 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 5d64b33..a49f0f4 100644 --- a/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java +++ b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java @@ -274,11 +274,13 @@ public final class JBindExtractorMain { } long itemUnits = safeSize(header.getUncompressedSize()); - File output = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved); - if (output == null) { - progress.advance(itemUnits); - continue; - } + OutputTarget outputTarget = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved); + File output = outputTarget.file; + if (output == null) { + emitOutput(request.archiveFile, entryName, outputTarget.reportedFile, "complete", outputTarget.disposition); + progress.advance(itemUnits); + continue; + } ensureDirectory(output.getParentFile()); rejectSymlink(output); @@ -319,17 +321,21 @@ public final class JBindExtractorMain { progress.advance(remaining[0]); } long modified = header.getLastModifiedTimeEpoch(); - if (modified > 0) { - output.setLastModified(modified); - } - extractionSuccess = true; - } catch (ZipException error) { + if (modified > 0) { + output.setLastModified(modified); + } + extractionSuccess = true; + emitOutput(request.archiveFile, entryName, output, "complete", outputTarget.disposition); + } catch (ZipException error) { if (isWrongPassword(error, encrypted)) { throw new WrongPasswordException(error); } throw error; - } finally { - if (!extractionSuccess && output.exists()) { + } finally { + if (!extractionSuccess && output.exists()) { + emitOutput(request.archiveFile, entryName, output, "partial", outputTarget.disposition); + } + if (!extractionSuccess && output.exists()) { try { output.delete(); } catch (Throwable ignored) { @@ -368,9 +374,11 @@ public final class JBindExtractorMain { long totalUnits = 0; boolean encrypted = false; - List fileIndices = new ArrayList(); - List outputFiles = new ArrayList(); - List fileSizes = new ArrayList(); + List fileIndices = new ArrayList(); + List outputFiles = new ArrayList(); + List fileSizes = new ArrayList(); + List entryNames = new ArrayList(); + List dispositions = new ArrayList(); Set reserved = new HashSet(); for (int i = 0; i < itemCount; i++) { @@ -396,10 +404,16 @@ public final class JBindExtractorMain { long itemSize = safeSize(rawSize); totalUnits += itemSize; - File output = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved); - fileIndices.add(i); - outputFiles.add(output); - fileSizes.add(itemSize); + OutputTarget outputTarget = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved); + File output = outputTarget.file; + if (output == null) { + emitOutput(request.archiveFile, entryName, outputTarget.reportedFile, "complete", outputTarget.disposition); + } + fileIndices.add(i); + outputFiles.add(output); + fileSizes.add(itemSize); + entryNames.add(entryName); + dispositions.add(outputTarget.disposition); } if (fileIndices.isEmpty()) { @@ -434,7 +448,7 @@ public final class JBindExtractorMain { try { archive.extract(indices, false, new BulkExtractCallback( - archive, indexToPos, fileIndices, outputFiles, fileSizes, + archive, request.archiveFile, indexToPos, fileIndices, outputFiles, fileSizes, entryNames, dispositions, progress, encryptedFinal, effectivePassword, currentOutput, currentStream, currentSuccess, currentRemaining, currentPos, firstError )); @@ -566,26 +580,26 @@ public final class JBindExtractorMain { return directory; } - private static File resolveOutputFile(File targetDir, String entryName, ConflictMode conflictMode, Set reserved) throws IOException { - File base = secureResolve(targetDir, entryName); + private static OutputTarget resolveOutputFile(File targetDir, String entryName, ConflictMode conflictMode, Set reserved) throws IOException { + File base = secureResolve(targetDir, entryName); String key = pathKey(base); boolean exists = base.exists() || reserved.contains(key); - if (!exists) { - reserved.add(key); - return base; - } - - if (conflictMode == ConflictMode.SKIP) { - return null; - } + if (!exists) { + reserved.add(key); + return new OutputTarget(base, base, "written"); + } + + if (conflictMode == ConflictMode.SKIP) { + return new OutputTarget(null, base, "skipped"); + } if (conflictMode == ConflictMode.OVERWRITE) { if (base.exists()) { deleteRecursively(base); - } - reserved.add(key); - return base; + } + reserved.add(key); + return new OutputTarget(base, base, "overwritten"); } File parent = base.getParentFile(); @@ -600,8 +614,8 @@ public final class JBindExtractorMain { File candidate = new File(parent, candidateName); String candidateKey = pathKey(candidate); if (!candidate.exists() && !reserved.contains(candidateKey)) { - reserved.add(candidateKey); - return candidate; + reserved.add(candidateKey); + return new OutputTarget(candidate, candidate, "renamed"); } counter += 1; } @@ -816,9 +830,23 @@ public final class JBindExtractorMain { System.out.println("RD_DONE"); } - private static void emitError(String message) { - System.err.println("RD_ERROR " + message); - } + private static void emitError(String message) { + System.err.println("RD_ERROR " + message); + } + + private static String encodeField(String value) { + return Base64.getEncoder().encodeToString((value == null ? "" : value).getBytes(StandardCharsets.UTF_8)); + } + + private static void emitOutput(File archiveFile, String entryPath, File outputFile, String state, String disposition) { + if (archiveFile == null || outputFile == null) { + return; + } + System.out.println("RD_OUTPUT 1 " + state + " " + disposition + " " + + encodeField(archiveFile.getAbsolutePath()) + " " + + encodeField(entryPath == null ? "" : entryPath.replace('\\', '/')) + " " + + encodeField(outputFile.getAbsolutePath())); + } private enum Backend { AUTO("auto"), @@ -866,20 +894,35 @@ public final class JBindExtractorMain { } } - private static final class ExtractionRequest { + private static final class ExtractionRequest { private File archiveFile; private File targetDir; private ConflictMode conflictMode = ConflictMode.SKIP; private Backend backend = Backend.AUTO; private final List passwords = new ArrayList(); - } + } + + private static final class OutputTarget { + private final File file; + private final File reportedFile; + private final String disposition; + + OutputTarget(File file, File reportedFile, String disposition) { + this.file = file; + this.reportedFile = reportedFile; + this.disposition = disposition; + } + } private static final class BulkExtractCallback implements IArchiveExtractCallback, ICryptoGetTextPassword { - private final IInArchive archive; + private final IInArchive archive; + private final File archiveFile; private final Map indexToPos; private final List fileIndices; private final List outputFiles; - private final List fileSizes; + private final List fileSizes; + private final List entryNames; + private final List dispositions; private final ProgressTracker progress; private final boolean encrypted; private final String password; @@ -890,17 +933,21 @@ public final class JBindExtractorMain { private final int[] currentPos; private final Throwable[] firstError; - BulkExtractCallback(IInArchive archive, Map indexToPos, - List fileIndices, List outputFiles, List fileSizes, + BulkExtractCallback(IInArchive archive, File archiveFile, Map indexToPos, + List fileIndices, List outputFiles, List fileSizes, + List entryNames, List dispositions, ProgressTracker progress, boolean encrypted, String password, File[] currentOutput, FileOutputStream[] currentStream, boolean[] currentSuccess, long[] currentRemaining, int[] currentPos, Throwable[] firstError) { - this.archive = archive; + this.archive = archive; + this.archiveFile = archiveFile; this.indexToPos = indexToPos; this.fileIndices = fileIndices; this.outputFiles = outputFiles; - this.fileSizes = fileSizes; + this.fileSizes = fileSizes; + this.entryNames = entryNames; + this.dispositions = dispositions; this.progress = progress; this.encrypted = encrypted; this.password = password; @@ -992,21 +1039,23 @@ public final class JBindExtractorMain { if (result == ExtractOperationResult.OK) { currentSuccess[0] = true; closeCurrentStream(); - if (currentPos[0] >= 0 && currentOutput[0] != null) { + if (currentPos[0] >= 0 && currentOutput[0] != null) { try { int archiveIndex = fileIndices.get(currentPos[0]); java.util.Date modified = (java.util.Date) archive.getProperty(archiveIndex, PropID.LAST_MODIFICATION_TIME); if (modified != null) { currentOutput[0].setLastModified(modified.getTime()); } - } catch (Throwable ignored) { - - } - } - } else { - closeCurrentStream(); - if (currentOutput[0] != null && currentOutput[0].exists()) { - try { + } catch (Throwable ignored) { + + } + emitOutput(archiveFile, entryNames.get(currentPos[0]), currentOutput[0], "complete", dispositions.get(currentPos[0])); + } + } else { + closeCurrentStream(); + if (currentOutput[0] != null && currentOutput[0].exists()) { + emitOutput(archiveFile, entryNames.get(currentPos[0]), currentOutput[0], "partial", dispositions.get(currentPos[0])); + try { currentOutput[0].delete(); } catch (Throwable ignored) { } diff --git a/src/main/extractor.ts b/src/main/extractor.ts index 1d01ff9..a97d0b4 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -4,8 +4,11 @@ import os from "node:os"; import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import AdmZip from "adm-zip"; import { CleanupMode, ConflictMode } from "../shared/types"; -import { logger } from "./logger"; -import { removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; +import { logger } from "./logger"; +import { removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; +import { PackageOutputScope, type ExtractOutputEvent } from "./package-output-scope"; + +export type { ExtractOutputEvent } from "./package-output-scope"; import crypto from "node:crypto"; @@ -59,9 +62,10 @@ export interface ExtractOptions { hybridMode?: boolean; maxParallel?: number; extractCpuPriority?: string; - onArchiveFailure?: (failure: ExtractArchiveFailureInfo) => void; - onLog?: (level: "INFO" | "WARN" | "ERROR", message: string) => void; -} + onArchiveFailure?: (failure: ExtractArchiveFailureInfo) => void; + onLog?: (level: "INFO" | "WARN" | "ERROR", message: string) => void; + onOutput?: (event: ExtractOutputEvent) => void; +} export interface ExtractProgressUpdate { current: number; @@ -131,7 +135,7 @@ type JvmExtractorLayout = { rootDir: string; }; -type JvmExtractResult = { +type JvmExtractResult = { ok: boolean; missingCommand: boolean; missingRuntime: boolean; @@ -139,8 +143,15 @@ type JvmExtractResult = { timedOut: boolean; errorText: string; usedPassword: string; - backend: string; -}; + backend: string; +}; + +export interface ExtractResult { + extracted: number; + failed: number; + lastError: string; + outputFiles: string[]; +} type ExtractSpawnResult = { ok: boolean; @@ -150,15 +161,34 @@ type ExtractSpawnResult = { errorText: string; }; -type ExtractResumeState = { - completedArchives: string[]; -}; +type ExtractResumeMember = { + path: string; + size: number; + mtimeMs: number; +}; + +type ExtractResumeOutput = { + entryPath: string; + path: string; + disposition: ExtractOutputEvent["disposition"]; +}; + +type ExtractResumeArchive = { + archivePath: string; + members: ExtractResumeMember[]; + outputs: ExtractResumeOutput[]; +}; + +type ExtractResumeState = { + version: 2; + archives: ExtractResumeArchive[]; +}; type ExtractorCommandKind = "rar_native" | "seven_zip" | "other"; interface SubstMapping { drive: string; original: string; } -interface DaemonRequest { +interface DaemonRequest { resolve: (result: JvmExtractResult) => void; onArchiveProgress?: (percent: number) => void; signal?: AbortSignal; @@ -166,8 +196,9 @@ interface DaemonRequest { parseState: { bestPercent: number; usedPassword: string; backend: string; reportedError: string }; archiveName: string; startedAt: number; - passwordCount: number; -} + passwordCount: number; + onOutput?: (event: ExtractOutputEvent) => void; +} const activeSubstDrives = new Set(); @@ -247,10 +278,6 @@ export function pathSetKey(filePath: string): string { return process.platform === "win32" ? filePath.toLowerCase() : filePath; } -function archiveNameKey(fileName: string): string { - return process.platform === "win32" ? String(fileName || "").toLowerCase() : String(fileName || ""); -} - function stripDuplicateSuffixBeforeExtension(fileName: string): string { return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, ""); } @@ -326,14 +353,30 @@ export async function findArchiveCandidates(packageDir: string): Promise entry.isFile()) - .map((entry) => path.join(packageDir, entry.name)); - } catch { - return []; - } + let files: string[] = []; + const stack = [packageDir]; + while (stack.length > 0) { + const current = stack.pop() as string; + let entries: fs.Dirent[] = []; + try { + entries = await fs.promises.readdir(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isSymbolicLink()) { + continue; + } + if (entry.isDirectory()) { + if (!/^\.rd-(?:output|replace)-/i.test(entry.name)) { + stack.push(fullPath); + } + } else if (entry.isFile()) { + files.push(fullPath); + } + } + } const fileNamesLower = new Set(files.map((filePath) => archiveDetectionName(filePath).toLowerCase())); const multipartRar = files.filter((filePath) => /\.part0*1\.rar$/i.test(archiveDetectionName(filePath))); @@ -1510,11 +1553,12 @@ function resolveJvmExtractorLayout(): JvmExtractorLayout | null { return null; } -function parseJvmLine( - line: string, - onArchiveProgress: ((percent: number) => void) | undefined, - state: { bestPercent: number; usedPassword: string; backend: string; reportedError: string } -): void { +function parseJvmLine( + line: string, + onArchiveProgress: ((percent: number) => void) | undefined, + state: { bestPercent: number; usedPassword: string; backend: string; reportedError: string }, + onOutput?: (event: ExtractOutputEvent) => void +): void { const trimmed = String(line || "").trim(); if (!trimmed) { return; @@ -1542,10 +1586,31 @@ function parseJvmLine( return; } - if (trimmed.startsWith("RD_BACKEND ")) { - state.backend = trimmed.slice("RD_BACKEND ".length).trim(); - return; - } + if (trimmed.startsWith("RD_BACKEND ")) { + state.backend = trimmed.slice("RD_BACKEND ".length).trim(); + return; + } + + if (trimmed.startsWith("RD_OUTPUT ")) { + const fields = trimmed.split(" "); + const stateValue = fields[2]; + const disposition = fields[3]; + if (fields.length !== 7 + || fields[1] !== "1" + || (stateValue !== "complete" && stateValue !== "partial") + || !(["written", "overwritten", "renamed", "skipped"] as const).includes(disposition as ExtractOutputEvent["disposition"])) { + return; + } + onOutput?.({ + version: 1, + archivePath: Buffer.from(fields[4], "base64").toString("utf8"), + entryPath: Buffer.from(fields[5], "base64").toString("utf8"), + outputPath: Buffer.from(fields[6], "base64").toString("utf8"), + state: stateValue, + disposition: disposition as ExtractOutputEvent["disposition"] + }); + return; + } if (trimmed.startsWith("RD_ERROR ")) { state.reportedError = trimmed.slice("RD_ERROR ".length).trim(); @@ -1601,11 +1666,11 @@ function flushDaemonParseBuffers(req: DaemonRequest | null): void { return; } if (daemonStdoutBuffer.trim()) { - parseJvmLine(daemonStdoutBuffer, req.onArchiveProgress, req.parseState); + parseJvmLine(daemonStdoutBuffer, req.onArchiveProgress, req.parseState, req.onOutput); daemonStdoutBuffer = ""; } if (daemonStderrBuffer.trim()) { - parseJvmLine(daemonStderrBuffer, req.onArchiveProgress, req.parseState); + parseJvmLine(daemonStderrBuffer, req.onArchiveProgress, req.parseState, req.onOutput); daemonStderrBuffer = ""; } } @@ -1663,7 +1728,7 @@ function handleDaemonLine(line: string): void { } if (daemonCurrentRequest) { - parseJvmLine(trimmed, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState); + parseJvmLine(trimmed, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState, daemonCurrentRequest.onOutput); } } @@ -1716,7 +1781,7 @@ function startDaemon(layout: JvmExtractorLayout): boolean { daemonStderrBuffer = lines.pop() || ""; for (const line of lines) { if (daemonCurrentRequest) { - parseJvmLine(line, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState); + parseJvmLine(line, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState, daemonCurrentRequest.onOutput); } } }); @@ -1778,14 +1843,15 @@ function waitForDaemonReady(maxWaitMs: number, signal?: AbortSignal): Promise void, - signal?: AbortSignal, - timeoutMs?: number + onArchiveProgress?: (percent: number) => void, + signal?: AbortSignal, + timeoutMs?: number, + onOutput?: (event: ExtractOutputEvent) => void ): Promise { return new Promise((resolve) => { const mode = effectiveConflictMode(conflictMode); @@ -1801,8 +1867,9 @@ function sendDaemonRequest( timeoutMs, parseState, archiveName, - startedAt: Date.now(), - passwordCount: passwordCandidates.length + startedAt: Date.now(), + passwordCount: passwordCandidates.length, + onOutput }; logger.info(`JVM Daemon Request Start: archive=${archiveName}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs || 0}, conflict=${mode}`); @@ -1858,15 +1925,16 @@ function sendDaemonRequest( }); } -async function runJvmExtractCommand( +async function runJvmExtractCommand( layout: JvmExtractorLayout, archivePath: string, targetDir: string, conflictMode: ConflictMode, passwordCandidates: string[], - onArchiveProgress?: (percent: number) => void, - signal?: AbortSignal, - timeoutMs?: number + onArchiveProgress?: (percent: number) => void, + signal?: AbortSignal, + timeoutMs?: number, + onOutput?: (event: ExtractOutputEvent) => void ): Promise { if (signal?.aborted) { return Promise.resolve({ @@ -1879,7 +1947,7 @@ async function runJvmExtractCommand( if (isDaemonAvailable(layout)) { lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority); logger.info(`JVM Daemon: Sofort verfügbar, sende Request für ${path.basename(archivePath)} (pwCandidates=${passwordCandidates.length})`); - return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs); + return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs, onOutput); } if (daemonProcess) { @@ -1891,7 +1959,7 @@ async function runJvmExtractCommand( if (ready) { lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority); logger.info(`JVM Daemon: Bereit nach ${waitedMs}ms — sende Request für ${path.basename(archivePath)}`); - return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs); + return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs, onOutput); } logger.warn(`JVM Daemon: Timeout nach ${waitedMs}ms beim Warten — Fallback auf neuen Prozess für ${path.basename(archivePath)}`); } @@ -1947,7 +2015,7 @@ async function runJvmExtractCommand( const lines = nextBuffer.split(/\r?\n/); const keep = lines.pop() || ""; for (const line of lines) { - parseJvmLine(line, onArchiveProgress, parseState); + parseJvmLine(line, onArchiveProgress, parseState, onOutput); } if (fromStdErr) { stderrBuffer = keep; @@ -2022,8 +2090,8 @@ async function runJvmExtractCommand( }); child.on("close", (code) => { - parseJvmLine(stdoutBuffer, onArchiveProgress, parseState); - parseJvmLine(stderrBuffer, onArchiveProgress, parseState); + parseJvmLine(stdoutBuffer, onArchiveProgress, parseState, onOutput); + parseJvmLine(stderrBuffer, onArchiveProgress, parseState, onOutput); if (abortedBySignal) { finish({ @@ -2102,10 +2170,11 @@ async function runExternalExtractInner( signal: AbortSignal | undefined, timeoutMs: number, hybridMode = false, - onPasswordAttempt?: (attempt: number, total: number) => void, - forceFlatMode = false, - flatModeResult?: { needed: boolean }, - onLog?: ExtractOptions["onLog"] + onPasswordAttempt?: (attempt: number, total: number) => void, + forceFlatMode = false, + flatModeResult?: { needed: boolean }, + onLog?: ExtractOptions["onLog"], + onOutput?: (event: ExtractOutputEvent) => void ): Promise { const passwords = passwordCandidates; let lastError = ""; @@ -2281,11 +2350,12 @@ async function runExternalExtract( onArchiveProgress?: (percent: number) => void, signal?: AbortSignal, hybridMode = false, - onPasswordAttempt?: (attempt: number, total: number) => void, - forceFlatMode = false, - flatModeResult?: { needed: boolean }, - onLog?: ExtractOptions["onLog"] -): Promise { + onPasswordAttempt?: (attempt: number, total: number) => void, + forceFlatMode = false, + flatModeResult?: { needed: boolean }, + onLog?: ExtractOptions["onLog"], + onOutput?: (event: ExtractOutputEvent) => void +): Promise { const timeoutMs = await computeExtractTimeoutMs(archivePath); const configuredBackendMode = extractorBackendMode(); const backendMode = extractorBackendModeForArchive(archivePath); @@ -2317,7 +2387,7 @@ async function runExternalExtract( onLog?.("INFO", `JVM-Extractor vorbereitet: archive=${archiveName}, passwordCandidates=${passwordCandidates.length}, layout=${layout.rootDir}`); const jvmResult = await runJvmExtractCommand( layout, archivePath, targetDir, conflictMode, passwordCandidates, - onArchiveProgress, signal, timeoutMs + onArchiveProgress, signal, timeoutMs, onOutput ); const jvmMs = Date.now() - jvmStartedAt; onLog?.("INFO", `JVM-Extractor Ergebnis: archive=${archiveName}, ok=${jvmResult.ok}, ms=${jvmMs}, timedOut=${jvmResult.timedOut}, aborted=${jvmResult.aborted}, backend=${jvmResult.backend || "unknown"}, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`); @@ -2497,7 +2567,8 @@ async function runExternalExtract( passwordCandidates, onArchiveProgress, signal, - timeoutMs + timeoutMs, + onOutput ); const jvmMs = Date.now() - jvmStartedAt; logger.info(`JVM-Extractor Ergebnis (nach Legacy-Fallback): archive=${archiveName}, ok=${jvmResult.ok}, ms=${jvmMs}, timedOut=${jvmResult.timedOut}, aborted=${jvmResult.aborted}, backend=${jvmResult.backend || "unknown"}, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`); @@ -2575,7 +2646,13 @@ export function selectZipFallbackError(internalError: unknown, externalError: un return externalError; } -async function extractZipArchive(archivePath: string, targetDir: string, conflictMode: ConflictMode, signal?: AbortSignal): Promise { +async function extractZipArchive( + archivePath: string, + targetDir: string, + conflictMode: ConflictMode, + signal?: AbortSignal, + onOutput?: (event: ExtractOutputEvent) => void +): Promise { const mode = effectiveConflictMode(conflictMode); const memoryLimitBytes = zipEntryMemoryLimitBytes(); const zip = new AdmZip(archivePath); @@ -2631,16 +2708,25 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic throw new Error(`ZIP-Eintrag komprimiert zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`); } - let outputPath = baseOutputPath; - let outputKey = pathSetKey(outputPath); - - await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); - const outputExists = usedOutputs.has(outputKey) || await fs.promises.access(outputPath).then(() => true, () => false); - if (outputExists) { - if (mode === "skip") { - continue; - } - if (mode === "rename") { + let outputPath = baseOutputPath; + let outputKey = pathSetKey(outputPath); + let disposition: ExtractOutputEvent["disposition"] = "written"; + + await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); + const outputExists = usedOutputs.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, "/"), + outputPath: baseOutputPath, + state: "complete", + disposition: "skipped" + }); + continue; + } + if (mode === "rename") { const parsed = path.parse(baseOutputPath); const counterKey = pathSetKey(baseOutputPath); let n = renameCounters.get(counterKey) || 1; @@ -2661,9 +2747,12 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic if (signal?.aborted) { throw new Error("aborted:extract"); } - outputPath = candidate; - outputKey = candidateKey; - } + outputPath = candidate; + outputKey = candidateKey; + disposition = "renamed"; + } else { + disposition = "overwritten"; + } } if (signal?.aborted) { @@ -2679,10 +2768,32 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic if (maxDeclaredSize > 0 && data.length > maxDeclaredSize * 20) { throw new Error(`ZIP-Eintrag verdächtig groß nach Entpacken (${entry.entryName})`); } - await fs.promises.writeFile(outputPath, data); - usedOutputs.add(outputKey); - } -} + try { + await fs.promises.writeFile(outputPath, data); + usedOutputs.add(outputKey); + onOutput?.({ + version: 1, + archivePath: path.resolve(archivePath), + entryPath: entry.entryName.replace(/\\/g, "/"), + outputPath, + state: "complete", + disposition + }); + } catch (error) { + if (await fs.promises.access(outputPath).then(() => true, () => false)) { + onOutput?.({ + version: 1, + archivePath: path.resolve(archivePath), + entryPath: entry.entryName.replace(/\\/g, "/"), + outputPath, + state: "partial", + disposition + }); + } + throw error; + } + } +} async function estimateArchivesTotalBytes(candidates: string[]): Promise { let total = 0; @@ -2774,30 +2885,135 @@ function extractProgressFilePath(packageDir: string, packageId?: string): string return path.join(packageDir, EXTRACT_PROGRESS_FILE); } -async function readExtractResumeState(packageDir: string, packageId?: string): Promise> { - const progressPath = extractProgressFilePath(packageDir, packageId); +function relativeResumePath(rootDir: string, filePath: string): string | null { + const relativePath = path.relative(path.resolve(rootDir), path.resolve(filePath)); + if (!relativePath || relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) { + return null; + } + return relativePath.replace(/\\/g, "/"); +} + +function archiveResumeIdentity(archivePath: string, packageDir: string, targetDir: string): string | null { + const packageRelative = relativeResumePath(packageDir, archivePath); + if (packageRelative) { + return `package:${packageRelative}`; + } + const targetRelative = relativeResumePath(targetDir, archivePath); + return targetRelative ? `target:${targetRelative}` : null; +} + +function resolveResumePath(identity: string, packageDir: string, targetDir: string): string | null { + const separator = identity.indexOf(":"); + if (separator <= 0) { + return null; + } + const prefix = identity.slice(0, separator); + const relativePath = identity.slice(separator + 1).replace(/\\/g, "/"); + const segments = relativePath.split("/"); + if (!relativePath || segments.some((segment) => !segment || segment === "..") || path.posix.isAbsolute(relativePath) || /^[a-zA-Z]:/.test(relativePath)) { + return null; + } + const rootDir = prefix === "package" ? packageDir : prefix === "target" ? targetDir : ""; + if (!rootDir) { + return null; + } + const resolved = path.resolve(rootDir, ...segments); + return relativeResumePath(rootDir, resolved) ? resolved : null; +} + +async function buildArchiveFingerprint(archivePath: string, packageDir: string, targetDir: string): Promise { + const members: ExtractResumeMember[] = []; + const candidates = collectArchiveCleanupTargets(archivePath); + for (const memberPath of candidates) { + const identity = archiveResumeIdentity(memberPath, packageDir, targetDir); + if (!identity) { + return []; + } + try { + const stat = await fs.promises.stat(memberPath); + if (!stat.isFile()) { + return []; + } + members.push({ path: identity, size: stat.size, mtimeMs: stat.mtimeMs }); + } catch { + return []; + } + } + return members.sort((left, right) => left.path.localeCompare(right.path)); +} + +function sameArchiveFingerprint(left: readonly ExtractResumeMember[], right: readonly ExtractResumeMember[]): boolean { + return left.length > 0 + && left.length === right.length + && left.every((member, index) => member.path === right[index]?.path + && member.size === right[index]?.size + && member.mtimeMs === right[index]?.mtimeMs); +} + +async function readExtractResumeState(packageDir: string, targetDir: string, packageId?: string): Promise> { + const progressPath = extractProgressFilePath(packageDir, packageId); try { await fs.promises.access(progressPath); } catch { - return new Set(); - } - try { - const payload = JSON.parse(await fs.promises.readFile(progressPath, "utf8")) as Partial; - const names = Array.isArray(payload.completedArchives) ? payload.completedArchives : []; - return new Set(names.map((value) => archiveNameKey(String(value || "").trim())).filter(Boolean)); - } catch { - return new Set(); - } -} - -async function writeExtractResumeState(packageDir: string, completedArchives: Set, packageId?: string): Promise { + return new Map(); + } + try { + const payload = JSON.parse(await fs.promises.readFile(progressPath, "utf8")) as Partial; + if (payload.version !== 2 || !Array.isArray(payload.archives)) { + return new Map(); + } + const archives = new Map(); + for (const rawArchive of payload.archives) { + if (!rawArchive || typeof rawArchive !== "object") { + continue; + } + const archivePath = String(rawArchive.archivePath || "").trim(); + if (!resolveResumePath(archivePath, packageDir, targetDir)) { + continue; + } + const members = Array.isArray(rawArchive.members) + ? rawArchive.members.flatMap((member) => { + const memberPath = String(member?.path || "").trim(); + const size = Number(member?.size); + const mtimeMs = Number(member?.mtimeMs); + return resolveResumePath(memberPath, packageDir, targetDir) + && Number.isFinite(size) && size >= 0 + && Number.isFinite(mtimeMs) && mtimeMs >= 0 + ? [{ path: memberPath, size, mtimeMs }] + : []; + }).sort((left, right) => left.path.localeCompare(right.path)) + : []; + const outputs = Array.isArray(rawArchive.outputs) + ? rawArchive.outputs.flatMap((output) => { + const outputPath = String(output?.path || "").trim().replace(/\\/g, "/"); + const entryPath = String(output?.entryPath || "").trim().replace(/\\/g, "/"); + const disposition = output?.disposition; + const resolvedOutput = resolveResumePath(`target:${outputPath}`, packageDir, targetDir); + return resolvedOutput + && entryPath + && !entryPath.split("/").some((segment) => segment === "..") + && (["written", "overwritten", "renamed"] as const).includes(disposition as ExtractResumeOutput["disposition"]) + ? [{ entryPath, path: outputPath, disposition: disposition as ExtractResumeOutput["disposition"] }] + : []; + }) + : []; + if (members.length > 0 && outputs.length > 0) { + archives.set(archivePath.toLocaleLowerCase("en-US"), { archivePath, members, outputs }); + } + } + return archives; + } catch { + return new Map(); + } +} + +async function writeExtractResumeState(packageDir: string, completedArchives: ReadonlyMap, packageId?: string): Promise { try { await fs.promises.mkdir(packageDir, { recursive: true }); - const progressPath = extractProgressFilePath(packageDir, packageId); - const payload: ExtractResumeState = { - completedArchives: Array.from(completedArchives) - .map((name) => archiveNameKey(name)) - .sort((a, b) => a.localeCompare(b)) + const progressPath = extractProgressFilePath(packageDir, packageId); + const payload: ExtractResumeState = { + version: 2, + archives: [...completedArchives.values()].sort((left, right) => left.archivePath.localeCompare(right.archivePath)) }; const tmpPath = progressPath + "." + Date.now() + "." + Math.random().toString(36).slice(2, 8) + ".tmp"; await fs.promises.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8"); @@ -2841,10 +3057,15 @@ function effectiveConflictMode(conflictMode: ConflictMode): "overwrite" | "skip" return "skip"; } -export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted: number; failed: number; lastError: string }> { - if (options.signal?.aborted) { - throw new Error("aborted:extract"); - } +export async function extractPackageArchives(options: ExtractOptions): Promise { + if (options.signal?.aborted) { + throw new Error("aborted:extract"); + } + const outputScope = new PackageOutputScope([options.targetDir]); + const emitOutput = (event: ExtractOutputEvent): void => { + outputScope.add(event); + options.onOutput?.(event); + }; options.onProgress?.({ current: 0, total: 0, percent: 0, archiveName: "Archive scannen...", phase: "preparing" }); const allCandidates = await findArchiveCandidates(options.packageDir); const candidates = options.onlyArchives @@ -2864,12 +3085,29 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ await checkDiskSpaceForExtraction(options.targetDir, candidates); } - if (candidates.length === 0) { - if (!options.onlyArchives) { - const existingResume = await readExtractResumeState(options.packageDir, options.packageId); - if (existingResume.size > 0 && await hasAnyEntries(options.targetDir)) { - await clearExtractResumeState(options.packageDir, options.packageId); - logger.info(`Entpacken übersprungen (Archive bereinigt, Ziel hat Dateien): ${options.packageDir}`); + if (candidates.length === 0) { + if (!options.onlyArchives) { + const existingResume = await readExtractResumeState(options.packageDir, options.targetDir, options.packageId); + const validResume = [...existingResume.values()].filter((archive) => archive.outputs.length > 0); + const allOutputsExist = validResume.length > 0 + && (await Promise.all(validResume.map((archive) => resumeOutputsExist(archive, options.packageDir, options.targetDir)))).every(Boolean); + if (allOutputsExist) { + for (const archive of validResume) { + const archivePath = resolveResumePath(archive.archivePath, options.packageDir, options.targetDir) as string; + for (const output of archive.outputs) { + const outputPath = resolveResumePath(`target:${output.path}`, options.packageDir, options.targetDir) as string; + outputScope.add({ + version: 1, + archivePath, + entryPath: output.entryPath, + outputPath, + state: "complete", + disposition: output.disposition + }); + } + } + await clearExtractResumeState(options.packageDir, options.packageId); + logger.info(`Entpacken übersprungen (Archive bereinigt, Ziel hat Dateien): ${options.packageDir}`); options.onProgress?.({ current: existingResume.size, total: existingResume.size, @@ -2877,12 +3115,13 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ archiveName: "", phase: "done" }); - return { extracted: existingResume.size, failed: 0, lastError: "" }; + outputScope.pruneMissing(); + return { extracted: existingResume.size, failed: 0, lastError: "", outputFiles: outputScope.completeFiles() }; } await clearExtractResumeState(options.packageDir, options.packageId); } logger.info(`Entpacken übersprungen (keine Archive gefunden): ${options.packageDir}`); - return { extracted: 0, failed: 0, lastError: "" }; + return { extracted: 0, failed: 0, lastError: "", outputFiles: [] }; } const conflictMode = effectiveConflictMode(options.conflictMode); @@ -2898,28 +3137,41 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ logger.info(`Passwort-Cache Treffer: ${passwordCacheLabel}, bekanntes Passwort wird zuerst getestet`); options.onLog?.("INFO", `Passwort-Cache Treffer: ${passwordCacheLabel}, bekanntes Passwort wird zuerst getestet`); } - const resumeCompleted = await readExtractResumeState(options.packageDir, options.packageId); - const resumeCompletedAtStart = resumeCompleted.size; - const allCandidateNames = new Set(allCandidates.map((archivePath) => archiveNameKey(path.basename(archivePath)))); - for (const archiveName of Array.from(resumeCompleted.values())) { - // Nested-archive progress (keyed "nested:") has no top-level candidate on - // disk to validate against, so it must NOT be pruned here — otherwise every - // extractPackageArchives call wiped it and nested archives were re-extracted on - // resume. It is cleared together with the rest once the package fully completes. - if (archiveName.startsWith("nested:")) { - continue; - } - if (!allCandidateNames.has(archiveName)) { - resumeCompleted.delete(archiveName); - } - } + const resumeCompleted = await readExtractResumeState(options.packageDir, options.targetDir, options.packageId); + const resumeCompletedAtStart = resumeCompleted.size; + const resumedArchivePaths = new Set(); + for (const archivePath of candidates) { + const identity = archiveResumeIdentity(archivePath, options.packageDir, options.targetDir); + const saved = identity ? resumeCompleted.get(identity.toLocaleLowerCase("en-US")) : undefined; + if (!identity || !saved) { + continue; + } + const currentFingerprint = await buildArchiveFingerprint(archivePath, options.packageDir, options.targetDir); + if (!sameArchiveFingerprint(saved.members, currentFingerprint) + || !(await resumeOutputsExist(saved, options.packageDir, options.targetDir))) { + resumeCompleted.delete(identity.toLocaleLowerCase("en-US")); + continue; + } + resumedArchivePaths.add(pathSetKey(archivePath)); + for (const output of saved.outputs) { + const outputPath = resolveResumePath(`target:${output.path}`, options.packageDir, options.targetDir) as string; + outputScope.add({ + version: 1, + archivePath: path.resolve(archivePath), + entryPath: output.entryPath, + outputPath, + state: "complete", + disposition: output.disposition + }); + } + } if (resumeCompleted.size > 0) { await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); } else { await clearExtractResumeState(options.packageDir, options.packageId); } - const pendingCandidates = candidates.filter((archivePath) => !resumeCompleted.has(archiveNameKey(path.basename(archivePath)))); + const pendingCandidates = candidates.filter((archivePath) => !resumedArchivePaths.has(pathSetKey(archivePath))); let extracted = candidates.length - pendingCandidates.length; let failed = 0; let lastError = ""; @@ -2929,7 +3181,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ const skippedNonArchives = new Set(); const failedArchiveCategories = new Map(); for (const archivePath of candidates) { - if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) { + if (resumedArchivePaths.has(pathSetKey(archivePath))) { const resumedName = path.basename(archivePath); const resumedIsGenericSplit = /\.\d{3}$/i.test(resumedName) && !/\.(zip|7z)\.\d{3}$/i.test(resumedName); if (resumedIsGenericSplit && !(await detectArchiveSignature(archivePath))) { @@ -2998,8 +3250,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ emitProgress(extracted, "", "extracting"); - for (const archivePath of candidates) { - if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) { + for (const archivePath of candidates) { + if (resumedArchivePaths.has(pathSetKey(archivePath))) { emitProgress(extracted, path.basename(archivePath), "extracting", 100, 0, undefined, { archiveDone: true, archiveSuccess: true }, archivePath); } } @@ -3016,7 +3268,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ throw new Error("noextractor:skipped"); } const archiveName = path.basename(archivePath); - const archiveResumeKey = archiveNameKey(archiveName); + const archiveResumeKey = archiveResumeIdentity(archivePath, options.packageDir, options.targetDir)?.toLocaleLowerCase("en-US") || ""; const archiveStartedAt = Date.now(); const startedCurrent = extracted + failed; if (lastArchiveFinishedAt !== null) { @@ -3053,9 +3305,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ if (!sig) { logger.info(`Generische Split-Datei übersprungen (keine Archiv-Signatur): ${archiveName}`); extracted += 1; - resumeCompleted.add(archiveResumeKey); - skippedNonArchives.add(pathSetKey(archivePath)); - await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); + skippedNonArchives.add(pathSetKey(archivePath)); clearInterval(pulseTimer); archiveOutcome = "skipped"; const skippedAt = Date.now(); @@ -3089,18 +3339,18 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ try { const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => { reportArchiveProgress(value); - }, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog); + }, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog, emitOutput); rememberLearnedPassword(usedPassword); } catch (error) { if (isNoExtractorError(String(error))) { - await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal); + await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput); } else { throw error; } } } else { try { - await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal); + await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput); archivePercent = 100; } catch (error) { if (!shouldFallbackToExternalZip(error)) { @@ -3109,7 +3359,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ try { const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => { reportArchiveProgress(value); - }, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog); + }, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog, emitOutput); rememberLearnedPassword(usedPassword); } catch (externalError) { throw selectZipFallbackError(error, externalError); @@ -3120,15 +3370,18 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ const flatResult = { needed: false }; const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => { reportArchiveProgress(value); - }, options.signal, hybrid, onPwAttempt, packageNeedsFlatMode, flatResult, options.onLog); + }, options.signal, hybrid, onPwAttempt, packageNeedsFlatMode, flatResult, options.onLog, emitOutput); rememberLearnedPassword(usedPassword); if (flatResult.needed) packageNeedsFlatMode = true; } extracted += 1; extractedArchives.add(archivePath); failedArchiveCategories.delete(archivePath); - resumeCompleted.add(archiveResumeKey); - await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); + const resumeArchive = await buildResumeArchive(archivePath, options.packageDir, options.targetDir, outputScope.records()); + if (archiveResumeKey && resumeArchive) { + resumeCompleted.set(archiveResumeKey, resumeArchive); + await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); + } logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`); options.onLog?.("INFO", `Entpacken erfolgreich: ${path.basename(archivePath)}`); archiveOutcome = "success"; @@ -3248,7 +3501,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ if (abortError) throw new Error("aborted:extract"); if (failed > 0 && extracted === 0) { - const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumeCompleted.has(archiveNameKey(path.basename(ap)))); + const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumedArchivePaths.has(pathSetKey(ap))); const failedCategories = failedArchives.map((archivePath) => failedArchiveCategories.get(archivePath) || "unknown"); if (failedArchives.length > 0 && shouldSerialRetryParallelFailures(extracted, failedCategories)) { const categorySummary = [...new Set(failedCategories)].join(","); @@ -3275,7 +3528,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ } if (failed > 0 && extracted > 0) { - const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumeCompleted.has(archiveNameKey(path.basename(ap)))); + const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumedArchivePaths.has(pathSetKey(ap))); if (failedArchives.length > 0) { logger.info(`Serielle Wiederholung: ${failedArchives.length} fehlgeschlagene Archive werden einzeln wiederholt (mögliche Parallelitäts-Kollision)`); let retryRecovered = 0; @@ -3308,8 +3561,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ if (extracted > 0 && failed === 0 && !options.skipPostCleanup && !options.onlyArchives) { try { - const nestedCandidates = (await findArchiveCandidates(options.targetDir)) - .filter((p) => !NESTED_EXTRACT_BLACKLIST_RE.test(p)); + const nestedCandidates = outputScope.archiveFiles() + .filter((p) => !NESTED_EXTRACT_BLACKLIST_RE.test(p)); if (nestedCandidates.length > 0) { logger.info(`Nested-Extraction: ${nestedCandidates.length} Archive im Output gefunden`); let nestedExtracted = 0; @@ -3321,13 +3574,31 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ nestedCandidates.length = 0; } for (const nestedArchive of nestedCandidates) { - if (options.signal?.aborted) throw new Error("aborted:extract"); - const nestedName = path.basename(nestedArchive); - const nestedKey = archiveNameKey(`nested:${nestedName}`); - if (resumeCompleted.has(nestedKey)) { - logger.info(`Nested-Extraction übersprungen (bereits entpackt): ${nestedName}`); - continue; - } + if (options.signal?.aborted) throw new Error("aborted:extract"); + const nestedName = path.basename(nestedArchive); + const nestedIdentity = archiveResumeIdentity(nestedArchive, options.packageDir, options.targetDir); + const nestedKey = nestedIdentity?.toLocaleLowerCase("en-US") || ""; + const savedNested = nestedKey ? resumeCompleted.get(nestedKey) : undefined; + const nestedFingerprint = savedNested + ? await buildArchiveFingerprint(nestedArchive, options.packageDir, options.targetDir) + : []; + if (savedNested + && sameArchiveFingerprint(savedNested.members, nestedFingerprint) + && await resumeOutputsExist(savedNested, options.packageDir, options.targetDir)) { + for (const output of savedNested.outputs) { + const outputPath = resolveResumePath(`target:${output.path}`, options.packageDir, options.targetDir) as string; + outputScope.add({ + version: 1, + archivePath: nestedArchive, + entryPath: output.entryPath, + outputPath, + state: "complete", + disposition: output.disposition + }); + } + logger.info(`Nested-Extraction übersprungen (bereits entpackt): ${nestedName}`); + continue; + } const nestedStartedAt = Date.now(); let nestedPercent = 0; emitProgress(extracted + failed, `nested: ${nestedName}`, "extracting", nestedPercent, 0, undefined, undefined, nestedArchive); @@ -3340,22 +3611,25 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ const ext = path.extname(nestedArchive).toLowerCase(); if (ext === ".zip" && !(await shouldPreferExternalZip(nestedArchive))) { try { - await extractZipArchive(nestedArchive, options.targetDir, options.conflictMode, options.signal); + await extractZipArchive(nestedArchive, options.targetDir, options.conflictMode, options.signal, emitOutput); nestedPercent = 100; } catch (zipErr) { if (!shouldFallbackToExternalZip(zipErr)) throw zipErr; - const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, options.signal, hybrid, undefined, false, undefined, options.onLog); + const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, options.signal, hybrid, undefined, false, undefined, options.onLog, emitOutput); rememberLearnedPassword(usedPw); } } else { - const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, options.signal, hybrid, undefined, false, undefined, options.onLog); + const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, options.signal, hybrid, undefined, false, undefined, options.onLog, emitOutput); rememberLearnedPassword(usedPw); } extracted += 1; nestedExtracted += 1; extractedArchives.add(nestedArchive); - resumeCompleted.add(nestedKey); - await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); + const nestedResume = await buildResumeArchive(nestedArchive, options.packageDir, options.targetDir, outputScope.records()); + if (nestedKey && nestedResume) { + resumeCompleted.set(nestedKey, nestedResume); + await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); + } logger.info(`Nested-Entpacken erfolgreich: ${nestedName}`); if (options.cleanupMode !== "none") { await cleanupArchives([nestedArchive], options.cleanupMode); @@ -3450,5 +3724,59 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ logger.info(`Entpacken beendet: extracted=${extracted}, failed=${failed}, targetDir=${options.targetDir}`); - return { extracted, failed, lastError }; -} + outputScope.pruneMissing(); + return { extracted, failed, lastError, outputFiles: outputScope.completeFiles() }; +} + +async function resumeOutputsExist( + archive: ExtractResumeArchive, + packageDir: string, + targetDir: string +): Promise { + const archivePath = resolveResumePath(archive.archivePath, packageDir, targetDir); + if (!archivePath || archive.outputs.length === 0) { + return false; + } + const scope = new PackageOutputScope([targetDir]); + try { + for (const output of archive.outputs) { + const outputPath = resolveResumePath(`target:${output.path}`, packageDir, targetDir); + if (!outputPath) { + return false; + } + scope.add({ + version: 1, + archivePath, + entryPath: output.entryPath, + outputPath, + state: "complete", + disposition: output.disposition + }); + } + } catch { + return false; + } + return scope.completeFiles().length === archive.outputs.length; +} + +async function buildResumeArchive( + archivePath: string, + packageDir: string, + targetDir: string, + outputRecords: readonly ExtractOutputEvent[] +): Promise { + const archiveIdentity = archiveResumeIdentity(archivePath, packageDir, targetDir); + const members = await buildArchiveFingerprint(archivePath, packageDir, targetDir); + if (!archiveIdentity || members.length === 0) { + return null; + } + const archiveKey = pathSetKey(path.resolve(archivePath)); + const outputs = outputRecords.flatMap((record) => { + if (record.state !== "complete" || record.disposition === "skipped" || pathSetKey(record.archivePath) !== archiveKey) { + return []; + } + const outputPath = relativeResumePath(targetDir, record.outputPath); + return outputPath ? [{ entryPath: record.entryPath, path: outputPath, disposition: record.disposition }] : []; + }); + return outputs.length > 0 ? { archivePath: archiveIdentity, members, outputs } : null; +} diff --git a/src/main/package-output-scope.ts b/src/main/package-output-scope.ts new file mode 100644 index 0000000..cec6ed5 --- /dev/null +++ b/src/main/package-output-scope.ts @@ -0,0 +1,220 @@ +import fs from "node:fs"; +import path from "node:path"; + +export type ExtractOutputState = "complete" | "partial"; +export type ExtractOutputDisposition = "written" | "overwritten" | "renamed" | "skipped"; + +export interface ExtractOutputEvent { + version: 1; + archivePath: string; + entryPath: string; + outputPath: string; + state: ExtractOutputState; + disposition: ExtractOutputDisposition; +} + +export class PackageOutputScope { + private readonly authorizedRoots: string[]; + + private readonly outputRecords = new Map(); + + public constructor(authorizedRoots: readonly string[], records: readonly ExtractOutputEvent[] = []) { + this.authorizedRoots = [...new Map( + authorizedRoots + .map((root) => path.resolve(String(root || "").trim())) + .filter(Boolean) + .map((root) => [this.pathKey(root), root]) + ).values()]; + if (this.authorizedRoots.length === 0) { + throw new Error("PackageOutputScope benötigt mindestens einen autorisierten Root"); + } + this.addMany(records); + } + + private pathKey(value: string): string { + return path.resolve(value).replace(/[\\/]+$/, "").toLocaleLowerCase("en-US"); + } + + private validateEntryPath(entryPath: string): string { + const normalized = String(entryPath || "").trim().replace(/\\/g, "/"); + const segments = normalized.split("/"); + if (!normalized + || normalized.startsWith("/") + || /^[a-zA-Z]:/.test(normalized) + || path.posix.isAbsolute(normalized) + || segments.some((segment) => segment === ".." || segment === "")) { + throw new Error(`Ungültiger Archive-Entry-Ausgabepfad: ${entryPath}`); + } + return segments.filter((segment) => segment !== ".").join("/"); + } + + private findAuthorizedRoot(outputPath: string): string { + const resolvedOutput = path.resolve(outputPath); + const outputKey = this.pathKey(resolvedOutput); + const root = this.authorizedRoots.find((candidate) => { + const rootKey = this.pathKey(candidate); + return outputKey === rootKey || outputKey.startsWith(`${rootKey}${path.sep.toLocaleLowerCase("en-US")}`); + }); + if (!root || this.pathKey(root) === outputKey) { + throw new Error(`Ausgabepfad liegt außerhalb eines autorisierten Roots: ${outputPath}`); + } + return root; + } + + private rejectLinkedPath(outputPath: string, authorizedRoot: string): void { + let current = path.resolve(outputPath); + const rootKey = this.pathKey(authorizedRoot); + while (true) { + try { + const stat = fs.lstatSync(current); + if (stat.isSymbolicLink()) { + throw new Error(`Symbolischer Link oder Reparse Point im Ausgabepfad: ${outputPath}`); + } + } catch (error) { + const code = String((error as NodeJS.ErrnoException)?.code || ""); + if (code !== "ENOENT") { + throw error; + } + } + if (this.pathKey(current) === rootKey) { + break; + } + const parent = path.dirname(current); + if (parent === current) { + throw new Error(`Ausgabepfad liegt außerhalb eines autorisierten Roots: ${outputPath}`); + } + current = parent; + } + } + + private normalizeEvent(event: ExtractOutputEvent): ExtractOutputEvent { + if (Number(event.version) !== 1) { + throw new Error(`Nicht unterstützte Extract-Output-Version: ${String(event.version)}`); + } + if (!path.isAbsolute(String(event.archivePath || ""))) { + throw new Error(`Archivpfad muss absolut sein: ${event.archivePath}`); + } + if (event.state !== "complete" && event.state !== "partial") { + throw new Error(`Ungültiger Extract-Output-Status: ${String(event.state)}`); + } + if (!(["written", "overwritten", "renamed", "skipped"] as const).includes(event.disposition)) { + throw new Error(`Ungültige Extract-Output-Disposition: ${String(event.disposition)}`); + } + const entryPath = this.validateEntryPath(event.entryPath); + if (!path.isAbsolute(String(event.outputPath || ""))) { + throw new Error(`Finaler Ausgabepfad muss absolut sein: ${event.outputPath}`); + } + const outputPath = path.resolve(event.outputPath); + const authorizedRoot = this.findAuthorizedRoot(outputPath); + this.rejectLinkedPath(outputPath, authorizedRoot); + if (event.disposition !== "skipped") { + let stat: fs.Stats; + try { + stat = fs.lstatSync(outputPath); + } catch { + throw new Error(`Gemeldete Extract-Ausgabe existiert nicht: ${outputPath}`); + } + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`Gemeldete Extract-Ausgabe ist keine reguläre Datei: ${outputPath}`); + } + } + return { + version: 1, + archivePath: path.resolve(event.archivePath), + entryPath, + outputPath, + state: event.state, + disposition: event.disposition + }; + } + + public add(event: ExtractOutputEvent): boolean { + const normalized = this.normalizeEvent(event); + if (normalized.disposition === "skipped") { + return false; + } + const key = this.pathKey(normalized.outputPath); + const current = this.outputRecords.get(key); + if (current) { + if (current.state === "partial" && normalized.state === "complete") { + this.outputRecords.set(key, { ...normalized, outputPath: current.outputPath }); + } + return false; + } + this.outputRecords.set(key, normalized); + return true; + } + + public addMany(events: readonly ExtractOutputEvent[]): number { + let added = 0; + for (const event of events) { + if (this.add(event)) { + added += 1; + } + } + return added; + } + + public records(): ExtractOutputEvent[] { + return [...this.outputRecords.values()]; + } + + public completeFiles(): string[] { + return this.records().filter((record) => record.state === "complete").map((record) => record.outputPath); + } + + public partialFiles(): string[] { + return this.records().filter((record) => record.state === "partial").map((record) => record.outputPath); + } + + public files(): string[] { + return this.records().map((record) => record.outputPath); + } + + public archiveFiles(): string[] { + return this.completeFiles().filter((filePath) => /\.(?:7z|rar|zip|tar|gz|bz2|xz|001)$/i.test(filePath)); + } + + public replacePath(sourcePath: string, targetPath: string, state?: ExtractOutputState): boolean { + const sourceKey = this.pathKey(sourcePath); + const current = this.outputRecords.get(sourceKey); + if (!current) { + return false; + } + const next = this.normalizeEvent({ + ...current, + outputPath: targetPath, + entryPath: path.basename(targetPath), + state: state || current.state, + disposition: targetPath === current.outputPath ? current.disposition : "renamed" + }); + this.outputRecords.delete(sourceKey); + this.outputRecords.set(this.pathKey(next.outputPath), next); + return true; + } + + public removePath(outputPath: string): boolean { + return this.outputRecords.delete(this.pathKey(outputPath)); + } + + public has(outputPath: string): boolean { + return this.outputRecords.has(this.pathKey(outputPath)); + } + + public pruneMissing(): number { + let removed = 0; + for (const record of this.records()) { + try { + const stat = fs.lstatSync(record.outputPath); + if (stat.isFile() && !stat.isSymbolicLink()) { + continue; + } + } catch { + } + if (this.removePath(record.outputPath)) { + removed += 1; + } + } + return removed; + } +} diff --git a/tests/extractor-jvm.test.ts b/tests/extractor-jvm.test.ts index 334b95c..bc2b065 100644 --- a/tests/extractor-jvm.test.ts +++ b/tests/extractor-jvm.test.ts @@ -47,23 +47,76 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b fs.mkdirSync(packageDir, { recursive: true }); const zipPath = path.join(packageDir, "release.zip"); - const zip = new AdmZip(); - zip.addFile("episode.txt", Buffer.from("ok")); - zip.writeZip(zipPath); - - const result = await extractPackageArchives({ + const zip = new AdmZip(); + zip.addFile("episode.txt", Buffer.from("ok")); + zip.writeZip(zipPath); + const events: import("../src/main/extractor").ExtractOutputEvent[] = []; + + const result = await extractPackageArchives({ packageDir, targetDir, cleanupMode: "none", conflictMode: "overwrite", removeLinks: false, - removeSamples: false - }); + removeSamples: false, + onOutput: (event) => events.push(event) + }); expect(result.extracted).toBe(1); - expect(result.failed).toBe(0); - expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true); - }); + expect(result.failed).toBe(0); + expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true); + expect(events).toEqual([ + expect.objectContaining({ + version: 1, + archivePath: path.resolve(zipPath), + entryPath: "episode.txt", + outputPath: path.join(targetDir, "episode.txt"), + state: "complete", + disposition: "written" + }) + ]); + expect(result.outputFiles).toEqual([path.join(targetDir, "episode.txt")]); + }); + + it.each(["7zjbinding", "zip4j"])("emits versioned Base64 output lines from %s", (backend) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-output-${backend}-`)); + tempDirs.push(root); + const targetDir = path.join(root, "out"); + const zipPath = path.join(root, "release.zip"); + const zip = new AdmZip(); + zip.addFile("folder/episode.txt", Buffer.from("ok")); + 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).toBe(0); + const outputLine = String(run.stdout).split(/\r?\n/).find((line) => line.startsWith("RD_OUTPUT ")); + expect(outputLine).toBeTruthy(); + const fields = String(outputLine).split(" "); + expect(fields.slice(0, 5)).toEqual(["RD_OUTPUT", "1", "complete", "written", fields[4]]); + expect(Buffer.from(fields[4], "base64").toString("utf8")).toBe(path.resolve(zipPath)); + expect(Buffer.from(fields[5], "base64").toString("utf8")).toBe("folder/episode.txt"); + expect(Buffer.from(fields[6], "base64").toString("utf8")).toBe(path.join(targetDir, "folder", "episode.txt")); + }); 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 3dec775..4989aff 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -16,7 +16,7 @@ import { selectZipFallbackError, shouldSerialRetryParallelFailures, findArchiveCandidates, - orderExtractorCandidatesForArchive, + orderExtractorCandidatesForArchive, resolveExtractorBackendModeForArchive, resolveExtractorBackendMode, shouldFallbackLegacyRarToJvm, @@ -610,7 +610,7 @@ describe("extractor", () => { expect(selectZipFallbackError(internalError, externalError)).toBe(externalError); }); - it.skipIf(process.platform !== "win32")("matches resume-state archive names case-insensitively on Windows", async () => { + it.skipIf(process.platform !== "win32")("invalidates legacy basename-only resume state on Windows", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); @@ -630,8 +630,8 @@ describe("extractor", () => { removeSamples: false }); - expect(result.extracted).toBe(1); - expect(result.failed).toBe(0); + expect(result.extracted).toBe(0); + expect(result.failed).toBe(1); }); describe("disk space check", () => { @@ -1256,7 +1256,7 @@ describe("extractor", () => { }); }); - describe("orderExtractorCandidatesForArchive", () => { + describe("orderExtractorCandidatesForArchive", () => { it("prefers RAR-native CLIs over 7-Zip for rar archives", () => { const ordered = orderExtractorCandidatesForArchive( ["7z.exe", "Rar.exe", "UnRAR.exe", "WinRAR.exe"], @@ -1283,5 +1283,189 @@ describe("extractor", () => { expect(ordered[0]).toBe("WinRAR.exe"); expect(ordered[1]).toBe("UnRAR.exe"); }); - }); -}); + }); + + describe("direct output scope", () => { + it.each([ + ["overwrite", "new", "overwritten", ["episode.mkv"]], + ["ask", "old", "skipped", []], + ["rename", "old", "renamed", ["episode (1).mkv"]] + ] as const)("emits exact internal ZIP outputs for %s conflicts", async (conflictMode, originalContent, disposition, outputNames) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-direct-${conflictMode}-`)); + 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 }); + fs.writeFileSync(path.join(targetDir, "episode.mkv"), "old"); + const archivePath = path.join(packageDir, "release.zip"); + const zip = new AdmZip(); + zip.addFile("episode.mkv", Buffer.from("new")); + zip.writeZip(archivePath); + const events: import("../src/main/extractor").ExtractOutputEvent[] = []; + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode, + removeLinks: false, + removeSamples: false, + onOutput: (event) => events.push(event) + }); + + expect(fs.readFileSync(path.join(targetDir, "episode.mkv"), "utf8")).toBe(originalContent); + expect(events).toEqual([expect.objectContaining({ + version: 1, + archivePath: path.resolve(archivePath), + entryPath: "episode.mkv", + outputPath: path.join(targetDir, outputNames[0] || "episode.mkv"), + state: "complete", + disposition + })]); + expect(result.outputFiles.map((filePath) => path.basename(filePath))).toEqual([...outputNames]); + }); + + it("extracts only nested archives produced by the package in a shared root", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-direct-nested-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "shared"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + const foreign = new AdmZip(); + foreign.addFile("foreign.txt", Buffer.from("foreign")); + foreign.writeZip(path.join(targetDir, "foreign.zip")); + const nested = new AdmZip(); + nested.addFile("owned.txt", Buffer.from("owned")); + const outer = new AdmZip(); + outer.addFile("owned.zip", nested.toBuffer()); + outer.writeZip(path.join(packageDir, "outer.zip")); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(2); + expect(fs.existsSync(path.join(targetDir, "owned.txt"))).toBe(true); + expect(fs.existsSync(path.join(targetDir, "foreign.txt"))).toBe(false); + }); + + it("resumes same-basename archives by relative path and invalidates changed multipart fingerprints", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-v2-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + const firstDir = path.join(packageDir, "first"); + const secondDir = path.join(packageDir, "second"); + fs.mkdirSync(firstDir, { recursive: true }); + fs.mkdirSync(secondDir, { recursive: true }); + const firstArchive = path.join(firstDir, "release.zip"); + const secondArchive = path.join(secondDir, "release.zip"); + const companionPath = path.join(firstDir, "release.sfv"); + const firstZip = new AdmZip(); + firstZip.addFile("first.txt", Buffer.from("first")); + firstZip.writeZip(firstArchive); + fs.writeFileSync(companionPath, "part-a"); + const secondZip = new AdmZip(); + secondZip.addFile("second.txt", Buffer.from("second")); + secondZip.writeZip(secondArchive); + + const firstResult = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + onlyArchives: new Set([path.resolve(firstArchive).toLowerCase()]) + }); + expect(firstResult.extracted).toBe(1); + expect(firstResult.failed).toBe(0); + expect(fs.existsSync(path.join(packageDir, ".rd_extract_progress.json"))).toBe(true); + fs.writeFileSync(companionPath, "part-b-changed"); + const emittedArchives: string[] = []; + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + onOutput: (event) => emittedArchives.push(event.archivePath) + }); + + expect(result.extracted).toBe(2); + expect(emittedArchives).toContain(path.resolve(firstArchive)); + expect(emittedArchives).toContain(path.resolve(secondArchive)); + expect(fs.existsSync(path.join(targetDir, "second.txt"))).toBe(true); + }); + + it("fails closed for an unknown resume-state version", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-unknown-")); + 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 }); + fs.writeFileSync(path.join(packageDir, "broken.zip"), "not-a-zip"); + fs.writeFileSync(path.join(targetDir, "foreign.txt"), "foreign"); + fs.writeFileSync(path.join(packageDir, ".rd_extract_progress.json"), JSON.stringify({ + version: 99, + completedArchives: ["broken.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); + }); + + it("retains concrete completed outputs when a later entry aborts", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-abort-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + const archivePath = path.join(packageDir, "release.zip"); + const zip = new AdmZip(); + zip.addFile("first.txt", Buffer.from("first")); + zip.addFile("second.txt", Buffer.from("second")); + zip.writeZip(archivePath); + const controller = new AbortController(); + const events: import("../src/main/extractor").ExtractOutputEvent[] = []; + + await expect(extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + signal: controller.signal, + onOutput: (event) => { + events.push(event); + controller.abort(); + } + })).rejects.toThrow("aborted:extract"); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual(expect.objectContaining({ state: "complete", outputPath: path.join(targetDir, "first.txt") })); + expect(fs.existsSync(path.join(targetDir, "first.txt"))).toBe(true); + expect(fs.existsSync(path.join(targetDir, "second.txt"))).toBe(false); + }); + + }); +}); diff --git a/tests/package-output-scope.test.ts b/tests/package-output-scope.test.ts new file mode 100644 index 0000000..8fa57d1 --- /dev/null +++ b/tests/package-output-scope.test.ts @@ -0,0 +1,159 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { PackageOutputScope } from "../src/main/package-output-scope"; + +const tempDirs: string[] = []; + +function createRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-scope-")); + tempDirs.push(root); + return root; +} + +afterEach(() => { + for (const directory of tempDirs.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("PackageOutputScope", () => { + it("normalizes and deduplicates complete Windows paths case-insensitively", () => { + const root = createRoot(); + const outputPath = path.join(root, "Season", "Episode.mkv"); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, "video"); + const scope = new PackageOutputScope([root]); + + scope.add({ + version: 1, + archivePath: path.join(root, "archive-a.rar"), + entryPath: "Season/Episode.mkv", + outputPath, + state: "complete", + disposition: "written" + }); + scope.add({ + version: 1, + archivePath: path.join(root, "archive-b.rar"), + entryPath: "season/episode.mkv", + outputPath: outputPath.toUpperCase(), + state: "complete", + disposition: "overwritten" + }); + + expect(scope.completeFiles()).toEqual([path.resolve(outputPath)]); + expect(scope.partialFiles()).toEqual([]); + expect(scope.records()).toHaveLength(1); + }); + + it.each([ + "../foreign.mkv", + "folder/../../foreign.mkv", + "/absolute.mkv", + "C:\\absolute.mkv" + ])("rejects unsafe archive entry path %s", (entryPath) => { + const root = createRoot(); + const outputPath = path.join(root, "safe.mkv"); + fs.writeFileSync(outputPath, "video"); + const scope = new PackageOutputScope([root]); + + expect(() => scope.add({ + version: 1, + archivePath: path.join(root, "archive.rar"), + entryPath, + outputPath, + state: "complete", + disposition: "written" + })).toThrow(/Ausgabepfad|entry/i); + }); + + it("rejects outputs outside authorized roots and unknown event versions", () => { + const root = createRoot(); + const foreignRoot = createRoot(); + const foreignPath = path.join(foreignRoot, "foreign.mkv"); + fs.writeFileSync(foreignPath, "foreign"); + const scope = new PackageOutputScope([root]); + const event = { + version: 1 as const, + archivePath: path.join(root, "archive.rar"), + entryPath: "foreign.mkv", + outputPath: foreignPath, + state: "complete" as const, + disposition: "written" as const + }; + + expect(() => scope.add(event)).toThrow(/autorisiert/i); + expect(() => scope.add({ ...event, outputPath: path.join(root, "safe.mkv"), version: 2 as 1 })).toThrow(/Version/i); + }); + + it("rejects symlinked parent chains and keeps complete and partial outputs separate", () => { + const root = createRoot(); + const foreignRoot = createRoot(); + const completePath = path.join(root, "complete.mkv"); + const partialPath = path.join(root, "partial.mkv"); + fs.writeFileSync(completePath, "complete"); + fs.writeFileSync(partialPath, "partial"); + const scope = new PackageOutputScope([root]); + + scope.add({ + version: 1, + archivePath: path.join(root, "archive.rar"), + entryPath: "complete.mkv", + outputPath: completePath, + state: "complete", + disposition: "written" + }); + scope.add({ + version: 1, + archivePath: path.join(root, "archive.rar"), + entryPath: "partial.mkv", + outputPath: partialPath, + state: "partial", + disposition: "written" + }); + + expect(scope.completeFiles()).toEqual([path.resolve(completePath)]); + expect(scope.partialFiles()).toEqual([path.resolve(partialPath)]); + + const link = path.join(root, "linked"); + try { + fs.symlinkSync(foreignRoot, link, "junction"); + } catch { + return; + } + const linkedOutput = path.join(link, "linked.mkv"); + fs.writeFileSync(path.join(foreignRoot, "linked.mkv"), "foreign"); + expect(() => scope.add({ + version: 1, + archivePath: path.join(root, "archive.rar"), + entryPath: "linked/linked.mkv", + outputPath: linkedOutput, + state: "complete", + disposition: "written" + })).toThrow(/symbol|reparse/i); + }); + + it("updates ownership after rename and removal without enumerating the root", () => { + const root = createRoot(); + const sourcePath = path.join(root, "source.mkv"); + const targetPath = path.join(root, "renamed.mkv"); + fs.writeFileSync(sourcePath, "video"); + const scope = new PackageOutputScope([root]); + scope.add({ + version: 1, + archivePath: path.join(root, "archive.rar"), + entryPath: "source.mkv", + outputPath: sourcePath, + state: "complete", + disposition: "written" + }); + fs.renameSync(sourcePath, targetPath); + + scope.replacePath(sourcePath, targetPath); + expect(scope.completeFiles()).toEqual([path.resolve(targetPath)]); + expect(scope.removePath(targetPath)).toBe(true); + expect(scope.completeFiles()).toEqual([]); + }); +});