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
+180 -91
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;
}