Harden download integrity, extraction safety, and update security
This commit is contained in:
parent
792a4249d0
commit
653e756010
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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-"));
|
||||
|
||||
@ -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");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user