Harden download integrity, extraction safety, and update security

This commit is contained in:
Sucukdeluxe 2026-03-28 16:27:21 +01:00
parent 792a4249d0
commit 653e756010
13 changed files with 511 additions and 198 deletions

View File

@ -75,6 +75,7 @@ export function planDownloadCompletion(args: {
export function validateDownloadedFileCompletion(args: { export function validateDownloadedFileCompletion(args: {
actualBytes: number; actualBytes: number;
plan: DownloadCompletionPlan; plan: DownloadCompletionPlan;
toleranceBytes?: number;
}): { }): {
ok: boolean; ok: boolean;
totalBytes: number; totalBytes: number;
@ -85,11 +86,12 @@ export function validateDownloadedFileCompletion(args: {
const expectedTotal = Number.isFinite(args.plan.expectedTotal || NaN) const expectedTotal = Number.isFinite(args.plan.expectedTotal || NaN)
? Math.max(0, Math.floor(args.plan.expectedTotal || 0)) ? Math.max(0, Math.floor(args.plan.expectedTotal || 0))
: 0; : 0;
const toleranceBytes = Math.max(0, Math.floor(Number(args.toleranceBytes ?? ALLOCATION_UNIT_SIZE) || 0));
if ( if (
expectedTotal > 0 && expectedTotal > 0 &&
(args.plan.source === "content-range" || args.plan.source === "content-length") && (args.plan.source === "content-range" || args.plan.source === "content-length") &&
actualBytes + ALLOCATION_UNIT_SIZE < expectedTotal actualBytes + toleranceBytes < expectedTotal
) { ) {
return { return {
ok: false, ok: false,
@ -109,10 +111,18 @@ export function validateDownloadedFileCompletion(args: {
} }
if (args.plan.source === "provider-metadata") { 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 { return {
ok: true, ok: true,
totalBytes: actualBytes, totalBytes: actualBytes,
acceptedMetadataMismatch: expectedTotal > 0 && Math.abs(actualBytes - expectedTotal) > ALLOCATION_UNIT_SIZE acceptedMetadataMismatch: expectedTotal > 0 && Math.abs(actualBytes - expectedTotal) > toleranceBytes
}; };
} }

View File

@ -129,10 +129,16 @@ 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; 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 { function itemExpectedMinBytes(item: DownloadItem): number {
return item.totalBytes && item.totalBytes > 0 const strict = isLargeBinaryLikePath(item.targetPath || item.fileName || "");
? Math.max(10240, item.totalBytes - ALLOCATION_UNIT_SIZE) return expectedMinBytes(item.totalBytes, strict);
: 10240;
} }
function resolvePackageItemDiskPath(pkg: PackageEntry, item: DownloadItem): string | null { function resolvePackageItemDiskPath(pkg: PackageEntry, item: DownloadItem): string | null {
@ -335,16 +341,34 @@ function cloneSettings(settings: AppSettings): AppSettings {
}; };
} }
function parseContentRangeTotal(contentRange: string | null): number | null { type ParsedContentRange = {
start: number;
end: number;
total: number | null;
};
function parseContentRange(contentRange: string | null): ParsedContentRange | null {
if (!contentRange) { if (!contentRange) {
return null; return null;
} }
const match = contentRange.match(/\/(\d+)$/); const match = contentRange.match(/^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i);
if (!match) { if (!match) {
return null; return null;
} }
const value = Number(match[1]); const start = Number(match[1]);
return Number.isFinite(value) ? value : null; 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 { function parseContentDispositionFilename(contentDisposition: string | null): string {
@ -5228,9 +5252,19 @@ export class DownloadManager extends EventEmitter {
* instead of reusing its own partial file or worse, overwrite another item's file. */ * instead of reusing its own partial file or worse, overwrite another item's file. */
private restoreTargetPathReservations(): void { private restoreTargetPathReservations(): void {
let restored = 0; let restored = 0;
let droppedUnsafe = 0;
for (const item of Object.values(this.session.items)) { 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(); const tp = String(item.targetPath || "").trim();
if (!tp) continue; if (!tp) continue;
if (!isPathInsideDir(tp, pkg.outputDir)) {
droppedUnsafe += 1;
item.targetPath = "";
continue;
}
const key = pathKey(tp); const key = pathKey(tp);
if (!this.reservedTargetPaths.has(key)) { if (!this.reservedTargetPaths.has(key)) {
this.reservedTargetPaths.set(key, item.id); this.reservedTargetPaths.set(key, item.id);
@ -5241,6 +5275,9 @@ export class DownloadManager extends EventEmitter {
if (restored > 0) { if (restored > 0) {
logger.info(`restoreTargetPathReservations: ${restored} Pfade aus Session wiederhergestellt`); logger.info(`restoreTargetPathReservations: ${restored} Pfade aus Session wiederhergestellt`);
} }
if (droppedUnsafe > 0) {
logger.warn(`restoreTargetPathReservations: ${droppedUnsafe} unsichere targetPath-Eintraege verworfen`);
}
this.reconcileDuplicateSuffixSessionItems(); this.reconcileDuplicateSuffixSessionItems();
// Fix legacy (N) suffix files: rename back to original if original path is free // Fix legacy (N) suffix files: rename back to original if original path is free
this.fixDuplicateSuffixFiles(); this.fixDuplicateSuffixFiles();
@ -5409,7 +5446,7 @@ export class DownloadManager extends EventEmitter {
if (!targetPath || !item.totalBytes || item.totalBytes <= 0) continue; if (!targetPath || !item.totalBytes || item.totalBytes <= 0) continue;
try { try {
const stat = fs.statSync(targetPath); 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; const persistedShortfall = item.downloadedBytes < expectedMinSize && stat.size >= expectedMinSize;
if (stat.size < expectedMinSize) { if (stat.size < expectedMinSize) {
logger.warn(`revalidateCompleted: ${item.fileName} ist nur ${humanSize(stat.size)} statt ${humanSize(item.totalBytes)}, setze auf queued`); logger.warn(`revalidateCompleted: ${item.fileName} ist nur ${humanSize(stat.size)} statt ${humanSize(item.totalBytes)}, setze auf queued`);
@ -5489,13 +5526,14 @@ export class DownloadManager extends EventEmitter {
|| normalizedError.includes("resume_download_underflow"); || normalizedError.includes("resume_download_underflow");
const archiveLikeTarget = String(item.fileName || diskState.diskPath || "").toLowerCase(); 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 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 expectedMinSize = expectedMinBytes(item.totalBytes, isLargeBinaryLikePath(item.fileName || diskState.diskPath || ""));
const looksComplete = diskState.exists const looksComplete = diskState.exists
&& diskState.fullOnDisk && diskState.fullOnDisk
&& ( && (
diskState.reason === "ok" diskState.reason === "ok"
|| item.progressPercent >= 100 || item.progressPercent >= 100
|| item.downloadedBytes >= diskState.minBytes || item.downloadedBytes >= diskState.minBytes
|| (item.totalBytes != null && item.totalBytes > 0 && diskState.size >= item.totalBytes - ALLOCATION_UNIT_SIZE) || (item.totalBytes != null && item.totalBytes > 0 && diskState.size >= expectedMinSize)
); );
if (!looksComplete || (knownShortfall > 0 && (underflowIndicated || archiveLike))) { if (!looksComplete || (knownShortfall > 0 && (underflowIndicated || archiveLike))) {
return false; return false;
@ -8524,8 +8562,9 @@ export class DownloadManager extends EventEmitter {
const expectedTotal = rangeTotal && rangeTotal > 0 const expectedTotal = rangeTotal && rangeTotal > 0
? rangeTotal ? rangeTotal
: (knownTotal && knownTotal > 0 ? knownTotal : null); : (knownTotal && knownTotal > 0 ? knownTotal : null);
const sizeToleranceBytes = isLargeBinaryLikePath(item.fileName || effectiveTargetPath) ? 0 : ALLOCATION_UNIT_SIZE;
const closeEnoughToExpected = expectedTotal != null const closeEnoughToExpected = expectedTotal != null
&& Math.abs(existingBytes - expectedTotal) <= ALLOCATION_UNIT_SIZE; && Math.abs(existingBytes - expectedTotal) <= sizeToleranceBytes;
if (expectedTotal != null && closeEnoughToExpected) { if (expectedTotal != null && closeEnoughToExpected) {
const finalizedTotal = Math.max(existingBytes, expectedTotal); const finalizedTotal = Math.max(existingBytes, expectedTotal);
item.totalBytes = finalizedTotal; item.totalBytes = finalizedTotal;
@ -8539,20 +8578,6 @@ export class DownloadManager extends EventEmitter {
}); });
return { resumable: true }; 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 { try {
await fs.promises.rm(effectiveTargetPath, { force: true }); 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 rawContentLength = Number(response.headers.get("content-length") || 0);
const contentLength = Number.isFinite(rawContentLength) && rawContentLength > 0 ? rawContentLength : 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 serverIgnoredRange = existingBytes > 0 && response.status === 200;
const allowFreshOverwriteAfterResumeReset = serverIgnoredRange const allowFreshOverwriteAfterResumeReset = serverIgnoredRange
&& active.resumeHardResetUsed && active.resumeHardResetUsed
@ -8666,6 +8692,56 @@ export class DownloadManager extends EventEmitter {
directUrl 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( const correctedRealDebridTotal = getAuthoritativeRealDebridTotal(
item.provider, item.provider,
@ -9257,7 +9333,8 @@ export class DownloadManager extends EventEmitter {
const completionValidation = validateDownloadedFileCompletion({ const completionValidation = validateDownloadedFileCompletion({
actualBytes: written, actualBytes: written,
plan: completionPlan plan: completionPlan,
toleranceBytes: isLargeBinaryLikePath(item.fileName || effectiveTargetPath) ? 0 : ALLOCATION_UNIT_SIZE
}); });
if (!completionValidation.ok) { if (!completionValidation.ok) {
const shortfall = Math.max(0, completionValidation.totalBytes - written); const shortfall = Math.max(0, completionValidation.totalBytes - written);
@ -9330,7 +9407,10 @@ export class DownloadManager extends EventEmitter {
error: lastError, error: lastError,
targetPath: effectiveTargetPath 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}`); throw new Error(`direct_link_retry_exhausted:${normalizedLastError}`);
} }
if (attempt < maxAttempts && written > existingBytes && shouldRewindResumeTail(normalizedLastError)) { if (attempt < maxAttempts && written > existingBytes && shouldRewindResumeTail(normalizedLastError)) {
@ -9826,10 +9906,7 @@ export class DownloadManager extends EventEmitter {
const stat = await fs.promises.stat(part); const stat = await fs.promises.stat(part);
// Find the item that owns this file to get its expected totalBytes // Find the item that owns this file to get its expected totalBytes
const ownerItem = this.findItemByDiskPath(pkg, part); const ownerItem = this.findItemByDiskPath(pkg, part);
const ownerTotalBytes = ownerItem?.totalBytes ?? 0; const minBytes = expectedMinBytes(ownerItem?.totalBytes ?? 0, isLargeBinaryLikePath(part));
const minBytes = ownerTotalBytes > 0
? ownerTotalBytes - ALLOCATION_UNIT_SIZE
: 10240;
if (stat.size < minBytes) { if (stat.size < minBytes) {
allMissingFullOnDisk = false; allMissingFullOnDisk = false;
break; break;
@ -10366,14 +10443,26 @@ export class DownloadManager extends EventEmitter {
if (!item.targetPath) { if (!item.targetPath) {
continue; 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 { try {
const stat = await fs.promises.stat(item.targetPath); const stat = await fs.promises.stat(item.targetPath);
// Require file to be essentially complete — within one allocation unit of the // Require file to be essentially complete — within one allocation unit of the
// expected size. The old 50% threshold incorrectly recovered partial downloads // expected size. The old 50% threshold incorrectly recovered partial downloads
// (e.g. 627 MB of 1001 MB) and triggered hybrid extraction on incomplete archives. // (e.g. 627 MB of 1001 MB) and triggered hybrid extraction on incomplete archives.
const minSize = item.totalBytes && item.totalBytes > 0 const minSize = expectedMinBytes(item.totalBytes, isLargeBinaryLikePath(item.fileName || item.targetPath));
? Math.max(10240, item.totalBytes - ALLOCATION_UNIT_SIZE)
: 10240;
if (stat.size >= minSize) { if (stat.size >= minSize) {
// Re-check: another task may have started this item during the await // Re-check: another task may have started this item during the await
const latestItem = this.session.items[item.id]; const latestItem = this.session.items[item.id];
@ -10384,7 +10473,7 @@ export class DownloadManager extends EventEmitter {
// Guard against pre-allocated sparse files from a hard crash: file has // Guard against pre-allocated sparse files from a hard crash: file has
// the full expected size but downloadedBytes is significantly behind. // the full expected size but downloadedBytes is significantly behind.
if (item.downloadedBytes > 0 && item.totalBytes && item.totalBytes > 0 if (item.downloadedBytes > 0 && item.totalBytes && item.totalBytes > 0
&& stat.size >= item.totalBytes - ALLOCATION_UNIT_SIZE && stat.size >= minSize
&& item.downloadedBytes < item.totalBytes * 0.95) { && 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)})`); logger.warn(`Item-Recovery: ${item.fileName} uebersprungen vermutlich pre-alloc (stat=${humanSize(stat.size)}, bytes=${humanSize(item.downloadedBytes)}, total=${humanSize(item.totalBytes)})`);
continue; continue;

View File

@ -2154,9 +2154,9 @@ async function runExternalExtractInner(
let lastError = ""; let lastError = "";
const extractorName = path.basename(command).replace(/\.exe$/i, "") || command; 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}`); 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 announcedStart = false;
let bestPercent = 0; let bestPercent = 0;
@ -2173,9 +2173,8 @@ async function runExternalExtractInner(
for (const password of passwords) { for (const password of passwords) {
if (signal?.aborted) throw new Error("aborted:extract"); if (signal?.aborted) throw new Error("aborted:extract");
passwordAttempt += 1; passwordAttempt += 1;
const quotedPw = password === "" ? '""' : `"${password}"`; onLog?.("INFO", `Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, password=<redacted>`);
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)} (password=<redacted>)`);
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)}: ${quotedPw}`);
const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true); const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true);
const result = await runExtractCommand(command, args, (chunk) => { const result = await runExtractCommand(command, args, (chunk) => {
const parsed = parseProgressPercent(chunk); const parsed = parseProgressPercent(chunk);
@ -2203,9 +2202,8 @@ async function runExternalExtractInner(
} }
passwordAttempt += 1; passwordAttempt += 1;
const attemptStartedAt = Date.now(); const attemptStartedAt = Date.now();
const quotedPw = password === "" ? '""' : `"${password}"`; onLog?.("INFO", `Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, password=<redacted>`);
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)} (password=<redacted>)`);
logger.info(`Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)}: ${quotedPw}`);
if (passwords.length > 1) { if (passwords.length > 1) {
onPasswordAttempt?.(passwordAttempt, passwords.length); onPasswordAttempt?.(passwordAttempt, passwords.length);
} }
@ -2262,8 +2260,8 @@ async function runExternalExtractInner(
if (!createErrorText && result.errorText.includes("Cannot create")) { if (!createErrorText && result.errorText.includes("Cannot create")) {
createErrorText = result.errorText; createErrorText = result.errorText;
createErrorPassword = password; createErrorPassword = password;
logger.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=<redacted>`);
onLog?.("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=<redacted>`);
} }
if (result.aborted) { if (result.aborted) {
@ -2300,9 +2298,8 @@ async function runExternalExtractInner(
for (const password of flatPasswords) { for (const password of flatPasswords) {
if (signal?.aborted) throw new Error("aborted:extract"); if (signal?.aborted) throw new Error("aborted:extract");
passwordAttempt += 1; passwordAttempt += 1;
const quotedPw = password === "" ? '""' : `"${password}"`; logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=<redacted>)`);
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=<redacted>`);
onLog?.("INFO", `Flach-Extraktion Versuch ${passwordAttempt}/${flatPasswords.length}: archive=${path.basename(archivePath)}, password=${quotedPw}`);
const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true); const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true);
const result = await runExtractCommand(command, args, (chunk) => { const result = await runExtractCommand(command, args, (chunk) => {
const parsed = parseProgressPercent(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)}`); logger.warn(`JVM-Extractor nicht verfügbar, nutze Legacy-Extractor: ${path.basename(archivePath)}`);
} else { } else {
const quotedPasswords = passwordCandidates.map((p) => p === "" ? '""' : `"${p}"`); const emptyCount = passwordCandidates.filter((candidate) => candidate === "").length;
logger.info(`JVM-Extractor aktiv (${layout.rootDir}): ${archiveName}, ${passwordCandidates.length} Passwörter: [${quotedPasswords.join(", ")}]`); logger.info(`JVM-Extractor aktiv (${layout.rootDir}): ${archiveName}, passwordCount=${passwordCandidates.length}, redacted=true, emptyCandidates=${emptyCount}`);
const jvmStartedAt = Date.now(); const jvmStartedAt = Date.now();
onLog?.("INFO", `JVM-Extractor vorbereitet: archive=${archiveName}, passwordCandidates=${passwordCandidates.length}, layout=${layout.rootDir}`); onLog?.("INFO", `JVM-Extractor vorbereitet: archive=${archiveName}, passwordCandidates=${passwordCandidates.length}, layout=${layout.rootDir}`);
const jvmResult = await runJvmExtractCommand( 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)" : ""}`); 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", `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; const hasManyPasswords = archivePasswordCandidates.length > 1;
if (hasManyPasswords) { if (hasManyPasswords) {
emitProgress(extracted + failed, archiveName, "extracting", 0, 0, { passwordAttempt: 0, passwordTotal: archivePasswordCandidates.length }); 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 const onPwAttempt = hasManyPasswords
? (attempt: number, total: number) => { ? (attempt: number, total: number) => {
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordAttempt: attempt, passwordTotal: total }); 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=<redacted>`);
options.onLog?.("INFO", `Passwort-Versuch ${attempt}/${total}: archive=${archiveName}, password=${attemptedPassword === "" ? '""' : `"${attemptedPassword}"`}`);
} }
: undefined; : undefined;
try { try {

View File

@ -1,5 +1,6 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import crypto from "node:crypto";
const ITEM_LOG_FLUSH_INTERVAL_MS = 200; const ITEM_LOG_FLUSH_INTERVAL_MS = 200;
const ITEM_LOG_RETENTION_DAYS = 30; const ITEM_LOG_RETENTION_DAYS = 30;
@ -21,7 +22,17 @@ const initializedThisProcess = new Set<string>();
let flushTimer: NodeJS.Timeout | null = null; let flushTimer: NodeJS.Timeout | null = null;
function normalizeItemId(itemId: string): string { 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 { function sanitizeFieldValue(value: unknown): string {
@ -51,8 +62,7 @@ function formatFields(fields?: Record<string, unknown>): string {
return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
} }
function getItemLogFilePath(itemId: string): string | null { function getItemLogFilePathFromNormalized(normalized: string): string | null {
const normalized = normalizeItemId(itemId);
if (!normalized || !itemLogsDir) { if (!normalized || !itemLogsDir) {
return null; return null;
} }
@ -65,12 +75,16 @@ function getItemLogFilePath(itemId: string): string | null {
return logPath; return logPath;
} }
function getItemLogFilePath(itemId: string): string | null {
return getItemLogFilePathFromNormalized(normalizeItemId(itemId));
}
function flushPending(): void { function flushPending(): void {
for (const [itemId, lines] of pendingLinesByItem.entries()) { for (const [itemId, lines] of pendingLinesByItem.entries()) {
if (lines.length === 0) { if (lines.length === 0) {
continue; continue;
} }
const logPath = getItemLogFilePath(itemId); const logPath = getItemLogFilePathFromNormalized(itemId);
if (!logPath) { if (!logPath) {
continue; continue;
} }
@ -140,8 +154,8 @@ export function initItemLogs(baseDir: string): void {
} }
export function ensureItemLog(meta: ItemLogMeta): string | null { export function ensureItemLog(meta: ItemLogMeta): string | null {
const itemId = normalizeItemId(meta.itemId); const normalizedItemId = normalizeItemId(meta.itemId);
const logPath = getItemLogFilePath(itemId); const logPath = getItemLogFilePath(meta.itemId);
if (!logPath) { if (!logPath) {
return null; return null;
} }
@ -150,12 +164,12 @@ export function ensureItemLog(meta: ItemLogMeta): string | null {
if (!fs.existsSync(logPath)) { if (!fs.existsSync(logPath)) {
fs.writeFileSync(logPath, "", "utf8"); fs.writeFileSync(logPath, "", "utf8");
} }
if (!initializedThisProcess.has(itemId)) { if (!initializedThisProcess.has(normalizedItemId)) {
initializedThisProcess.add(itemId); initializedThisProcess.add(normalizedItemId);
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
fs.appendFileSync( fs.appendFileSync(
logPath, 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" "utf8"
); );
fs.appendFileSync( fs.appendFileSync(
@ -204,7 +218,7 @@ export function shutdownItemLogs(): void {
} }
flushPending(); flushPending();
for (const itemId of knownLogPaths.keys()) { for (const itemId of knownLogPaths.keys()) {
const logPath = getItemLogFilePath(itemId); const logPath = getItemLogFilePathFromNormalized(itemId);
if (!logPath) { if (!logPath) {
continue; continue;
} }

View File

@ -1,5 +1,6 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import crypto from "node:crypto";
const PACKAGE_LOG_FLUSH_INTERVAL_MS = 200; const PACKAGE_LOG_FLUSH_INTERVAL_MS = 200;
const PACKAGE_LOG_RETENTION_DAYS = 30; const PACKAGE_LOG_RETENTION_DAYS = 30;
@ -20,7 +21,17 @@ const initializedThisProcess = new Set<string>();
let flushTimer: NodeJS.Timeout | null = null; let flushTimer: NodeJS.Timeout | null = null;
function normalizePackageId(packageId: string): string { 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 { function sanitizeFieldValue(value: unknown): string {
@ -50,8 +61,7 @@ function formatFields(fields?: Record<string, unknown>): string {
return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
} }
function getPackageLogFilePath(packageId: string): string | null { function getPackageLogFilePathFromNormalized(normalized: string): string | null {
const normalized = normalizePackageId(packageId);
if (!normalized || !packageLogsDir) { if (!normalized || !packageLogsDir) {
return null; return null;
} }
@ -64,12 +74,16 @@ function getPackageLogFilePath(packageId: string): string | null {
return logPath; return logPath;
} }
function getPackageLogFilePath(packageId: string): string | null {
return getPackageLogFilePathFromNormalized(normalizePackageId(packageId));
}
function flushPending(): void { function flushPending(): void {
for (const [packageId, lines] of pendingLinesByPackage.entries()) { for (const [packageId, lines] of pendingLinesByPackage.entries()) {
if (lines.length === 0) { if (lines.length === 0) {
continue; continue;
} }
const logPath = getPackageLogFilePath(packageId); const logPath = getPackageLogFilePathFromNormalized(packageId);
if (!logPath) { if (!logPath) {
continue; continue;
} }
@ -139,8 +153,8 @@ export function initPackageLogs(baseDir: string): void {
} }
export function ensurePackageLog(meta: PackageLogMeta): string | null { export function ensurePackageLog(meta: PackageLogMeta): string | null {
const packageId = normalizePackageId(meta.packageId); const normalizedPackageId = normalizePackageId(meta.packageId);
const logPath = getPackageLogFilePath(packageId); const logPath = getPackageLogFilePath(meta.packageId);
if (!logPath) { if (!logPath) {
return null; return null;
} }
@ -149,12 +163,12 @@ export function ensurePackageLog(meta: PackageLogMeta): string | null {
if (!fs.existsSync(logPath)) { if (!fs.existsSync(logPath)) {
fs.writeFileSync(logPath, "", "utf8"); fs.writeFileSync(logPath, "", "utf8");
} }
if (!initializedThisProcess.has(packageId)) { if (!initializedThisProcess.has(normalizedPackageId)) {
initializedThisProcess.add(packageId); initializedThisProcess.add(normalizedPackageId);
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
fs.appendFileSync( fs.appendFileSync(
logPath, 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" "utf8"
); );
fs.appendFileSync( fs.appendFileSync(
@ -202,7 +216,7 @@ export function shutdownPackageLogs(): void {
} }
flushPending(); flushPending();
for (const packageId of knownLogPaths.keys()) { for (const packageId of knownLogPaths.keys()) {
const logPath = getPackageLogFilePath(packageId); const logPath = getPackageLogFilePathFromNormalized(packageId);
if (!logPath) { if (!logPath) {
continue; continue;
} }

View File

@ -23,11 +23,43 @@ const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([
]); ]);
const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]); const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]);
const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]); 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 { function asText(value: unknown): string {
return String(value ?? "").trim(); 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 { function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
const num = Number(value); const num = Number(value);
if (!Number.isFinite(num)) { if (!Number.isFinite(num)) {
@ -544,8 +576,8 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
if (!item) { if (!item) {
continue; continue;
} }
const id = asText(item.id) || entryId; const id = normalizeSessionId(item.id) || normalizeSessionId(entryId);
const packageId = asText(item.packageId); const packageId = normalizeSessionId(item.packageId);
const url = asText(item.url); const url = asText(item.url);
if (!id || !packageId || !url) { if (!id || !packageId || !url) {
continue; continue;
@ -590,7 +622,7 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
if (!pkg) { if (!pkg) {
continue; continue;
} }
const id = asText(pkg.id) || entryId; const id = normalizeSessionId(pkg.id) || normalizeSessionId(entryId);
if (!id) { if (!id) {
continue; continue;
} }
@ -604,7 +636,7 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
extractDir: asText(pkg.extractDir), extractDir: asText(pkg.extractDir),
status, status,
itemIds: rawItemIds itemIds: rawItemIds
.map((value) => asText(value)) .map((value) => normalizeSessionId(value))
.filter((value) => value.length > 0), .filter((value) => value.length > 0),
cancelled: Boolean(pkg.cancelled), cancelled: Boolean(pkg.cancelled),
enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled), enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled),
@ -627,6 +659,22 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
logger.warn(`normalizeLoadedSession: ${orphanedItemCount} verwaiste Items entfernt (fehlende Pakete)`); 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)) { for (const pkg of Object.values(packagesById)) {
pkg.itemIds = pkg.itemIds.filter((itemId) => { pkg.itemIds = pkg.itemIds.filter((itemId) => {
const item = itemsById[itemId]; const item = itemsById[itemId];
@ -637,7 +685,7 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : []; const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : [];
const seenOrder = new Set<string>(); const seenOrder = new Set<string>();
const packageOrder = rawOrder const packageOrder = rawOrder
.map((entry) => asText(entry)) .map((entry) => normalizeSessionId(entry))
.filter((id) => { .filter((id) => {
if (!(id in packagesById) || seenOrder.has(id)) { if (!(id in packagesById) || seenOrder.has(id)) {
return false; return false;

View File

@ -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, 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_host.txt"), "runtime/debug_host.txt");
addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.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, getTraceConfigPath(), "runtime/trace_config.json");
addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log"); addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log");

View File

@ -380,8 +380,11 @@ async function verifyDownloadedInstaller(filePath: string, digestRaw: string): P
const expected = parseExpectedDigest(digestRaw); const expected = parseExpectedDigest(digestRaw);
if (!expected) { if (!expected) {
logger.warn("Update-Asset ohne gültigen SHA-Digest; nur EXE-Basisprüfung durchgeführt"); if (String(process.env.RD_ALLOW_UNSIGNED_UPDATE || "").trim() === "1") {
return; 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); const actualRaw = await hashFile(filePath, expected.algorithm, expected.encoding);

View File

@ -4235,7 +4235,8 @@ describe("download manager", () => {
for (const [index, archiveName] of archiveNames.entries()) { for (const [index, archiveName] of archiveNames.entries()) {
const targetPath = path.join(outputDir, archiveName); 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]!] = { session.items[itemIds[index]!] = {
id: itemIds[index]!, id: itemIds[index]!,
packageId, packageId,
@ -4244,8 +4245,8 @@ describe("download manager", () => {
status: "completed", status: "completed",
retries: 0, retries: 0,
speedBps: 0, speedBps: 0,
downloadedBytes: 4096, downloadedBytes: partBytes.length,
totalBytes: 4096, totalBytes: partBytes.length,
progressPercent: 100, progressPercent: 100,
fileName: archiveName, fileName: archiveName,
targetPath, targetPath,
@ -5315,9 +5316,9 @@ describe("download manager", () => {
const part2 = path.join(packageDir, "legacy.old.part02.rar"); const part2 = path.join(packageDir, "legacy.old.part02.rar");
const part3 = path.join(packageDir, "legacy.old.part03.rar"); const part3 = path.join(packageDir, "legacy.old.part03.rar");
const keep = path.join(packageDir, "keep.nfo"); const keep = path.join(packageDir, "keep.nfo");
fs.writeFileSync(part1, "part1", "utf8"); fs.writeFileSync(part1, Buffer.alloc(123, 0x61));
fs.writeFileSync(part2, "part2", "utf8"); fs.writeFileSync(part2, Buffer.alloc(123, 0x62));
fs.writeFileSync(part3, "part3", "utf8"); fs.writeFileSync(part3, Buffer.alloc(123, 0x63));
fs.writeFileSync(keep, "keep", "utf8"); fs.writeFileSync(keep, "keep", "utf8");
fs.writeFileSync(path.join(extractDir, "episode.mkv"), "video", "utf8"); fs.writeFileSync(path.join(extractDir, "episode.mkv"), "video", "utf8");
@ -7846,7 +7847,12 @@ describe("download manager", () => {
createStoragePaths(path.join(root, "state")) 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(); const snapshot = manager.getSnapshot();
expect(snapshot.session.packages[packageId]?.status).toBe("completed"); expect(snapshot.session.packages[packageId]?.status).toBe("completed");
expect(snapshot.session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true); expect(snapshot.session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true);

View File

@ -63,4 +63,23 @@ describe("item-log", () => {
expect(content).toContain("archive=episode.part2.rar"); expect(content).toContain("archive=episode.part2.rar");
expect(content).toContain("code=missing_parts"); 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);
});
}); });

View File

@ -61,4 +61,22 @@ describe("package-log", () => {
expect(content).toContain("archive=episode.part1.rar"); expect(content).toContain("archive=episode.part1.rar");
expect(content).toContain("password=\"secret\""); 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);
});
}); });

View File

@ -612,6 +612,75 @@ describe("settings storage", () => {
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 () => { it("captures async session save payload before later mutations", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir); tempDirs.push(dir);

View File

@ -319,6 +319,35 @@ describe("update", () => {
expect(result.message).toMatch(/integrit|sha256|mismatch/i); 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<Response> => {
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 () => { it("uses latest.yml SHA512 digest when API asset digest is missing", async () => {
const executablePayload = fs.readFileSync(process.execPath); const executablePayload = fs.readFileSync(process.execPath);
const digestSha512Hex = sha512Hex(executablePayload); const digestSha512Hex = sha512Hex(executablePayload);