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: {
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
};
}

View File

@ -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;
}

View File

@ -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=<redacted>`);
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=<redacted>)`);
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=<redacted>`);
logger.info(`Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=<redacted>)`);
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=<redacted>`);
onLog?.("WARN", `Entpack-Pfadfehler gemerkt: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, extractor=${extractorName}, password=<redacted>`);
}
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=<redacted>)`);
onLog?.("INFO", `Flach-Extraktion Versuch ${passwordAttempt}/${flatPasswords.length}: archive=${path.basename(archivePath)}, password=<redacted>`);
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=<redacted>`);
}
: undefined;
try {

View File

@ -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<string>();
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, unknown>): 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;
}

View File

@ -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<string>();
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, unknown>): 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;
}

View File

@ -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<HistoryRetentionMode>(["never", "session", "permanent"]);
const VALID_PACKAGE_PRIORITIES = new Set<string>(["high", "normal", "low"]);
const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([
"queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled"
]);
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_PACKAGE_PRIORITIES = new Set<string>(["high", "normal", "low"]);
const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([
"queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled"
]);
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 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<string, DownloadItem> = {};
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<string, PackageEntry> = {};
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<string>();
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<string>();
const packageOrder = rawOrder
.map((entry) => normalizeSessionId(entry))
.filter((id) => {
if (!(id in packagesById) || seenOrder.has(id)) {
return false;
}
seenOrder.add(id);
return true;

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, "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");

View File

@ -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);

View File

@ -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);

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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-"));

View File

@ -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<Response> => {
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<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 () => {
const executablePayload = fs.readFileSync(process.execPath);
const digestSha512Hex = sha512Hex(executablePayload);
const digestSha512Base64 = Buffer.from(digestSha512Hex, "hex").toString("base64");