From 653e756010174a71bffc45838d55df9125f40ec5 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 28 Mar 2026 16:27:21 +0100 Subject: [PATCH] Harden download integrity, extraction safety, and update security --- src/main/download-completion.ts | 14 +- src/main/download-manager.ts | 271 +++++++++++++++++++++----------- src/main/extractor.ts | 33 ++-- src/main/item-log.ts | 34 ++-- src/main/package-log.ts | 34 ++-- src/main/storage.ts | 140 +++++++++++------ src/main/support-bundle.ts | 3 - src/main/update.ts | 7 +- tests/download-manager.test.ts | 20 ++- tests/item-log.test.ts | 19 +++ tests/package-log.test.ts | 18 +++ tests/storage.test.ts | 75 ++++++++- tests/update.test.ts | 41 ++++- 13 files changed, 511 insertions(+), 198 deletions(-) diff --git a/src/main/download-completion.ts b/src/main/download-completion.ts index a11430f..b5f7783 100644 --- a/src/main/download-completion.ts +++ b/src/main/download-completion.ts @@ -75,6 +75,7 @@ export function planDownloadCompletion(args: { export function validateDownloadedFileCompletion(args: { actualBytes: number; plan: DownloadCompletionPlan; + toleranceBytes?: number; }): { ok: boolean; totalBytes: number; @@ -85,11 +86,12 @@ export function validateDownloadedFileCompletion(args: { const expectedTotal = Number.isFinite(args.plan.expectedTotal || NaN) ? Math.max(0, Math.floor(args.plan.expectedTotal || 0)) : 0; + const toleranceBytes = Math.max(0, Math.floor(Number(args.toleranceBytes ?? ALLOCATION_UNIT_SIZE) || 0)); if ( expectedTotal > 0 && (args.plan.source === "content-range" || args.plan.source === "content-length") && - actualBytes + ALLOCATION_UNIT_SIZE < expectedTotal + actualBytes + toleranceBytes < expectedTotal ) { return { ok: false, @@ -109,10 +111,18 @@ export function validateDownloadedFileCompletion(args: { } if (args.plan.source === "provider-metadata") { + if (expectedTotal > 0 && actualBytes + toleranceBytes < expectedTotal) { + return { + ok: false, + totalBytes: expectedTotal, + acceptedMetadataMismatch: false, + error: `download_underflow:${actualBytes}/${expectedTotal}` + }; + } return { ok: true, totalBytes: actualBytes, - acceptedMetadataMismatch: expectedTotal > 0 && Math.abs(actualBytes - expectedTotal) > ALLOCATION_UNIT_SIZE + acceptedMetadataMismatch: expectedTotal > 0 && Math.abs(actualBytes - expectedTotal) > toleranceBytes }; } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 9ad3656..98128b0 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -127,13 +127,19 @@ const RESUME_REWIND_BYTES = 256 * 1024; const REALDEBRID_TOTAL_MISMATCH_TOLERANCE_BYTES = 64 * 1024; -const LARGE_BINARY_FILE_RE = /\.(?:part\d+\.rar|rar|r\d{2,3}|zip(?:\.\d+)?|7z(?:\.\d+)?|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|ts|m2ts|webm|mp3|flac|aac|wav)$/i; - -function itemExpectedMinBytes(item: DownloadItem): number { - return item.totalBytes && item.totalBytes > 0 - ? Math.max(10240, item.totalBytes - ALLOCATION_UNIT_SIZE) - : 10240; -} +const LARGE_BINARY_FILE_RE = /\.(?:part\d+\.rar|rar|r\d{2,3}|zip(?:\.\d+)?|7z(?:\.\d+)?|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|ts|m2ts|webm|mp3|flac|aac|wav)$/i; + +function expectedMinBytes(totalBytes: number | null | undefined, strict: boolean): number { + if (!totalBytes || totalBytes <= 0) { + return 10240; + } + return strict ? totalBytes : Math.max(10240, totalBytes - ALLOCATION_UNIT_SIZE); +} + +function itemExpectedMinBytes(item: DownloadItem): number { + const strict = isLargeBinaryLikePath(item.targetPath || item.fileName || ""); + return expectedMinBytes(item.totalBytes, strict); +} function resolvePackageItemDiskPath(pkg: PackageEntry, item: DownloadItem): string | null { if (item.targetPath) { @@ -335,17 +341,35 @@ function cloneSettings(settings: AppSettings): AppSettings { }; } -function parseContentRangeTotal(contentRange: string | null): number | null { - if (!contentRange) { - return null; - } - const match = contentRange.match(/\/(\d+)$/); - if (!match) { - return null; - } - const value = Number(match[1]); - return Number.isFinite(value) ? value : null; -} +type ParsedContentRange = { + start: number; + end: number; + total: number | null; +}; + +function parseContentRange(contentRange: string | null): ParsedContentRange | null { + if (!contentRange) { + return null; + } + const match = contentRange.match(/^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i); + if (!match) { + return null; + } + const start = Number(match[1]); + const end = Number(match[2]); + const total = match[3] === "*" ? null : Number(match[3]); + if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start) { + return null; + } + if (total !== null && (!Number.isFinite(total) || total <= 0 || end >= total)) { + return null; + } + return { start, end, total }; +} + +function parseContentRangeTotal(contentRange: string | null): number | null { + return parseContentRange(contentRange)?.total ?? null; +} function parseContentDispositionFilename(contentDisposition: string | null): string { if (!contentDisposition) { @@ -5226,22 +5250,35 @@ export class DownloadManager extends EventEmitter { * knows which files belong to which items. Without this, after restart all paths are * unclaimed and a new download with the same filename would create a "(1)" copy * instead of reusing its own partial file — or worse, overwrite another item's file. */ - private restoreTargetPathReservations(): void { - let restored = 0; - for (const item of Object.values(this.session.items)) { - const tp = String(item.targetPath || "").trim(); - if (!tp) continue; - const key = pathKey(tp); - if (!this.reservedTargetPaths.has(key)) { - this.reservedTargetPaths.set(key, item.id); - this.claimedTargetPathByItem.set(item.id, tp); - restored += 1; + private restoreTargetPathReservations(): void { + let restored = 0; + let droppedUnsafe = 0; + for (const item of Object.values(this.session.items)) { + const pkg = this.session.packages[item.packageId]; + if (!pkg) { + continue; + } + const tp = String(item.targetPath || "").trim(); + if (!tp) continue; + if (!isPathInsideDir(tp, pkg.outputDir)) { + droppedUnsafe += 1; + item.targetPath = ""; + continue; + } + const key = pathKey(tp); + if (!this.reservedTargetPaths.has(key)) { + this.reservedTargetPaths.set(key, item.id); + this.claimedTargetPathByItem.set(item.id, tp); + restored += 1; } } - if (restored > 0) { - logger.info(`restoreTargetPathReservations: ${restored} Pfade aus Session wiederhergestellt`); - } - this.reconcileDuplicateSuffixSessionItems(); + if (restored > 0) { + logger.info(`restoreTargetPathReservations: ${restored} Pfade aus Session wiederhergestellt`); + } + if (droppedUnsafe > 0) { + logger.warn(`restoreTargetPathReservations: ${droppedUnsafe} unsichere targetPath-Eintraege verworfen`); + } + this.reconcileDuplicateSuffixSessionItems(); // Fix legacy (N) suffix files: rename back to original if original path is free this.fixDuplicateSuffixFiles(); } @@ -5409,7 +5446,7 @@ export class DownloadManager extends EventEmitter { if (!targetPath || !item.totalBytes || item.totalBytes <= 0) continue; try { const stat = fs.statSync(targetPath); - const expectedMinSize = item.totalBytes - ALLOCATION_UNIT_SIZE; + const expectedMinSize = expectedMinBytes(item.totalBytes, isLargeBinaryLikePath(item.fileName || targetPath)); const persistedShortfall = item.downloadedBytes < expectedMinSize && stat.size >= expectedMinSize; if (stat.size < expectedMinSize) { logger.warn(`revalidateCompleted: ${item.fileName} ist nur ${humanSize(stat.size)} statt ${humanSize(item.totalBytes)}, setze auf queued`); @@ -5489,14 +5526,15 @@ export class DownloadManager extends EventEmitter { || normalizedError.includes("resume_download_underflow"); const archiveLikeTarget = String(item.fileName || diskState.diskPath || "").toLowerCase(); const archiveLike = /(?:\.part\d+\.rar|\.rar|\.r\d{2,3}|\.zip(?:\.\d+)?|\.7z(?:\.\d+)?|\.(?:tar(?:\.(?:gz|bz2|xz))?|tgz|tbz2|txz)|\.\d{3})$/i.test(archiveLikeTarget); - const looksComplete = diskState.exists - && diskState.fullOnDisk - && ( - diskState.reason === "ok" - || item.progressPercent >= 100 - || item.downloadedBytes >= diskState.minBytes - || (item.totalBytes != null && item.totalBytes > 0 && diskState.size >= item.totalBytes - ALLOCATION_UNIT_SIZE) - ); + const expectedMinSize = expectedMinBytes(item.totalBytes, isLargeBinaryLikePath(item.fileName || diskState.diskPath || "")); + const looksComplete = diskState.exists + && diskState.fullOnDisk + && ( + diskState.reason === "ok" + || item.progressPercent >= 100 + || item.downloadedBytes >= diskState.minBytes + || (item.totalBytes != null && item.totalBytes > 0 && diskState.size >= expectedMinSize) + ); if (!looksComplete || (knownShortfall > 0 && (underflowIndicated || archiveLike))) { return false; } @@ -8521,11 +8559,12 @@ export class DownloadManager extends EventEmitter { if (response.status === 416 && existingBytes > 0) { await response.arrayBuffer().catch(() => undefined); const rangeTotal = parseContentRangeTotal(response.headers.get("content-range")); - const expectedTotal = rangeTotal && rangeTotal > 0 - ? rangeTotal - : (knownTotal && knownTotal > 0 ? knownTotal : null); - const closeEnoughToExpected = expectedTotal != null - && Math.abs(existingBytes - expectedTotal) <= ALLOCATION_UNIT_SIZE; + const expectedTotal = rangeTotal && rangeTotal > 0 + ? rangeTotal + : (knownTotal && knownTotal > 0 ? knownTotal : null); + const sizeToleranceBytes = isLargeBinaryLikePath(item.fileName || effectiveTargetPath) ? 0 : ALLOCATION_UNIT_SIZE; + const closeEnoughToExpected = expectedTotal != null + && Math.abs(existingBytes - expectedTotal) <= sizeToleranceBytes; if (expectedTotal != null && closeEnoughToExpected) { const finalizedTotal = Math.max(existingBytes, expectedTotal); item.totalBytes = finalizedTotal; @@ -8539,20 +8578,6 @@ export class DownloadManager extends EventEmitter { }); return { resumable: true }; } - // No total available but we have substantial data - assume file is complete - // This prevents deleting multi-GB files when the server sends 416 without Content-Range - if (!expectedTotal && existingBytes > 1048576) { - logger.warn(`HTTP 416 ohne Größeninfo, ${humanSize(existingBytes)} vorhanden – als vollständig behandelt: ${item.fileName}`); - item.totalBytes = existingBytes; - item.downloadedBytes = existingBytes; - item.progressPercent = 100; - item.speedBps = 0; - item.updatedAt = nowMs(); - logAttemptEvent("WARN", "HTTP 416 ohne Größeninfo als vollständig behandelt", { - existingBytes - }); - return { resumable: true }; - } try { await fs.promises.rm(effectiveTargetPath, { force: true }); @@ -8635,7 +8660,8 @@ export class DownloadManager extends EventEmitter { const rawContentLength = Number(response.headers.get("content-length") || 0); const contentLength = Number.isFinite(rawContentLength) && rawContentLength > 0 ? rawContentLength : 0; - const totalFromRange = parseContentRangeTotal(response.headers.get("content-range")); + const parsedContentRange = parseContentRange(response.headers.get("content-range")); + const totalFromRange = parsedContentRange?.total ?? null; const serverIgnoredRange = existingBytes > 0 && response.status === 200; const allowFreshOverwriteAfterResumeReset = serverIgnoredRange && active.resumeHardResetUsed @@ -8655,19 +8681,69 @@ export class DownloadManager extends EventEmitter { } throw new Error(`range_ignored_on_resume:${existingBytes}/${contentLength || 0}`); } - if (allowFreshOverwriteAfterResumeReset) { - logger.warn( - `Server ignorierte Range-Header nach Resume-Reset, ueberschreibe alte Teil-Datei frisch: ${item.fileName}` - ); + if (allowFreshOverwriteAfterResumeReset) { + logger.warn( + `Server ignorierte Range-Header nach Resume-Reset, ueberschreibe alte Teil-Datei frisch: ${item.fileName}` + ); logAttemptEvent("WARN", "Range ignoriert nach Resume-Reset, frischer Vollstart erlaubt", { attempt, existingBytes, contentLength, - directUrl - }); - } - - const correctedRealDebridTotal = getAuthoritativeRealDebridTotal( + directUrl + }); + } + if (existingBytes > 0 && response.status === 206) { + if (!parsedContentRange) { + logAttemptEvent("WARN", "Resume-Range-Header ungueltig oder fehlt", { + attempt, + existingBytes, + contentRange: response.headers.get("content-range") || "" + }); + try { + await response.body?.cancel(); + } catch { + // ignore + } + throw new Error(`range_mismatch_on_resume:${existingBytes}/invalid`); + } + if (parsedContentRange.start !== existingBytes) { + const sizeToleranceBytes = isLargeBinaryLikePath(item.fileName || effectiveTargetPath) ? 0 : ALLOCATION_UNIT_SIZE; + const canTreatAsAlreadyComplete = contentLength === 0 + && parsedContentRange.start === 0 + && parsedContentRange.total != null + && Math.abs(existingBytes - parsedContentRange.total) <= sizeToleranceBytes; + if (canTreatAsAlreadyComplete) { + item.totalBytes = parsedContentRange.total; + item.downloadedBytes = existingBytes; + item.progressPercent = 100; + item.speedBps = 0; + item.updatedAt = nowMs(); + logAttemptEvent("WARN", "Resume-Range-Start abweichend, Datei aber bereits vollstaendig", { + attempt, + existingBytes, + totalFromRange: parsedContentRange.total, + contentLength + }); + return { resumable: true }; + } + logAttemptEvent("WARN", "Resume-Range-Start stimmt nicht mit lokaler Dateigroesse ueberein", { + attempt, + expectedStart: existingBytes, + actualStart: parsedContentRange.start, + actualEnd: parsedContentRange.end, + totalFromRange, + directUrl + }); + try { + await response.body?.cancel(); + } catch { + // ignore + } + throw new Error(`range_mismatch_on_resume:${existingBytes}/${parsedContentRange.start}`); + } + } + + const correctedRealDebridTotal = getAuthoritativeRealDebridTotal( item.provider, knownTotal || 0, existingBytes, @@ -9255,10 +9331,11 @@ export class DownloadManager extends EventEmitter { } } - const completionValidation = validateDownloadedFileCompletion({ - actualBytes: written, - plan: completionPlan - }); + const completionValidation = validateDownloadedFileCompletion({ + actualBytes: written, + plan: completionPlan, + toleranceBytes: isLargeBinaryLikePath(item.fileName || effectiveTargetPath) ? 0 : ALLOCATION_UNIT_SIZE + }); if (!completionValidation.ok) { const shortfall = Math.max(0, completionValidation.totalBytes - written); if (preAllocated) { @@ -9330,7 +9407,10 @@ export class DownloadManager extends EventEmitter { error: lastError, targetPath: effectiveTargetPath }); - if (normalizedLastError.startsWith("range_ignored_on_resume:")) { + if ( + normalizedLastError.startsWith("range_ignored_on_resume:") + || normalizedLastError.startsWith("range_mismatch_on_resume:") + ) { throw new Error(`direct_link_retry_exhausted:${normalizedLastError}`); } if (attempt < maxAttempts && written > existingBytes && shouldRewindResumeTail(normalizedLastError)) { @@ -9825,11 +9905,8 @@ export class DownloadManager extends EventEmitter { try { const stat = await fs.promises.stat(part); // Find the item that owns this file to get its expected totalBytes - const ownerItem = this.findItemByDiskPath(pkg, part); - const ownerTotalBytes = ownerItem?.totalBytes ?? 0; - const minBytes = ownerTotalBytes > 0 - ? ownerTotalBytes - ALLOCATION_UNIT_SIZE - : 10240; + const ownerItem = this.findItemByDiskPath(pkg, part); + const minBytes = expectedMinBytes(ownerItem?.totalBytes ?? 0, isLargeBinaryLikePath(part)); if (stat.size < minBytes) { allMissingFullOnDisk = false; break; @@ -10363,17 +10440,29 @@ export class DownloadManager extends EventEmitter { if (item.status === "downloading" || item.status === "validating" || item.status === "integrity_check") { continue; } - if (!item.targetPath) { - continue; - } - try { - const stat = await fs.promises.stat(item.targetPath); + if (!item.targetPath) { + continue; + } + if (!isPathInsideDir(item.targetPath, pkg.outputDir)) { + logger.warn(`Item-Recovery: Unsicherer targetPath verworfen (${item.fileName} -> ${item.targetPath})`); + this.releaseTargetPath(item.id); + this.dropItemContribution(item.id); + item.targetPath = ""; + item.status = "queued"; + item.attempts = 0; + item.downloadedBytes = 0; + item.progressPercent = 0; + item.speedBps = 0; + item.fullStatus = "Wartet (ungueltiger Zielpfad)"; + item.updatedAt = nowMs(); + continue; + } + try { + const stat = await fs.promises.stat(item.targetPath); // Require file to be essentially complete — within one allocation unit of the // expected size. The old 50% threshold incorrectly recovered partial downloads // (e.g. 627 MB of 1001 MB) and triggered hybrid extraction on incomplete archives. - const minSize = item.totalBytes && item.totalBytes > 0 - ? Math.max(10240, item.totalBytes - ALLOCATION_UNIT_SIZE) - : 10240; + const minSize = expectedMinBytes(item.totalBytes, isLargeBinaryLikePath(item.fileName || item.targetPath)); if (stat.size >= minSize) { // Re-check: another task may have started this item during the await const latestItem = this.session.items[item.id]; @@ -10383,9 +10472,9 @@ export class DownloadManager extends EventEmitter { } // Guard against pre-allocated sparse files from a hard crash: file has // the full expected size but downloadedBytes is significantly behind. - if (item.downloadedBytes > 0 && item.totalBytes && item.totalBytes > 0 - && stat.size >= item.totalBytes - ALLOCATION_UNIT_SIZE - && item.downloadedBytes < item.totalBytes * 0.95) { + if (item.downloadedBytes > 0 && item.totalBytes && item.totalBytes > 0 + && stat.size >= minSize + && item.downloadedBytes < item.totalBytes * 0.95) { logger.warn(`Item-Recovery: ${item.fileName} uebersprungen – vermutlich pre-alloc (stat=${humanSize(stat.size)}, bytes=${humanSize(item.downloadedBytes)}, total=${humanSize(item.totalBytes)})`); continue; } diff --git a/src/main/extractor.ts b/src/main/extractor.ts index ffa6791..04adea4 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -2154,9 +2154,9 @@ async function runExternalExtractInner( let lastError = ""; const extractorName = path.basename(command).replace(/\.exe$/i, "") || command; - const quotedPasswords = passwords.map((p) => p === "" ? '""' : `"${p}"`); + const emptyPasswordCount = passwords.filter((candidate) => candidate === "").length; onLog?.("INFO", `Legacy-Extractor Start: archive=${path.basename(archivePath)}, extractor=${extractorName}, passwordCount=${passwords.length}, forceFlatMode=${forceFlatMode}, targetDir=${targetDir}`); - logger.info(`Legacy-Extractor (${extractorName}): ${path.basename(archivePath)}, ${passwords.length} Passwörter: [${quotedPasswords.join(", ")}]${forceFlatMode ? " (flat-mode cached)" : ""}`); + logger.info(`Legacy-Extractor (${extractorName}): ${path.basename(archivePath)}, passwordCount=${passwords.length}, redacted=true, emptyCandidates=${emptyPasswordCount}${forceFlatMode ? " (flat-mode cached)" : ""}`); let announcedStart = false; let bestPercent = 0; @@ -2173,9 +2173,8 @@ async function runExternalExtractInner( for (const password of passwords) { if (signal?.aborted) throw new Error("aborted:extract"); passwordAttempt += 1; - const quotedPw = password === "" ? '""' : `"${password}"`; - onLog?.("INFO", `Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, password=${quotedPw}`); - logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)}: ${quotedPw}`); + 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 runExtractCommand(command, args, (chunk) => { const parsed = parseProgressPercent(chunk); @@ -2203,9 +2202,8 @@ async function runExternalExtractInner( } passwordAttempt += 1; const attemptStartedAt = Date.now(); - const quotedPw = password === "" ? '""' : `"${password}"`; - onLog?.("INFO", `Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, password=${quotedPw}`); - logger.info(`Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)}: ${quotedPw}`); + onLog?.("INFO", `Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, password=`); + logger.info(`Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=)`); if (passwords.length > 1) { onPasswordAttempt?.(passwordAttempt, passwords.length); } @@ -2262,8 +2260,8 @@ async function runExternalExtractInner( if (!createErrorText && result.errorText.includes("Cannot create")) { createErrorText = result.errorText; createErrorPassword = password; - logger.warn(`Entpack-Pfadfehler gemerkt: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, extractor=${extractorName}, password=${quotedPw}`); - onLog?.("WARN", `Entpack-Pfadfehler gemerkt: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, extractor=${extractorName}, password=${quotedPw}`); + logger.warn(`Entpack-Pfadfehler gemerkt: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, extractor=${extractorName}, password=`); + onLog?.("WARN", `Entpack-Pfadfehler gemerkt: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, extractor=${extractorName}, password=`); } if (result.aborted) { @@ -2300,9 +2298,8 @@ async function runExternalExtractInner( for (const password of flatPasswords) { if (signal?.aborted) throw new Error("aborted:extract"); passwordAttempt += 1; - const quotedPw = password === "" ? '""' : `"${password}"`; - logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)}: ${quotedPw}`); - onLog?.("INFO", `Flach-Extraktion Versuch ${passwordAttempt}/${flatPasswords.length}: archive=${path.basename(archivePath)}, password=${quotedPw}`); + 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 runExtractCommand(command, args, (chunk) => { const parsed = parseProgressPercent(chunk); @@ -2360,8 +2357,8 @@ async function runExternalExtract( } logger.warn(`JVM-Extractor nicht verfügbar, nutze Legacy-Extractor: ${path.basename(archivePath)}`); } else { - const quotedPasswords = passwordCandidates.map((p) => p === "" ? '""' : `"${p}"`); - logger.info(`JVM-Extractor aktiv (${layout.rootDir}): ${archiveName}, ${passwordCandidates.length} Passwörter: [${quotedPasswords.join(", ")}]`); + const emptyCount = passwordCandidates.filter((candidate) => candidate === "").length; + logger.info(`JVM-Extractor aktiv (${layout.rootDir}): ${archiveName}, passwordCount=${passwordCandidates.length}, redacted=true, emptyCandidates=${emptyCount}`); const jvmStartedAt = Date.now(); onLog?.("INFO", `JVM-Extractor vorbereitet: archive=${archiveName}, passwordCandidates=${passwordCandidates.length}, layout=${layout.rootDir}`); const jvmResult = await runJvmExtractCommand( @@ -3128,7 +3125,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ logger.info(`Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}${hybrid ? " (hybrid, reduced threads, low I/O)" : ""}`); options.onLog?.("INFO", `Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}${hybrid ? " (hybrid, reduced threads, low I/O)" : ""}`); - options.onLog?.("INFO", `Archiv-Passwortliste: archive=${archiveName}, candidates=[${archivePasswordCandidates.map((p) => p === "" ? '""' : `"${p}"`).join(", ")}]`); + const emptyArchivePasswordCount = archivePasswordCandidates.filter((candidate) => candidate === "").length; + options.onLog?.("INFO", `Archiv-Passwortliste: archive=${archiveName}, passwordCount=${archivePasswordCandidates.length}, redacted=true, emptyCandidates=${emptyArchivePasswordCount}`); const hasManyPasswords = archivePasswordCandidates.length > 1; if (hasManyPasswords) { emitProgress(extracted + failed, archiveName, "extracting", 0, 0, { passwordAttempt: 0, passwordTotal: archivePasswordCandidates.length }); @@ -3136,8 +3134,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ const onPwAttempt = hasManyPasswords ? (attempt: number, total: number) => { emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordAttempt: attempt, passwordTotal: total }); - const attemptedPassword = archivePasswordCandidates[Math.max(0, attempt - 1)] ?? ""; - options.onLog?.("INFO", `Passwort-Versuch ${attempt}/${total}: archive=${archiveName}, password=${attemptedPassword === "" ? '""' : `"${attemptedPassword}"`}`); + options.onLog?.("INFO", `Passwort-Versuch ${attempt}/${total}: archive=${archiveName}, password=`); } : undefined; try { diff --git a/src/main/item-log.ts b/src/main/item-log.ts index ca4c5a5..bc862b3 100644 --- a/src/main/item-log.ts +++ b/src/main/item-log.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import crypto from "node:crypto"; const ITEM_LOG_FLUSH_INTERVAL_MS = 200; const ITEM_LOG_RETENTION_DAYS = 30; @@ -21,7 +22,17 @@ const initializedThisProcess = new Set(); let flushTimer: NodeJS.Timeout | null = null; function normalizeItemId(itemId: string): string { - return String(itemId || "").trim(); + const trimmed = String(itemId || "").trim(); + if (!trimmed) { + return ""; + } + const safePrefix = trimmed + .replace(/[^a-zA-Z0-9._-]/g, "_") + .replace(/_+/g, "_") + .slice(0, 64) + .replace(/^_+|_+$/g, ""); + const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 12); + return `${safePrefix || "item"}_${hash}`; } function sanitizeFieldValue(value: unknown): string { @@ -51,8 +62,7 @@ function formatFields(fields?: Record): string { return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; } -function getItemLogFilePath(itemId: string): string | null { - const normalized = normalizeItemId(itemId); +function getItemLogFilePathFromNormalized(normalized: string): string | null { if (!normalized || !itemLogsDir) { return null; } @@ -65,12 +75,16 @@ function getItemLogFilePath(itemId: string): string | null { return logPath; } +function getItemLogFilePath(itemId: string): string | null { + return getItemLogFilePathFromNormalized(normalizeItemId(itemId)); +} + function flushPending(): void { for (const [itemId, lines] of pendingLinesByItem.entries()) { if (lines.length === 0) { continue; } - const logPath = getItemLogFilePath(itemId); + const logPath = getItemLogFilePathFromNormalized(itemId); if (!logPath) { continue; } @@ -140,8 +154,8 @@ export function initItemLogs(baseDir: string): void { } export function ensureItemLog(meta: ItemLogMeta): string | null { - const itemId = normalizeItemId(meta.itemId); - const logPath = getItemLogFilePath(itemId); + const normalizedItemId = normalizeItemId(meta.itemId); + const logPath = getItemLogFilePath(meta.itemId); if (!logPath) { return null; } @@ -150,12 +164,12 @@ export function ensureItemLog(meta: ItemLogMeta): string | null { if (!fs.existsSync(logPath)) { fs.writeFileSync(logPath, "", "utf8"); } - if (!initializedThisProcess.has(itemId)) { - initializedThisProcess.add(itemId); + if (!initializedThisProcess.has(normalizedItemId)) { + initializedThisProcess.add(normalizedItemId); const startedAt = new Date().toISOString(); fs.appendFileSync( logPath, - `=== Item-Log Start: ${startedAt} | itemId=${itemId} | fileName=${sanitizeFieldValue(meta.fileName)} ===\n`, + `=== Item-Log Start: ${startedAt} | itemId=${sanitizeFieldValue(String(meta.itemId || ""))} | logKey=${normalizedItemId} | fileName=${sanitizeFieldValue(meta.fileName)} ===\n`, "utf8" ); fs.appendFileSync( @@ -204,7 +218,7 @@ export function shutdownItemLogs(): void { } flushPending(); for (const itemId of knownLogPaths.keys()) { - const logPath = getItemLogFilePath(itemId); + const logPath = getItemLogFilePathFromNormalized(itemId); if (!logPath) { continue; } diff --git a/src/main/package-log.ts b/src/main/package-log.ts index 2f22449..33777f6 100644 --- a/src/main/package-log.ts +++ b/src/main/package-log.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import crypto from "node:crypto"; const PACKAGE_LOG_FLUSH_INTERVAL_MS = 200; const PACKAGE_LOG_RETENTION_DAYS = 30; @@ -20,7 +21,17 @@ const initializedThisProcess = new Set(); let flushTimer: NodeJS.Timeout | null = null; function normalizePackageId(packageId: string): string { - return String(packageId || "").trim(); + const trimmed = String(packageId || "").trim(); + if (!trimmed) { + return ""; + } + const safePrefix = trimmed + .replace(/[^a-zA-Z0-9._-]/g, "_") + .replace(/_+/g, "_") + .slice(0, 64) + .replace(/^_+|_+$/g, ""); + const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 12); + return `${safePrefix || "pkg"}_${hash}`; } function sanitizeFieldValue(value: unknown): string { @@ -50,8 +61,7 @@ function formatFields(fields?: Record): string { return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; } -function getPackageLogFilePath(packageId: string): string | null { - const normalized = normalizePackageId(packageId); +function getPackageLogFilePathFromNormalized(normalized: string): string | null { if (!normalized || !packageLogsDir) { return null; } @@ -64,12 +74,16 @@ function getPackageLogFilePath(packageId: string): string | null { return logPath; } +function getPackageLogFilePath(packageId: string): string | null { + return getPackageLogFilePathFromNormalized(normalizePackageId(packageId)); +} + function flushPending(): void { for (const [packageId, lines] of pendingLinesByPackage.entries()) { if (lines.length === 0) { continue; } - const logPath = getPackageLogFilePath(packageId); + const logPath = getPackageLogFilePathFromNormalized(packageId); if (!logPath) { continue; } @@ -139,8 +153,8 @@ export function initPackageLogs(baseDir: string): void { } export function ensurePackageLog(meta: PackageLogMeta): string | null { - const packageId = normalizePackageId(meta.packageId); - const logPath = getPackageLogFilePath(packageId); + const normalizedPackageId = normalizePackageId(meta.packageId); + const logPath = getPackageLogFilePath(meta.packageId); if (!logPath) { return null; } @@ -149,12 +163,12 @@ export function ensurePackageLog(meta: PackageLogMeta): string | null { if (!fs.existsSync(logPath)) { fs.writeFileSync(logPath, "", "utf8"); } - if (!initializedThisProcess.has(packageId)) { - initializedThisProcess.add(packageId); + if (!initializedThisProcess.has(normalizedPackageId)) { + initializedThisProcess.add(normalizedPackageId); const startedAt = new Date().toISOString(); fs.appendFileSync( logPath, - `=== Paket-Log Start: ${startedAt} | packageId=${packageId} | name=${sanitizeFieldValue(meta.name)} ===\n`, + `=== Paket-Log Start: ${startedAt} | packageId=${sanitizeFieldValue(String(meta.packageId || ""))} | logKey=${normalizedPackageId} | name=${sanitizeFieldValue(meta.name)} ===\n`, "utf8" ); fs.appendFileSync( @@ -202,7 +216,7 @@ export function shutdownPackageLogs(): void { } flushPending(); for (const packageId of knownLogPaths.keys()) { - const logPath = getPackageLogFilePath(packageId); + const logPath = getPackageLogFilePathFromNormalized(packageId); if (!logPath) { continue; } diff --git a/src/main/storage.ts b/src/main/storage.ts index f0a067e..1fef58f 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -17,16 +17,48 @@ const VALID_SPEED_MODES = new Set(["global", "per_download"]); const VALID_THEMES = new Set(["dark", "light"]); const VALID_EXTRACT_CPU_PRIORITIES = new Set(["high", "middle", "low"]); const VALID_HISTORY_RETENTION_MODES = new Set(["never", "session", "permanent"]); -const VALID_PACKAGE_PRIORITIES = new Set(["high", "normal", "low"]); -const VALID_DOWNLOAD_STATUSES = new Set([ - "queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled" -]); -const VALID_ITEM_PROVIDERS = new Set(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]); -const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]); +const VALID_PACKAGE_PRIORITIES = new Set(["high", "normal", "low"]); +const VALID_DOWNLOAD_STATUSES = new Set([ + "queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled" +]); +const VALID_ITEM_PROVIDERS = new Set(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]); +const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]); +const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/; -function asText(value: unknown): string { - return String(value ?? "").trim(); -} +function asText(value: unknown): string { + return String(value ?? "").trim(); +} + +function normalizeSessionId(value: unknown): string { + const text = asText(value); + if (!text || !SAFE_SESSION_ID_RE.test(text)) { + return ""; + } + return text; +} + +function isPathInsideDir(filePath: string, dirPath: string): boolean { + try { + const resolvedFile = path.resolve(filePath); + const resolvedDir = path.resolve(dirPath); + const normalizedFile = process.platform === "win32" ? resolvedFile.toLowerCase() : resolvedFile; + const normalizedDir = process.platform === "win32" ? resolvedDir.toLowerCase() : resolvedDir; + return normalizedFile === normalizedDir || normalizedFile.startsWith(`${normalizedDir}${path.sep}`); + } catch { + return false; + } +} + +function normalizeSessionTargetPath(value: unknown, packageOutputDir: string): string { + const targetPath = asText(value); + if (!targetPath || !packageOutputDir || !path.isAbsolute(targetPath)) { + return ""; + } + if (!isPathInsideDir(targetPath, packageOutputDir)) { + return ""; + } + return path.resolve(targetPath); +} function clampNumber(value: unknown, fallback: number, min: number, max: number): number { const num = Number(value); @@ -538,18 +570,18 @@ export function normalizeLoadedSession(raw: unknown): SessionState { const now = Date.now(); const itemsById: Record = {}; - const rawItems = asRecord(parsed.items) ?? {}; - for (const [entryId, rawItem] of Object.entries(rawItems)) { - const item = asRecord(rawItem); - if (!item) { - continue; - } - const id = asText(item.id) || entryId; - const packageId = asText(item.packageId); - const url = asText(item.url); - if (!id || !packageId || !url) { - continue; - } + const rawItems = asRecord(parsed.items) ?? {}; + for (const [entryId, rawItem] of Object.entries(rawItems)) { + const item = asRecord(rawItem); + if (!item) { + continue; + } + const id = normalizeSessionId(item.id) || normalizeSessionId(entryId); + const packageId = normalizeSessionId(item.packageId); + const url = asText(item.url); + if (!id || !packageId || !url) { + continue; + } const statusRaw = asText(item.status) as DownloadStatus; const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued"; @@ -584,16 +616,16 @@ export function normalizeLoadedSession(raw: unknown): SessionState { } const packagesById: Record = {}; - const rawPackages = asRecord(parsed.packages) ?? {}; - for (const [entryId, rawPkg] of Object.entries(rawPackages)) { - const pkg = asRecord(rawPkg); - if (!pkg) { - continue; - } - const id = asText(pkg.id) || entryId; - if (!id) { - continue; - } + const rawPackages = asRecord(parsed.packages) ?? {}; + for (const [entryId, rawPkg] of Object.entries(rawPackages)) { + const pkg = asRecord(rawPkg); + if (!pkg) { + continue; + } + const id = normalizeSessionId(pkg.id) || normalizeSessionId(entryId); + if (!id) { + continue; + } const statusRaw = asText(pkg.status) as DownloadStatus; const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued"; const rawItemIds = Array.isArray(pkg.itemIds) ? pkg.itemIds : []; @@ -601,11 +633,11 @@ export function normalizeLoadedSession(raw: unknown): SessionState { id, name: asText(pkg.name) || "Paket", outputDir: asText(pkg.outputDir), - extractDir: asText(pkg.extractDir), - status, - itemIds: rawItemIds - .map((value) => asText(value)) - .filter((value) => value.length > 0), + extractDir: asText(pkg.extractDir), + status, + itemIds: rawItemIds + .map((value) => normalizeSessionId(value)) + .filter((value) => value.length > 0), cancelled: Boolean(pkg.cancelled), enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled), priority: VALID_PACKAGE_PRIORITIES.has(asText(pkg.priority)) ? asText(pkg.priority) as PackagePriority : "normal", @@ -623,9 +655,25 @@ export function normalizeLoadedSession(raw: unknown): SessionState { delete itemsById[itemId]; } } - if (orphanedItemCount > 0) { - logger.warn(`normalizeLoadedSession: ${orphanedItemCount} verwaiste Items entfernt (fehlende Pakete)`); - } + if (orphanedItemCount > 0) { + logger.warn(`normalizeLoadedSession: ${orphanedItemCount} verwaiste Items entfernt (fehlende Pakete)`); + } + + let droppedUnsafeTargetPathCount = 0; + for (const item of Object.values(itemsById)) { + const pkg = packagesById[item.packageId]; + if (!pkg) { + continue; + } + const safeTargetPath = normalizeSessionTargetPath(item.targetPath, pkg.outputDir); + if (!safeTargetPath && asText(item.targetPath)) { + droppedUnsafeTargetPathCount += 1; + } + item.targetPath = safeTargetPath; + } + if (droppedUnsafeTargetPathCount > 0) { + logger.warn(`normalizeLoadedSession: ${droppedUnsafeTargetPathCount} unsichere targetPath-Eintraege verworfen`); + } for (const pkg of Object.values(packagesById)) { pkg.itemIds = pkg.itemIds.filter((itemId) => { @@ -634,13 +682,13 @@ export function normalizeLoadedSession(raw: unknown): SessionState { }); } - const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : []; - const seenOrder = new Set(); - const packageOrder = rawOrder - .map((entry) => asText(entry)) - .filter((id) => { - if (!(id in packagesById) || seenOrder.has(id)) { - return false; + const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : []; + const seenOrder = new Set(); + const packageOrder = rawOrder + .map((entry) => normalizeSessionId(entry)) + .filter((id) => { + if (!(id in packagesById) || seenOrder.has(id)) { + return false; } seenOrder.add(id); return true; diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index 971ca15..eead79c 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -113,9 +113,6 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string): B addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`); addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt"); addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt"); - addFileIfExists(zip, storagePaths.configFile, "runtime/rd_downloader_config.json"); - addFileIfExists(zip, storagePaths.sessionFile, "runtime/rd_session_state.json"); - addFileIfExists(zip, storagePaths.historyFile, "runtime/rd_history.json"); addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json"); addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log"); diff --git a/src/main/update.ts b/src/main/update.ts index 7718e34..154b415 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -380,8 +380,11 @@ async function verifyDownloadedInstaller(filePath: string, digestRaw: string): P const expected = parseExpectedDigest(digestRaw); if (!expected) { - logger.warn("Update-Asset ohne gültigen SHA-Digest; nur EXE-Basisprüfung durchgeführt"); - return; + if (String(process.env.RD_ALLOW_UNSIGNED_UPDATE || "").trim() === "1") { + logger.warn("Update-Asset ohne gültigen SHA-Digest (RD_ALLOW_UNSIGNED_UPDATE=1) - nur EXE-Basisprüfung durchgeführt"); + return; + } + throw new Error("Update-Asset ohne gültigen SHA-Digest"); } const actualRaw = await hashFile(filePath, expected.algorithm, expected.encoding); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 82e99a6..45d1c6d 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -4235,7 +4235,8 @@ describe("download manager", () => { for (const [index, archiveName] of archiveNames.entries()) { const targetPath = path.join(outputDir, archiveName); - fs.writeFileSync(targetPath, Buffer.from(`part-${index}`)); + const partBytes = Buffer.alloc(4096, 0x41 + index); + fs.writeFileSync(targetPath, partBytes); session.items[itemIds[index]!] = { id: itemIds[index]!, packageId, @@ -4244,8 +4245,8 @@ describe("download manager", () => { status: "completed", retries: 0, speedBps: 0, - downloadedBytes: 4096, - totalBytes: 4096, + downloadedBytes: partBytes.length, + totalBytes: partBytes.length, progressPercent: 100, fileName: archiveName, targetPath, @@ -5315,9 +5316,9 @@ describe("download manager", () => { const part2 = path.join(packageDir, "legacy.old.part02.rar"); const part3 = path.join(packageDir, "legacy.old.part03.rar"); const keep = path.join(packageDir, "keep.nfo"); - fs.writeFileSync(part1, "part1", "utf8"); - fs.writeFileSync(part2, "part2", "utf8"); - fs.writeFileSync(part3, "part3", "utf8"); + fs.writeFileSync(part1, Buffer.alloc(123, 0x61)); + fs.writeFileSync(part2, Buffer.alloc(123, 0x62)); + fs.writeFileSync(part3, Buffer.alloc(123, 0x63)); fs.writeFileSync(keep, "keep", "utf8"); fs.writeFileSync(path.join(extractDir, "episode.mkv"), "video", "utf8"); @@ -7846,7 +7847,12 @@ describe("download manager", () => { createStoragePaths(path.join(root, "state")) ); - await waitFor(() => fs.existsSync(path.join(extractDir, "episode.txt")), 25000); + await waitFor( + () => + fs.existsSync(path.join(extractDir, "episode.txt")) && + manager.getSnapshot().session.packages[packageId]?.status === "completed", + 25000 + ); const snapshot = manager.getSnapshot(); expect(snapshot.session.packages[packageId]?.status).toBe("completed"); expect(snapshot.session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true); diff --git a/tests/item-log.test.ts b/tests/item-log.test.ts index a66499b..6a3fa0f 100644 --- a/tests/item-log.test.ts +++ b/tests/item-log.test.ts @@ -63,4 +63,23 @@ describe("item-log", () => { expect(content).toContain("archive=episode.part2.rar"); expect(content).toContain("code=missing_parts"); }); + + it("keeps traversal-like item ids inside the item log directory", () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-")); + tempDirs.push(baseDir); + + initItemLogs(baseDir); + const logPath = ensureItemLog({ + itemId: "..\\..\\outside", + packageId: "pkg-traversal", + packageName: "Traversal Paket", + fileName: "episode.part2.rar", + targetPath: "C:\\downloads\\Traversal Paket\\episode.part2.rar" + }); + + expect(logPath).not.toBeNull(); + const logsDir = path.resolve(path.join(baseDir, "item-logs")); + const resolvedLogPath = path.resolve(logPath!); + expect(resolvedLogPath === logsDir || resolvedLogPath.startsWith(`${logsDir}${path.sep}`)).toBe(true); + }); }); diff --git a/tests/package-log.test.ts b/tests/package-log.test.ts index 2008d6e..2219958 100644 --- a/tests/package-log.test.ts +++ b/tests/package-log.test.ts @@ -61,4 +61,22 @@ describe("package-log", () => { expect(content).toContain("archive=episode.part1.rar"); expect(content).toContain("password=\"secret\""); }); + + it("keeps traversal-like package ids inside the package log directory", () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-")); + tempDirs.push(baseDir); + + initPackageLogs(baseDir); + const logPath = ensurePackageLog({ + packageId: "..\\..\\outside", + name: "Traversal Paket", + outputDir: "C:\\downloads\\Traversal Paket", + extractDir: "C:\\extract\\Traversal Paket" + }); + + expect(logPath).not.toBeNull(); + const logsDir = path.resolve(path.join(baseDir, "package-logs")); + const resolvedLogPath = path.resolve(logPath!); + expect(resolvedLogPath === logsDir || resolvedLogPath.startsWith(`${logsDir}${path.sep}`)).toBe(true); + }); }); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 30f2c53..ddd84d8 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -572,7 +572,7 @@ describe("settings storage", () => { expect(loaded.packageName).toBe("from-backup"); }); - it("sanitizes malformed persisted session structures", () => { + it("sanitizes malformed persisted session structures", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); tempDirs.push(dir); const paths = createStoragePaths(dir); @@ -609,8 +609,77 @@ describe("settings storage", () => { const loaded = loadSession(paths); expect(Object.keys(loaded.packages)).toEqual(["pkg-valid"]); expect(Object.keys(loaded.items)).toEqual(["item-valid"]); - expect(loaded.packageOrder).toEqual(["pkg-valid"]); - }); + expect(loaded.packageOrder).toEqual(["pkg-valid"]); + }); + + it("drops unsafe session ids and target paths outside the package output directory", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + const outputDir = path.join(dir, "downloads", "safe"); + const safeTargetPath = path.join(outputDir, "safe.bin"); + const outsideTargetPath = path.join(dir, "outside.bin"); + + fs.writeFileSync(paths.sessionFile, JSON.stringify({ + version: 2, + packageOrder: ["pkg-safe", "../pkg-evil"], + packages: { + "pkg-safe": { + id: "pkg-safe", + name: "Safe Package", + outputDir, + extractDir: path.join(dir, "extract", "safe"), + status: "queued", + itemIds: ["item-safe", "item-outside", "../item-evil"], + cancelled: false, + enabled: true + }, + "../pkg-evil": { + id: "../pkg-evil", + name: "Unsafe Package", + outputDir, + extractDir: path.join(dir, "extract", "unsafe"), + status: "queued", + itemIds: ["item-evil"], + cancelled: false, + enabled: true + } + }, + items: { + "item-safe": { + id: "item-safe", + packageId: "pkg-safe", + url: "https://example.com/safe", + status: "queued", + fileName: "safe.bin", + targetPath: safeTargetPath + }, + "item-outside": { + id: "item-outside", + packageId: "pkg-safe", + url: "https://example.com/outside", + status: "queued", + fileName: "outside.bin", + targetPath: outsideTargetPath + }, + "../item-evil": { + id: "../item-evil", + packageId: "pkg-safe", + url: "https://example.com/evil", + status: "queued", + fileName: "evil.bin", + targetPath: safeTargetPath + } + } + }), "utf8"); + + const loaded = loadSession(paths); + expect(Object.keys(loaded.packages)).toEqual(["pkg-safe"]); + expect(Object.keys(loaded.items).sort()).toEqual(["item-outside", "item-safe"]); + expect(loaded.packageOrder).toEqual(["pkg-safe"]); + expect(path.resolve(loaded.items["item-safe"]?.targetPath || "")).toBe(path.resolve(safeTargetPath)); + expect(loaded.items["item-outside"]?.targetPath).toBe(""); + }); it("captures async session save payload before later mutations", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); diff --git a/tests/update.test.ts b/tests/update.test.ts index 01ca753..0241655 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -290,7 +290,7 @@ describe("update", () => { } }, 20000); - it("blocks installer start when SHA256 digest mismatches", async () => { + it("blocks installer start when SHA256 digest mismatches", async () => { const executablePayload = fs.readFileSync(process.execPath); globalThis.fetch = (async (input: RequestInfo | URL): Promise => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; @@ -315,11 +315,40 @@ describe("update", () => { }; const result = await installLatestUpdate("owner/repo", prechecked); - expect(result.started).toBe(false); - expect(result.message).toMatch(/integrit|sha256|mismatch/i); - }); - - it("uses latest.yml SHA512 digest when API asset digest is missing", async () => { + expect(result.started).toBe(false); + expect(result.message).toMatch(/integrit|sha256|mismatch/i); + }); + + it("blocks installer start when no digest can be resolved", async () => { + const executablePayload = fs.readFileSync(process.execPath); + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("unsigned-setup.exe")) { + return new Response(executablePayload, { + status: 200, + headers: { "Content-Type": "application/octet-stream" } + }); + } + return new Response("missing", { status: 404 }); + }) as typeof fetch; + + const prechecked: UpdateCheckResult = { + updateAvailable: true, + currentVersion: APP_VERSION, + latestVersion: "9.9.9", + latestTag: "", + releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9", + setupAssetUrl: "https://example.invalid/unsigned-setup.exe", + setupAssetName: "setup.exe", + setupAssetDigest: "" + }; + + const result = await installLatestUpdate("owner/repo", prechecked); + expect(result.started).toBe(false); + expect(result.message).toMatch(/digest|integrit|sha/i); + }); + + it("uses latest.yml SHA512 digest when API asset digest is missing", async () => { const executablePayload = fs.readFileSync(process.execPath); const digestSha512Hex = sha512Hex(executablePayload); const digestSha512Base64 = Buffer.from(digestSha512Hex, "hex").toString("base64");