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 d2f137d..cd1adfd 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 7d81699..2f0051b 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 fa5cecb..cb762dc 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 aed1e17..d294ccd 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 c8a5780..0e1aa02 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$OutputTarget.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$OutputTarget.class index 6314745..24e233e 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$OutputTarget.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$OutputTarget.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class index 8edca16..e94c4ec 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 b51750e..23e29c6 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 6c93e30..82aef6f 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 2aee3ae..2c81688 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 265e1cc..420ccd3 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 8761ec6..2144cf2 100644 --- a/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java +++ b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java @@ -668,20 +668,25 @@ public final class JBindExtractorMain { return output; } - private static String normalizeEntryName(String value, String fallback) { - String entry = value == null ? "" : value.trim(); - if (entry.length() == 0) { - return fallback; - } - entry = entry.replace('\\', '/'); + private static String normalizeEntryName(String value, String fallback) { + String entry = value == null ? "" : value; + if (entry.trim().length() == 0) { + return fallback; + } + entry = entry.replace('\\', '/'); while (entry.startsWith("./")) { entry = entry.substring(2); } - if (entry.length() == 0) { - return fallback; - } - - String[] segments = entry.split("/", -1); + if (entry.length() == 0) { + return fallback; + } + + while (entry.endsWith("/")) { + entry = entry.substring(0, entry.length() - 1); + } + validateWindowsEntryName(entry); + + String[] segments = entry.split("/", -1); StringBuilder sanitized = new StringBuilder(); for (int i = 0; i < segments.length; i++) { if (i > 0) { @@ -693,8 +698,33 @@ public final class JBindExtractorMain { if (entry.length() == 0) { return fallback; } - return entry; - } + return entry; + } + + private static void validateWindowsEntryName(String entry) { + if (entry.startsWith("/") || entry.matches("^[a-zA-Z]:.*")) { + throw new IllegalArgumentException("Ungueltiger Windows-Archivpfad: " + entry); + } + String[] segments = entry.split("/", -1); + for (String segment : segments) { + if (segment.length() == 0 || ".".equals(segment) || "..".equals(segment) + || segment.endsWith(".") || segment.endsWith(" ") + || WINDOWS_SPECIAL_CHARS_RE.matcher(segment).find()) { + throw new IllegalArgumentException("Ungueltiger Windows-Archivpfad: " + entry); + } + for (int i = 0; i < segment.length(); i++) { + if (segment.charAt(i) < 32) { + throw new IllegalArgumentException("Ungueltiger Windows-Archivpfad: " + entry); + } + } + int dot = segment.indexOf('.'); + String base = (dot >= 0 ? segment.substring(0, dot) : segment).toUpperCase(Locale.ROOT); + if ("CON".equals(base) || "PRN".equals(base) || "AUX".equals(base) || "NUL".equals(base) + || base.matches("COM[1-9¹²³]") || base.matches("LPT[1-9¹²³]")) { + throw new IllegalArgumentException("Reservierter Windows-Archivpfad: " + entry); + } + } + } private static long safeSize(Long value) { if (value == null) { diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 87b8bc9..e83c645 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -4477,19 +4477,26 @@ export class DownloadManager extends EventEmitter { private async writePackageOutputOwnerMarkerAtomic(pkg: PackageEntry, marker: PackageOutputOwnerMarker): Promise { const markerPath = this.packageOutputOwnerMarkerPath(pkg); const tempPath = path.join(pkg.extractDir, `.${PACKAGE_OUTPUT_OWNER_MARKER}.${uuidv4()}.tmp`); - const handle = await fs.promises.open(tempPath, "wx"); + const reservation = await fs.promises.open(markerPath, "wx"); try { - await handle.writeFile(JSON.stringify(marker), "utf8"); - await handle.sync(); - } finally { - await handle.close(); - } - try { - await fs.promises.link(tempPath, markerPath); - await fs.promises.rm(tempPath, { force: true }); + await reservation.writeFile("{}", "utf8"); + await reservation.sync(); + await reservation.close(); + const temp = await fs.promises.open(tempPath, "wx"); + try { + await temp.writeFile(JSON.stringify(marker), "utf8"); + await temp.sync(); + } finally { + await temp.close().catch(() => {}); + } + await fs.promises.rename(tempPath, markerPath); } catch (error) { + await reservation.close().catch(() => {}); await fs.promises.rm(tempPath, { force: true }).catch(() => {}); + await fs.promises.rm(markerPath, { force: true }).catch(() => {}); throw error; + } finally { + await reservation.close().catch(() => {}); } } @@ -4638,7 +4645,13 @@ export class DownloadManager extends EventEmitter { const scope = this.getPackageOutputScope(pkg); try { await fs.promises.mkdir(pkg.extractDir, { recursive: true }); - await this.ensurePackageOutputOwnerMarker(pkg); + try { + await this.ensurePackageOutputOwnerMarker(pkg); + } catch (error) { + pkg.outputOwnerId = ""; + pkg.outputOwnerGeneration = 0; + logger.warn(`Output-Owner-Marker nicht verfügbar: pkg=${pkg.id}, reason=${compactErrorText(error)}`); + } return await operation(pkg.extractDir, scope); } finally { if (!packageWasInSession || this.session.packages[pkg.id] === pkg) { diff --git a/src/main/extractor.ts b/src/main/extractor.ts index 3debcee..38ae4e6 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -214,6 +214,7 @@ interface DaemonRequest { startedAt: number; passwordCount: number; onOutput?: (event: ExtractOutputEvent) => void; + targetDir: string; } const activeSubstDrives = new Set(); @@ -1629,17 +1630,10 @@ function parseJvmLine( state.openedOutputs ||= new Map(); if (event.state === "opened") { state.openedOutputs.set(outputKey, event); - } else if (event.state === "complete" || event.state === "removed") { + } else if (event.state === "complete" || event.state === "partial" || event.state === "removed") { state.openedOutputs.delete(outputKey); } - if (!state.outputError) { - try { - onOutput?.(event); - } catch (error) { - state.outputError = error instanceof Error ? error : new Error(String(error)); - state.reportedError = state.outputError.message; - } - } + dispatchJvmOutputEvent(state, onOutput, event); return; } @@ -1676,10 +1670,27 @@ export function shutdownDaemon(): void { daemonLayout = null; } -function finishDaemonRequest(result: JvmExtractResult): void { - const req = daemonCurrentRequest; - if (!req) return; - daemonCurrentRequest = null; +function finishDaemonRequest(result: JvmExtractResult): void { + const req = daemonCurrentRequest; + if (!req) return; + const openedCount = reconcileJvmOpenedOutputs(req.parseState, req.onOutput, req.targetDir); + let finalResult = result; + if (req.parseState.outputError) { + finalResult = { + ...result, + ok: false, + aborted: false, + timedOut: false, + errorText: cleanErrorText(req.parseState.outputError.message || String(req.parseState.outputError)) + }; + } else if (result.ok && openedCount > 0) { + finalResult = { + ...result, + ok: false, + errorText: "JVM-Output blieb ohne Abschlussstatus" + }; + } + daemonCurrentRequest = null; daemonBusy = false; daemonStdoutBuffer = ""; daemonStderrBuffer = ""; @@ -1689,7 +1700,7 @@ function finishDaemonRequest(result: JvmExtractResult): void { req.signal.removeEventListener("abort", daemonAbortHandler); daemonAbortHandler = null; } - req.resolve(result); + req.resolve(finalResult); } function flushDaemonParseBuffers(req: DaemonRequest | null): void { @@ -1908,7 +1919,8 @@ function sendDaemonRequest( archiveName, startedAt: Date.now(), passwordCount: passwordCandidates.length, - onOutput + onOutput, + targetDir }; logger.info(`JVM Daemon Request Start: archive=${archiveName}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs || 0}, conflict=${mode}`); @@ -2072,11 +2084,28 @@ async function runJvmExtractCommand( fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {}); }; - const finish = (result: JvmExtractResult): void => { - if (settled) { - return; - } - settled = true; + const finish = (result: JvmExtractResult): void => { + if (settled) { + return; + } + const openedCount = reconcileJvmOpenedOutputs(parseState, onOutput, targetDir); + let finalResult = result; + if (parseState.outputError) { + finalResult = { + ...result, + ok: false, + aborted: false, + timedOut: false, + errorText: cleanErrorText(parseState.outputError.message || String(parseState.outputError)) + }; + } else if (result.ok && openedCount > 0) { + finalResult = { + ...result, + ok: false, + errorText: "JVM-Output blieb ohne Abschlussstatus" + }; + } + settled = true; if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; @@ -2085,7 +2114,7 @@ async function runJvmExtractCommand( signal.removeEventListener("abort", onAbort); } cleanupTmpDir(); - resolve(result); + resolve(finalResult); }; if (timeoutMs && timeoutMs > 0) { @@ -2216,6 +2245,137 @@ export function buildExternalExtractArgs( return ["x", "-y", "-bb1", "-sccUTF-8", overwrite, pass, archivePath, `-o${targetDir}`]; } +export function buildExternalListArgs(command: string, archivePath: string, password = ""): string[] { + if (isRarNativeCommand(command)) { + const pass = password ? `-p${password}` : "-p-"; + return ["lb", pass, "-y", archivePath]; + } + const pass = password ? `-p${password}` : "-p"; + return ["l", "-slt", "-sccUTF-8", pass, archivePath]; +} + +export function parseNativeArchiveEntryList(command: string, output: string): string[] { + const lines = String(output || "").split(/\r?\n/); + if (isRarNativeCommand(command)) { + return lines.map((line) => line.trim()).filter(Boolean); + } + const entries: string[] = []; + let inEntries = false; + for (const line of lines) { + if (/^-{8,}\s*$/.test(line.trim())) { + inEntries = true; + continue; + } + if (!inEntries) { + continue; + } + const match = line.match(/^Path = (.*)$/); + if (match?.[1]) { + entries.push(match[1]); + } + } + return entries; +} + +export function validateNativeArchiveEntryCandidates(entries: readonly string[], targetDir: string): void { + const scope = new PackageOutputScope([targetDir]); + for (const rawEntry of entries) { + const entryPath = String(rawEntry || "").replace(/\\/g, "/").replace(/\/$/, ""); + if (!entryPath) { + continue; + } + const outputPath = path.resolve(targetDir, ...entryPath.split("/")); + scope.validateTarget(entryPath, outputPath); + } +} + +function dispatchJvmOutputEvent( + state: JvmParseState, + onOutput: ((event: ExtractOutputEvent) => void) | undefined, + event: ExtractOutputEvent +): void { + if (state.outputError) { + return; + } + try { + onOutput?.(event); + } catch (error) { + state.outputError = error instanceof Error ? error : new Error(String(error)); + state.reportedError = state.outputError.message; + } +} + +function reconcileJvmOpenedOutputs( + state: JvmParseState, + onOutput: ((event: ExtractOutputEvent) => void) | undefined, + targetDir: string +): number { + const opened = [...(state.openedOutputs?.values() || [])]; + state.openedOutputs?.clear(); + if (state.outputError || opened.length === 0) { + return opened.length; + } + const validator = new PackageOutputScope([targetDir]); + for (const event of opened) { + let next: ExtractOutputEvent = { ...event, state: "removed" }; + try { + const stat = fs.lstatSync(event.outputPath); + if (stat.isFile() && !stat.isSymbolicLink()) { + next = { ...event, state: "partial" }; + } + validator.add(next); + } catch { + next = { ...event, state: "removed" }; + try { + validator.add(next); + } catch (error) { + state.outputError = error instanceof Error ? error : new Error(String(error)); + state.reportedError = state.outputError.message; + return opened.length; + } + } + dispatchJvmOutputEvent(state, onOutput, next); + } + return opened.length; +} + +async function runNativeEntryPreflight( + command: string, + archivePath: string, + targetDir: string, + password: string, + signal: AbortSignal | undefined, + timeoutMs: number +): Promise { + const chunks: string[] = []; + const result = await runExtractCommand( + command, + buildExternalListArgs(command, archivePath, password), + (chunk) => chunks.push(chunk), + signal, + timeoutMs + ); + if (!result.ok) { + return result; + } + try { + const entries = parseNativeArchiveEntryList(command, chunks.join("")); + if (entries.length === 0) { + throw new Error("Native Archivliste enthält keine validierbaren Einträge"); + } + validateNativeArchiveEntryCandidates(entries, targetDir); + return result; + } catch (error) { + return { + ok: false, + missingCommand: false, + aborted: false, + timedOut: false, + errorText: cleanErrorText(String(error)) + }; + } +} + export function parseNativeExtractOutput( command: string, line: string, @@ -2359,7 +2519,11 @@ async function runExternalExtractInner( const summarizeResultError = (errorText: string): string => cleanErrorText(errorText); let createErrorText = ""; let createErrorPassword = ""; - const runNativeAttempt = async (args: string[]): Promise => { + const runNativeAttempt = async (args: string[], password: string): Promise => { + const preflight = await runNativeEntryPreflight(command, archivePath, targetDir, password, signal, timeoutMs); + if (!preflight.ok) { + return preflight; + } const outputs = createNativeOutputCollector(command, archivePath, targetDir, conflictMode, onOutput); const result = await runExtractCommand(command, args, (chunk) => { outputs.push(chunk); @@ -2386,7 +2550,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); + const result = await runNativeAttempt(args, password); 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; } @@ -2413,7 +2577,7 @@ async function runExternalExtractInner( onPasswordAttempt?.(passwordAttempt, passwords.length); } let args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode); - let result = await runNativeAttempt(args); + let result = await runNativeAttempt(args, password); if (!result.ok && usePerformanceFlags && isUnsupportedExtractorSwitchError(result.errorText)) { usePerformanceFlags = false; @@ -2421,7 +2585,7 @@ async function runExternalExtractInner( onLog?.("WARN", `Entpacker ohne Performance-Flags fortgesetzt: ${path.basename(archivePath)}`); logger.warn(`Entpacker ohne Performance-Flags fortgesetzt: ${path.basename(archivePath)}`); args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, false, hybridMode); - result = await runNativeAttempt(args); + result = await runNativeAttempt(args, password); } logger.info( @@ -2484,7 +2648,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); + const result = await runNativeAttempt(args, password); 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; } diff --git a/src/main/package-output-scope.ts b/src/main/package-output-scope.ts index 57f62a9..241e077 100644 --- a/src/main/package-output-scope.ts +++ b/src/main/package-output-scope.ts @@ -37,8 +37,20 @@ export class PackageOutputScope { return path.resolve(value).replace(/[\\/]+$/, "").toLocaleLowerCase("en-US"); } + private validateWindowsSegments(segments: readonly string[], sourcePath: string): void { + for (const segment of segments) { + const reservedBase = segment.split(".", 1)[0]; + if (segment.includes(":") + || /[<>"|?*\u0000-\u001f]/.test(segment) + || /[. ]$/.test(segment) + || /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])$/i.test(reservedBase)) { + throw new Error(`Ungültiger Win32-Entry-Ausgabepfad: ${sourcePath}`); + } + } + } + private validateEntryPath(entryPath: string): string { - const normalized = String(entryPath || "").trim().replace(/\\/g, "/"); + const normalized = String(entryPath || "").replace(/\\/g, "/"); const segments = normalized.split("/"); if (!normalized || normalized.startsWith("/") @@ -47,6 +59,7 @@ export class PackageOutputScope { || segments.some((segment) => segment === ".." || segment === "")) { throw new Error(`Ungültiger Archive-Entry-Ausgabepfad: ${entryPath}`); } + this.validateWindowsSegments(segments, entryPath); return segments.filter((segment) => segment !== ".").join("/"); } @@ -131,6 +144,8 @@ export class PackageOutputScope { } const normalizedOutputPath = path.resolve(outputPath); const authorizedRoot = this.findAuthorizedRoot(normalizedOutputPath); + const relativeOutputPath = path.relative(authorizedRoot, normalizedOutputPath).replace(/\\/g, "/"); + this.validateWindowsSegments(relativeOutputPath.split("/"), outputPath); this.rejectLinkedPath(normalizedOutputPath, authorizedRoot); return { entryPath: normalizedEntryPath, outputPath: normalizedOutputPath }; } diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 80ca676..1893b58 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -13451,6 +13451,136 @@ describe("download manager", () => { expect(fs.existsSync(path.join(extractDir, ".rd-package-output-owner-v1.json"))).toBe(false); }); + it("creates a package owner marker without hardlink support", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-owner-no-link-")); + tempDirs.push(root); + const packageName = "no-link-package"; + const session = emptySession(); + const packageId = "no-link-package-id"; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir: path.join(root, "downloads", packageName), + extractDir: path.join(root, "extract", packageName), + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + createdAt: 1_000, + updatedAt: 1_000 + }; + const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state"))); + const linkSpy = vi.spyOn(fs.promises, "link").mockRejectedValue(Object.assign(new Error("link unsupported"), { code: "ENOTSUP" })); + + try { + await expect((manager as any).ensurePackageOutputOwnerMarker(session.packages[packageId])).resolves.toBe(true); + expect(fs.existsSync(path.join(session.packages[packageId].extractDir, ".rd-package-output-owner-v1.json"))).toBe(true); + } finally { + linkSpy.mockRestore(); + } + }); + + it("continues direct scoped output when owner marker creation is denied", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-owner-denied-")); + tempDirs.push(root); + const packageName = "denied-marker-package"; + const session = emptySession(); + const packageId = "denied-marker-package-id"; + const pkg: PackageEntry = { + id: packageId, + name: packageName, + outputDir: path.join(root, "downloads", packageName), + extractDir: path.join(root, "extract", packageName), + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + createdAt: 1_000, + updatedAt: 1_000 + }; + session.packageOrder = [packageId]; + session.packages[packageId] = pkg; + const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state"))); + const originalOpen = fs.promises.open.bind(fs.promises); + const openSpy = vi.spyOn(fs.promises, "open").mockImplementation(async (filePath: any, ...args: any[]) => { + if (String(filePath).includes("rd-package-output-owner-v1")) { + throw Object.assign(new Error("marker denied"), { code: "EPERM" }); + } + return originalOpen(filePath, ...(args as [any, any])); + }); + + try { + await expect((manager as any).runWithPackageOutputProvenance(pkg, async (targetDir: string, scope: any) => { + const outputPath = path.join(targetDir, "owned.mkv"); + fs.writeFileSync(outputPath, "owned"); + scope.add({ + version: 1, + archivePath: path.join(pkg.outputDir, "archive.rar"), + entryPath: "owned.mkv", + outputPath, + state: "complete", + disposition: "written" + }); + })).resolves.toBeUndefined(); + } finally { + openSpy.mockRestore(); + } + + expect(fs.readFileSync(path.join(pkg.extractDir, "owned.mkv"), "utf8")).toBe("owned"); + expect(pkg.outputRecords).toEqual([expect.objectContaining({ entryPath: "owned.mkv" })]); + expect(fs.existsSync(path.join(pkg.extractDir, ".rd-package-output-owner-v1.json"))).toBe(false); + }); + + it("rejects a replayed owner marker from an older package generation", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-owner-replay-")); + tempDirs.push(root); + const packageName = "replay-package"; + const extractDir = path.join(root, "extract", packageName); + const libraryDir = path.join(root, "library"); + fs.mkdirSync(extractDir, { recursive: true }); + const foreignPath = path.join(extractDir, "foreign.mkv"); + fs.writeFileSync(foreignPath, "foreign"); + const ownerId = crypto.randomUUID().toLowerCase(); + fs.writeFileSync(path.join(extractDir, ".rd-package-output-owner-v1.json"), JSON.stringify({ + version: 1, + packageId: "replay-package-id", + generation: 1, + ownerId + })); + const session = emptySession(); + const packageId = "replay-package-id"; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir: path.join(root, "downloads", packageName), + extractDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + outputProvenanceVersion: 1, + outputRecords: [], + outputOwnerId: ownerId, + outputOwnerGeneration: 1, + resultGeneration: 2, + createdAt: 1_000, + updatedAt: 1_000 + }; + const manager = new DownloadManager( + { ...defaultSettings(), autoExtract: true, collectMkvToLibrary: true, mkvLibraryDir: libraryDir }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId]); + + expect(fs.existsSync(foreignPath)).toBe(true); + expect(fs.existsSync(path.join(libraryDir, "foreign.mkv"))).toBe(false); + expect(session.packages[packageId].outputRecords).toEqual([]); + }); + it("does NOT move bonus files from Extras subdirectory to flat library", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); diff --git a/tests/extractor-jvm.test.ts b/tests/extractor-jvm.test.ts index 47b201f..81f2152 100644 --- a/tests/extractor-jvm.test.ts +++ b/tests/extractor-jvm.test.ts @@ -269,6 +269,97 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b expect(states[states.length - 1]).toBe("removed"); expect(fs.existsSync(path.join(targetDir, "episode.bin"))).toBe(false); }); + + it.each([ + ["7zjbinding", "file.mkv:stream", "file.mkv"], + ["7zjbinding", "name.", "name"], + ["7zjbinding", "name ", "name"], + ["7zjbinding", "CON", "safe-base.txt"], + ["7zjbinding", "aux.txt", "safe-base.txt"], + ["7zjbinding", "folder/LPT1.mkv", "safe-base.txt"], + ["zip4j", "file.mkv:stream", "file.mkv"], + ["zip4j", "name.", "name"], + ["zip4j", "name ", "name"], + ["zip4j", "CON", "safe-base.txt"], + ["zip4j", "aux.txt", "safe-base.txt"], + ["zip4j", "folder/LPT1.mkv", "safe-base.txt"] + ] as const)("rejects %s Win32-unsafe entry %s before changing its alias", (backend, entryName, baseName) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-win32-${backend}-`)); + tempDirs.push(root); + const targetDir = path.join(root, "out"); + fs.mkdirSync(targetDir, { recursive: true }); + const basePath = path.join(targetDir, baseName); + fs.writeFileSync(basePath, "foreign"); + const zipPath = path.join(root, "unsafe.zip"); + const zip = new AdmZip(); + zip.addFile(entryName, Buffer.from("package")); + zip.writeZip(zipPath); + const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm"); + const classPath = [ + path.join(runtimeRoot, "classes"), + path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"), + path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"), + path.join(runtimeRoot, "lib", "zip4j.jar") + ].join(path.delimiter); + + const run = spawnSync("java", [ + "-cp", + classPath, + "com.sucukdeluxe.extractor.JBindExtractorMain", + "--archive", + zipPath, + "--target", + targetDir, + "--conflict", + "overwrite", + "--backend", + backend + ], { encoding: "utf8" }); + + expect(run.status).not.toBe(0); + expect(fs.readFileSync(basePath, "utf8")).toBe("foreign"); + }); + + it("reconciles a real aborted JVM opened output to partial or removed", async () => { + process.env.RD_EXTRACT_BACKEND = "jvm"; + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-abort-output-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + const zip = new AdmZip(); + zip.addFile("episode.bin", Buffer.alloc(8 * 1024 * 1024, 7)); + zip.writeZip(path.join(packageDir, "large.zip")); + const controller = new AbortController(); + const events: import("../src/main/extractor").ExtractOutputEvent[] = []; + + const extraction = extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + signal: controller.signal, + onOutput: (event) => { + events.push(event); + if (event.state === "opened") { + controller.abort(); + } + } + }); + + await expect(extraction).rejects.toThrow("aborted:extract"); + expect(events[0]?.state).toBe("opened"); + expect(["partial", "removed"]).toContain(events[events.length - 1]?.state); + const outputPath = path.join(targetDir, "episode.bin"); + if (events[events.length - 1]?.state === "partial") { + expect(fs.statSync(outputPath).isFile()).toBe(true); + } else { + expect(fs.existsSync(outputPath)).toBe(false); + } + await new Promise((resolve) => setTimeout(resolve, 500)); + }, 10000); 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 edcb9ef..e5db277 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -4,7 +4,8 @@ import path from "node:path"; import AdmZip from "adm-zip"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { - buildExternalExtractArgs, + buildExternalExtractArgs, + buildExternalListArgs, cleanErrorText, collectArchiveCleanupTargets, extractPackageArchives, @@ -18,9 +19,11 @@ import { findArchiveCandidates, orderExtractorCandidatesForArchive, parseNativeExtractOutput, + parseNativeArchiveEntryList, resolveExtractorBackendModeForArchive, - resolveExtractorBackendMode, - shouldFallbackLegacyRarToJvm, + resolveExtractorBackendMode, + shouldFallbackLegacyRarToJvm, + validateNativeArchiveEntryCandidates, } from "../src/main/extractor"; const tempDirs: string[] = []; @@ -1598,5 +1601,65 @@ describe("extractor", () => { ]); }); + it.each([ + ["file.mkv:stream", "file.mkv"], + ["name.", "name"], + ["name ", "name"], + ["CON", "safe-base.txt"], + ["aux.txt", "safe-base.txt"], + ["folder/LPT1.mkv", "safe-base.txt"] + ] as const)("rejects Win32-unsafe internal ZIP entry %s before changing its alias", async (entryName, baseName) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-win32-entry-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + const basePath = path.join(targetDir, baseName); + fs.writeFileSync(basePath, "foreign"); + const zip = new AdmZip(); + zip.addFile(entryName, Buffer.from("package")); + zip.writeZip(path.join(packageDir, "release.zip")); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(0); + expect(result.failed).toBe(1); + expect(fs.readFileSync(basePath, "utf8")).toBe("foreign"); + }); + + it.each(["file.mkv:stream", "name.", "name ", "CON", "aux.txt", "folder/LPT1.mkv"])("rejects native preflight entry %s before extraction", (entryName) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-preflight-")); + tempDirs.push(root); + const targetDir = path.join(root, "out"); + fs.mkdirSync(targetDir, { recursive: true }); + + expect(validateNativeArchiveEntryCandidates).toBeTypeOf("function"); + expect(() => validateNativeArchiveEntryCandidates([entryName], targetDir)).toThrow(/Ausgabepfad|entry/i); + }); + + it("uses deterministic native list commands and parses entry-only output", () => { + expect(buildExternalListArgs("7z.exe", "archive.7z")).toEqual(["l", "-slt", "-sccUTF-8", "-p", "archive.7z"]); + expect(buildExternalListArgs("UnRAR.exe", "archive.rar")).toEqual(["lb", "-p-", "-y", "archive.rar"]); + expect(parseNativeArchiveEntryList("7z.exe", [ + "Path = archive.7z", + "Type = 7z", + "----------", + "Path = folder/episode.mkv", + "Size = 10" + ].join("\n"))).toEqual(["folder/episode.mkv"]); + expect(parseNativeArchiveEntryList("UnRAR.exe", "folder/episode.mkv\r\nsubtitle.srt\r\n")).toEqual([ + "folder/episode.mkv", + "subtitle.srt" + ]); + }); + }); }); diff --git a/tests/package-output-scope.test.ts b/tests/package-output-scope.test.ts index 3800c68..35c181f 100644 --- a/tests/package-output-scope.test.ts +++ b/tests/package-output-scope.test.ts @@ -52,7 +52,13 @@ describe("PackageOutputScope", () => { "../foreign.mkv", "folder/../../foreign.mkv", "/absolute.mkv", - "C:\\absolute.mkv" + "C:\\absolute.mkv", + "file.mkv:stream", + "CON", + "aux.txt", + "folder/LPT1.mkv", + "name.", + "name " ])("rejects unsafe archive entry path %s", (entryPath) => { const root = createRoot(); const outputPath = path.join(root, "safe.mkv");