From 6724b3605cd5363fa400877f1fa9fb1e30cd2d77 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 22 Aug 2026 15:40:59 +0200 Subject: [PATCH] fix: close Windows extraction namespace gaps Reject alternate data streams, reserved Win32 device names, and trailing-dot or trailing-space aliases before internal ZIP, JVM, or native extraction writes. Preflight native archive entry lists, reconcile opened JVM outputs to partial or removed state on abnormal results, and replace hardlink-based owner markers with portable exclusive reservation plus atomic replacement while allowing direct scoped extraction to continue when marker persistence is unavailable. --- .../JBindExtractorMain$Backend.class | Bin 2310 -> 2310 bytes ...dExtractorMain$BulkExtractCallback$1.class | Bin 1996 -> 1996 bytes ...indExtractorMain$BulkExtractCallback.class | Bin 7032 -> 7032 bytes .../JBindExtractorMain$ConflictMode.class | Bin 2059 -> 2059 bytes ...JBindExtractorMain$ExtractionRequest.class | Bin 2539 -> 2539 bytes .../JBindExtractorMain$OutputTarget.class | Bin 884 -> 884 bytes .../JBindExtractorMain$ProgressTracker.class | Bin 1387 -> 1387 bytes ...ExtractorMain$SevenZipArchiveContext.class | Bin 1663 -> 1663 bytes ...ExtractorMain$SevenZipVolumeCallback.class | Bin 3667 -> 3667 bytes ...ExtractorMain$WrongPasswordException.class | Bin 458 -> 458 bytes .../extractor/JBindExtractorMain.class | Bin 26254 -> 27081 bytes .../extractor/JBindExtractorMain.java | 56 +++-- src/main/download-manager.ts | 33 ++- src/main/extractor.ts | 216 +++++++++++++++--- src/main/package-output-scope.ts | 17 +- tests/download-manager.test.ts | 130 +++++++++++ tests/extractor-jvm.test.ts | 91 ++++++++ tests/extractor.test.ts | 69 +++++- tests/package-output-scope.test.ts | 8 +- 19 files changed, 566 insertions(+), 54 deletions(-) diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class index d2f137d1269140fa0b973d87a1c3ce084ca0dcca..cd1adfd0374f0f9d51e2106fc65e531a1cd5229a 100644 GIT binary patch delta 96 zcmZn@Y7^RUl#Q``@-a4jAo-2WhO>@=jk%tIo4H}KHTw$LW(E=F76w`7Rt81pb_N^f w4h9G2E(TBLZU$fG-bow|KuwMuHbAnDBa*j*fuFgOL5{hKL6^CF@)Hgv0M&dLDF6Tf delta 96 zcmZn@Y7^RUl#MZR@-a4jAo-2WhBJYIjX9Bln>lH+HTw$LR0a{|GzMAbbOuG{Oa>d~ wECvVW90pJ3Tn1m}{7D=RKuwMuHbAnDBa%0YfuA{=L5?|wL6iOGA|=BlwsGjOuVFmSWTGf1!~F-Ws0FvzedGFY=HGkCM8FhsDZ NG9inZcTcmBE{ZjUj@C NogtZpV{$#aF955v3ylB( 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 fa5cecb48d552480e361b9bbca12aa32818fd21e..cb762dcdd2422ba79873db841a97149e220d032a 100644 GIT binary patch delta 379 zcmWm7OG}ht9ES1h@qa6fLgnFA$3X-^!J@#)JX9t*p-w6WW-*Qgp7KPk#8sft2QYZP zLLWd726F2@7v`bzkd_v0k}yz8>UDFiZtmYn{Ym}8_oD7qWbcXC8W(JxLfar}n^f2b zs_Y|I>=U)NMT2eAY&#@um!y5B$M)#6ebV-YLHo*>9q`N!{f!{*e)#dA+8z1%u)h~G zL8nWdhR7cR`x~C05wm|(32w>Jsw~nHL&|1CIZP?Sv~qc=JYMM{uXTxK<+Gv!wp7SZ z74u6)94l~vN_~Gx(p_78Q_|?Vxo~FE|(96 mn;|MSOpQiDag;|IM6z!dzmkh2QYQik-?gSlv3nkXdF>AxO^OV~Ks_dd$ r2?|x2$hpjEyF$|1qvKUNElZzWXTowWA8YX*xSd#S#4!BN_AvDiXn6^1IaqJ*|L=kBFt3`vdq;Cip;eP#>{mL=FANY4$O@V jF3io7&Dev0>N?qjc}p00nM)a@n9CT{nTsdCWLE+JI0X|* 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 c8a57802fcec342e512a01ffd8de2e4e3e3bb1e5..0e1aa02d2c7b3c8aeeaab3d730c7881351a7e79b 100644 GIT binary patch delta 126 zcmaDY{91U!H+J5o3@pqm7`T~NG6*rRn#|0h#kh2`9)}K)jONf`Un;=CV8mcNIgvwj z@?s7_FyDm16wJTCAqZ6U2&{vbQ;QX()@rgKr>GGB1_lNOMh0CV$p)kun3qmY<^&ns K2PSuN8UX+dOCE6m delta 126 zcmaDY{91U!H+J531{UTn25#nV1|jC2$;=#DjO~;4ICOwyG=~m*y8s7+5rgsML=Mr( zi#Y_rd=my!F#iIFAW+pKunt~MEmn|PtI2|#qC)%|7#J8B8FYap8<1vTZl9dY2{O13 JOzz|~0sxrK9L)d# 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 63147455818c08d26e3fc0c03978cdca1b620a5b..24e233e2688d338fb30eb5a0c9eec803690d3594 100644 GIT binary patch delta 47 ycmeyu_JwW32`0f!3@pr>8914@Fz_*NWe{fGHu)x#7USB^ zF$l6`Gsv>!FsQQRG1#%>GdQypOzvT^<1A+2U@2kXV=0|{nI%@Vf`OZ*l0lfIia~^> RhC!00mcg8*Zn7(@8URD>7P9~V 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 b51750ecbc18855b5a00a61a78ab665a1bfda61a..23e29c6c8d69f668f329e6bb3c3834fe12b3e7ab 100644 GIT binary patch delta 101 zcmV-r0Gj{*4F3$UMgYc>Hgm=82YPI$r5|q34ahcLqL5eK7RJ$Tw3zp&5@0i!hJd EKa4*XZU6uP 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 6c93e304d7198b3225a01f05c1f4307de6c7fe16..82aef6fdae28c4965caa65cf5b924ca915adcea7 100644 GIT binary patch delta 212 zcmWN{yDo!q0LI~8dEe5npH_sRJ}Q+o(WKHuLJSCtMGRItNGwfECW*;4{BJ@m?CwCE zk`jwUoZ_%q3x_cVDd~cmF2i=TnYp4v*TlLB57A-umbC5| z(0%JYQq&U@dS+5D%!9F4N*4`00soV02l2ClRgY80o;>m45&Te01gD<01yP^02~D603!tE05AmS05=5a067He073-o z07nGu0AU2}0BHp70Bi*C0C)uP0DA=N0D%PdlTHn)8U*_QA_V;aCItTgDFpriDg^)l OEd>DpHU$Ec5)MIO|2$j( 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 2aee3aef1db0831a8ac1b7041e11ff7e2cd3c746..2c816889543ee6ed433c86a9f8d9a6931d899727 100644 GIT binary patch delta 17 YcmX@be2RI)YDU%~23D5h$r~7@05#ABNB{r; delta 17 YcmX@be2RI)YDU%&23D5P$r~7@05mKG3;+NC diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class index 265e1ccec4e8fbd9aec2c080920be60beeff4c29..420ccd3c71bbc04bdb558ff22bd94f4fc95a41c4 100644 GIT binary patch delta 4869 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 nj>h+B{FKI15X~8zWqp{!nn`yvK$3JH7z2E!2 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 Rn_a-y@*{9Fw&Cj#@PA+&bB6!` 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");