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