diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e7679f..43e631f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to Multi-Debrid Downloader are documented in this file. +## [2.0.30] - 2026-08-13 + +### Rotation diagnostics + +- Recorded every real account conversion attempt in the affected file log, including masked TEST, FAILED, timeout, fallback, and successful outcome events. +- Kept timeout-cooldown attempts visible as failures in the live rotation diagnostics without changing their technical event type. +- Removed historical and current account masks, short credentials, URLs, and local paths from generated support bundles. + +### Download recovery + +- Persisted resumed-link recovery state across application restarts so repeated range rejection advances to one clean full download instead of restarting the same retry cycle. +- Retried transient Windows file locks before discarding a partial download and prevented a new request until the old partial file is confirmed removed. +- Reconciled Pause and Resume controls with the authoritative main-process snapshot after success, rejection, or a missing state event. + +### Live interface cadence + +- Limited large running queues and the live bandwidth chart to the same stable 500 ms refresh cadence used by the other active download telemetry. + ## [2.0.29] - 2026-08-13 ### Account and rotation reliability diff --git a/package-lock.json b/package-lock.json index 23d6ce5..586962d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "2.0.29", + "version": "2.0.30", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "2.0.29", + "version": "2.0.30", "license": "MIT", "dependencies": { "adm-zip": "0.6.0", diff --git a/package.json b/package.json index 3074999..6024e04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "2.0.29", + "version": "2.0.30", "description": "Desktop downloader", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/account-rotation-log.ts b/src/main/account-rotation-log.ts index e2c2800..d772ee7 100644 --- a/src/main/account-rotation-log.ts +++ b/src/main/account-rotation-log.ts @@ -28,10 +28,6 @@ export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): Rotati return slice; } -function isUiRelevantRotationEvent(event: string): boolean { - return event !== "TEST"; -} - function pushRotationEvent( level: RotationLevel, provider: string, @@ -62,16 +58,16 @@ function pushRotationEvent( } } - if (!isUiRelevantRotationEvent(event)) { - return; - } - rotationEventRing.push(entry); + const uiEntry = event === "TIMEOUT_COOLDOWN" + ? { ...entry, reason: entry.reason ? `Versuch fehlgeschlagen: ${entry.reason}` : "Versuch fehlgeschlagen" } + : entry; + rotationEventRing.push(uiEntry); if (rotationEventRing.length > ROTATION_EVENT_RING_MAX) { rotationEventRing.splice(0, rotationEventRing.length - ROTATION_EVENT_RING_MAX); } if (rotationEventListener) { try { - rotationEventListener(entry); + rotationEventListener(uiEntry); } catch { } } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index a62626b..ac83ed2 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -1735,6 +1735,33 @@ function retryDelayWithJitter(attempt: number, baseMs: number): number { return Math.floor(jitter); } +function clearResumeRecoveryState(item: DownloadItem): void { + delete item.resumeLinkRenewalFailures; + delete item.resumeHardResetUsed; + delete item.resumeResetPending; +} + +async function removeResumePartialForReset(targetPath: string): Promise { + const retryableCodes = new Set(["EBUSY", "EPERM", "EACCES"]); + for (let attempt = 0; attempt < 4; attempt += 1) { + try { + fs.rmSync(targetPath, { force: true }); + } catch (error) { + const code = String((error as NodeJS.ErrnoException)?.code || ""); + if (!retryableCodes.has(code)) { + return false; + } + } + if (!fs.existsSync(targetPath)) { + return true; + } + if (attempt < 3) { + await sleep((attempt + 1) * 100); + } + } + return !fs.existsSync(targetPath); +} + function isMegaDebridProviderKey(value: string): boolean { return value === "megadebrid" || value === "megadebrid-api" @@ -2083,9 +2110,9 @@ export class DownloadManager extends EventEmitter { }); } - private logItemOnly( - item: DownloadItem, - level: "INFO" | "WARN" | "ERROR", + private logItemOnly( + item: DownloadItem, + level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record ): void { @@ -2098,9 +2125,21 @@ export class DownloadManager extends EventEmitter { fileName: item.fileName, status: item.status, targetPath: item.targetPath, - ...fields - }); - } + ...fields + }); + } + + private logRotationEventForItem(item: DownloadItem, event: RotationEvent): void { + this.logItemOnly(item, event.level, "Account-Rotation", { + provider: event.provider, + account: event.accountLabel, + event: event.event, + reason: event.reason, + category: event.category, + cooldownSec: event.cooldownSec, + next: event.next + }); + } private collectRenameMatchTokensForItem(pkg: PackageEntry, item: DownloadItem): string[] { const tokens = new Set(); @@ -5434,9 +5473,10 @@ export class DownloadManager extends EventEmitter { this.runOutcomes.delete(itemId); this.runItemIds.delete(itemId); this.retryAfterByItem.delete(itemId); - this.retryStateByItem.delete(itemId); - - item.status = "queued"; + this.retryStateByItem.delete(itemId); + clearResumeRecoveryState(item); + + item.status = "queued"; item.downloadedBytes = 0; item.totalBytes = null; item.progressPercent = 0; @@ -5517,9 +5557,10 @@ export class DownloadManager extends EventEmitter { this.dropItemContribution(itemId); this.runOutcomes.delete(itemId); this.retryAfterByItem.delete(itemId); - this.retryStateByItem.delete(itemId); - - item.status = "queued"; + this.retryStateByItem.delete(itemId); + clearResumeRecoveryState(item); + + item.status = "queued"; item.downloadedBytes = 0; item.totalBytes = null; item.progressPercent = 0; @@ -6238,9 +6279,10 @@ export class DownloadManager extends EventEmitter { item.updatedAt = nowMs(); continue; } - if (item.status === "extracting" || item.status === "integrity_check") { - item.status = "completed"; - item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`; + if (item.status === "extracting" || item.status === "integrity_check") { + item.status = "completed"; + clearResumeRecoveryState(item); + item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`; item.speedBps = 0; item.updatedAt = nowMs(); } else if (item.status === "downloading" @@ -6529,11 +6571,11 @@ export class DownloadManager extends EventEmitter { return; } const itemCount = this.itemCount; - const emitDelay = this.session.running - ? itemCount >= 1500 - ? 700 - : itemCount >= 700 - ? 500 + const emitDelay = this.session.running + ? itemCount >= 1500 + ? 500 + : itemCount >= 700 + ? 500 : itemCount >= 250 ? 300 : 150 @@ -6986,8 +7028,9 @@ export class DownloadManager extends EventEmitter { error: errorText || undefined }); - item.status = "completed"; - item.fullStatus = this.settings.autoExtract + item.status = "completed"; + clearResumeRecoveryState(item); + item.fullStatus = this.settings.autoExtract ? "Entpacken - Ausstehend" : `Fertig (${humanSize(diskState.size)})`; item.downloadedBytes = diskState.size; @@ -9010,7 +9053,7 @@ export class DownloadManager extends EventEmitter { return count; } - private queueRetry(item: DownloadItem, active: ActiveTask, delayMs: number, statusText: string): void { + private queueRetry(item: DownloadItem, active: ActiveTask, delayMs: number, statusText: string): void { const waitMs = Math.max(0, Math.floor(delayMs)); item.status = "queued"; item.speedBps = 0; @@ -9019,13 +9062,14 @@ export class DownloadManager extends EventEmitter { item.attempts = 0; active.abortController = new AbortController(); active.abortReason = "none"; - this.retryStateByItem.set(item.id, { + this.retryStateByItem.set(item.id, { freshRetryUsed: Boolean(active.freshRetryUsed), resumeHardResetUsed: Boolean(active.resumeHardResetUsed), stallRetries: Number(active.stallRetries || 0), genericErrorRetries: Number(active.genericErrorRetries || 0), - unrestrictRetries: Number(active.unrestrictRetries || 0) - }); + unrestrictRetries: Number(active.unrestrictRetries || 0) + }); + item.resumeHardResetUsed = Boolean(active.resumeHardResetUsed) || undefined; this.logPackageForItem(item, "WARN", "Retry eingeplant", { delayMs: waitMs, statusText, @@ -9039,6 +9083,25 @@ export class DownloadManager extends EventEmitter { const pkg = this.session.packages[item.packageId]; if (pkg) this.refreshPackageStatus(pkg); } + + private async applyPendingResumeReset(item: DownloadItem, active: ActiveTask, targetPath: string): Promise { + const removed = !targetPath || await removeResumePartialForReset(targetPath); + if (!removed) { + active.resumeHardResetUsed = false; + delete item.resumeHardResetUsed; + return false; + } + active.resumeHardResetUsed = true; + item.resumeHardResetUsed = true; + delete item.resumeResetPending; + this.releaseTargetPath(item.id); + this.dropItemContribution(item.id); + item.downloadedBytes = 0; + item.totalBytes = null; + item.progressPercent = 0; + item.speedBps = 0; + return true; + } private scheduleHttp416Retry( item: DownloadItem, @@ -9199,19 +9262,32 @@ export class DownloadManager extends EventEmitter { return; } - const retryState = this.retryStateByItem.get(item.id) || { - freshRetryUsed: false, - resumeHardResetUsed: false, - stallRetries: 0, - genericErrorRetries: 0, - unrestrictRetries: 0 + const retryState = this.retryStateByItem.get(item.id) || { + freshRetryUsed: false, + resumeHardResetUsed: Boolean(item.resumeHardResetUsed), + stallRetries: 0, + genericErrorRetries: Math.max(0, Number(item.resumeLinkRenewalFailures || 0)), + unrestrictRetries: 0 }; this.retryStateByItem.set(item.id, retryState); active.freshRetryUsed = retryState.freshRetryUsed; active.resumeHardResetUsed = retryState.resumeHardResetUsed; active.stallRetries = retryState.stallRetries; - active.genericErrorRetries = retryState.genericErrorRetries; - active.unrestrictRetries = retryState.unrestrictRetries; + active.genericErrorRetries = retryState.genericErrorRetries; + active.unrestrictRetries = retryState.unrestrictRetries; + if (item.resumeResetPending) { + const resetTargetPath = String(item.targetPath || "").trim(); + const resetApplied = await this.applyPendingResumeReset(item, active, resetTargetPath); + this.queueRetry( + item, + active, + resetApplied ? 300 : 1000, + resetApplied ? "Resume-Fehler erkannt, kompletter Neuversuch" : "Warte auf Teildatei-Freigabe" + ); + this.persistSoon(); + this.emitState(); + return; + } const configuredRetryLimit = normalizeRetryLimit(this.settings.retryLimit); const retryDisplayLimit = retryLimitLabel(configuredRetryLimit); const maxItemRetries = retryLimitToMaxRetries(configuredRetryLimit); @@ -9291,30 +9367,33 @@ export class DownloadManager extends EventEmitter { const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]); let unrestricted; try { - unrestricted = await runWithConversionTrace( - { - itemId: item.id, - itemName: item.fileName || item.id, - link: item.url, - providerOrder: (this.settings.providerOrder || []).join(",") || String(this.getExpectedProviderForItem(item) || "?") - }, - async () => { - traceConversionNote("slots", this.describeSlotOccupancy()); - traceConversionNote("retry", Number(active.unrestrictRetries || 0)); - try { - return await this.debridService.unrestrictLink(item.url, unrestrictedSignal, undefined, preferredLeadProvider); - } catch (innerError) { - if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { - traceConversionPhase({ - phase: "caller-timeout", - outcome: "timeout", - detail: `Caller-Budget ${Math.ceil(unrestrictTimeoutMs / 1000)}s erschoepft (siehe letzte Phase fuer in-flight Provider/Account)` - }); - } - throw innerError; - } - } - ); + unrestricted = await runWithRotationItemSink( + (event) => this.logRotationEventForItem(item, event), + () => runWithConversionTrace( + { + itemId: item.id, + itemName: item.fileName || item.id, + link: item.url, + providerOrder: (this.settings.providerOrder || []).join(",") || String(this.getExpectedProviderForItem(item) || "?") + }, + async () => { + traceConversionNote("slots", this.describeSlotOccupancy()); + traceConversionNote("retry", Number(active.unrestrictRetries || 0)); + try { + return await this.debridService.unrestrictLink(item.url, unrestrictedSignal, undefined, preferredLeadProvider); + } catch (innerError) { + if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { + traceConversionPhase({ + phase: "caller-timeout", + outcome: "timeout", + detail: `Caller-Budget ${Math.ceil(unrestrictTimeoutMs / 1000)}s erschoepft (siehe letzte Phase fuer in-flight Provider/Account)` + }); + } + throw innerError; + } + } + ) + ); } catch (unrestrictError) { if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { this.recordProviderFailure(cooldownProvider); @@ -9518,8 +9597,9 @@ export class DownloadManager extends EventEmitter { } const completedAt = nowMs(); - item.status = "completed"; - item.fullStatus = this.settings.autoExtract + item.status = "completed"; + clearResumeRecoveryState(item); + item.fullStatus = this.settings.autoExtract ? "Entpacken - Ausstehend" : `Fertig (${humanSize(item.downloadedBytes)})`; item.progressPercent = 100; @@ -9779,32 +9859,30 @@ export class DownloadManager extends EventEmitter { return; } if (isResumeHardResetReason(exhaustedReason, active.genericErrorRetries) && !active.resumeHardResetUsed) { - active.resumeHardResetUsed = true; - item.retries += 1; - logger.warn(`Resume-Neustart: item=${item.fileName || item.id}, error=${exhaustedReason}, provider=${item.provider || "?"}`); - const resetTargetPath = claimedTargetPath || String(item.targetPath || "").trim(); - if (resetTargetPath) { - try { - fs.rmSync(resetTargetPath, { force: true }); - } catch { - } - } - this.releaseTargetPath(item.id); - this.dropItemContribution(item.id); - item.lastError = exhaustedReason; - item.downloadedBytes = 0; - item.totalBytes = null; - item.progressPercent = 0; - this.queueRetry(item, active, 300, "Resume-Fehler erkannt, kompletter Neuversuch"); + item.retries += 1; + logger.warn(`Resume-Neustart: item=${item.fileName || item.id}, error=${exhaustedReason}, provider=${item.provider || "?"}`); + const resetTargetPath = claimedTargetPath || String(item.targetPath || "").trim(); + item.resumeResetPending = true; + item.lastError = exhaustedReason; + const resetApplied = await this.applyPendingResumeReset(item, active, resetTargetPath); + this.queueRetry( + item, + active, + resetApplied ? 300 : 1000, + resetApplied ? "Resume-Fehler erkannt, kompletter Neuversuch" : "Warte auf Teildatei-Freigabe" + ); this.persistSoon(); this.emitState(); return; } } - if (directLinkRetryMatch && active.genericErrorRetries < maxGenericErrorRetries) { - active.genericErrorRetries += 1; + if (directLinkRetryMatch && active.genericErrorRetries < maxGenericErrorRetries) { + active.genericErrorRetries += 1; item.retries += 1; - const exhaustedReason = compactErrorText(directLinkRetryMatch[1] || errorText).replace(/^Error:\s*/i, ""); + const exhaustedReason = compactErrorText(directLinkRetryMatch[1] || errorText).replace(/^Error:\s*/i, ""); + if (isResumeHardResetReason(exhaustedReason, 1)) { + item.resumeLinkRenewalFailures = active.genericErrorRetries; + } const refreshDelayMs = retryDelayWithJitter(active.genericErrorRetries, 200); logger.warn( `Direktlink erschöpft: item=${item.fileName || item.id}, ` + @@ -11265,9 +11343,10 @@ export class DownloadManager extends EventEmitter { return recovered + finalized; } - private queueItemForRetry(item: DownloadItem, options: { hardReset: boolean; reason: string }): void { - this.retryStateByItem.delete(item.id); - const targetPath = String(item.targetPath || "").trim(); + private queueItemForRetry(item: DownloadItem, options: { hardReset: boolean; reason: string }): void { + this.retryStateByItem.delete(item.id); + clearResumeRecoveryState(item); + const targetPath = String(item.targetPath || "").trim(); if (options.hardReset && targetPath) { try { fs.rmSync(targetPath, { force: true }); @@ -12213,8 +12292,9 @@ export class DownloadManager extends EventEmitter { continue; } logger.info(`Item-Recovery: ${item.fileName} war "${item.status}" aber Datei existiert (${humanSize(stat.size)}), setze auf completed`); - item.status = "completed"; - item.fullStatus = this.settings.autoExtract ? "Entpacken - Ausstehend" : `Fertig (${humanSize(stat.size)})`; + item.status = "completed"; + clearResumeRecoveryState(item); + item.fullStatus = this.settings.autoExtract ? "Entpacken - Ausstehend" : `Fertig (${humanSize(stat.size)})`; item.downloadedBytes = stat.size; item.progressPercent = 100; item.speedBps = 0; diff --git a/src/main/storage.ts b/src/main/storage.ts index 42bfc23..1a055fc 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -759,11 +759,13 @@ export function normalizeLoadedSession(raw: unknown): SessionState { const statusRaw = asText(item.status) as DownloadStatus; const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued"; - const providerRaw = asText(item.provider) as DebridProvider; - - const onlineStatusRaw = asText(item.onlineStatus); - - itemsById[id] = { + const providerRaw = asText(item.provider) as DebridProvider; + + const onlineStatusRaw = asText(item.onlineStatus); + const lastError = asText(item.lastError); + const legacyResumeFailureCount = /^(?:range_ignored_on_resume:|range_mismatch_on_resume:|resume_download_underflow:)/i.test(lastError) ? 1 : 0; + + itemsById[id] = { id, packageId, url, @@ -781,9 +783,12 @@ export function normalizeLoadedSession(raw: unknown): SessionState { targetPath: asText(item.targetPath), resumable: item.resumable === undefined ? true : Boolean(item.resumable), attempts: clampNumber(item.attempts, 0, 0, 10_000), - lastError: asText(item.lastError), - fullStatus: asText(item.fullStatus), - onlineStatus: VALID_ONLINE_STATUSES.has(onlineStatusRaw) ? onlineStatusRaw as "online" | "offline" | "checking" : undefined, + lastError, + fullStatus: asText(item.fullStatus), + resumeLinkRenewalFailures: clampNumber(item.resumeLinkRenewalFailures, legacyResumeFailureCount, 0, 1_000_000) || undefined, + resumeHardResetUsed: Boolean(item.resumeHardResetUsed) || undefined, + resumeResetPending: Boolean(item.resumeResetPending) || undefined, + onlineStatus: VALID_ONLINE_STATUSES.has(onlineStatusRaw) ? onlineStatusRaw as "online" | "offline" | "checking" : undefined, createdAt: clampNumber(item.createdAt, now, 0, Number.MAX_SAFE_INTEGER), updatedAt: clampNumber(item.updatedAt, now, 0, Number.MAX_SAFE_INTEGER) }; diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index b43adf2..a1e0a89 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -14,8 +14,10 @@ import { getDesktopRenameLogPath } from "./desktop-rename-log"; import { getSessionLogPath } from "./session-log"; import { createStoragePaths, loadSettings } from "./storage"; import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload } from "./support-data"; -import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log"; -import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics"; +import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log"; +import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics"; +import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import { maskMegaDebridLogin } from "../shared/mega-debrid-accounts"; import type { DownloadManager } from "./download-manager"; import type { DownloadItem, HistoryEntry, PackageEntry, SessionState } from "../shared/types"; @@ -41,27 +43,37 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +function addSensitiveValue(output: Set, value: string): void { + const trimmed = value.trim(); + if (trimmed) { + output.add(trimmed); + } +} + function collectSensitiveValues(value: unknown, key = "", output = new Set()): Set { if (typeof value === "string") { if (/token|api.?key|password|passwd|secret|cookie|authorization|credential|login|username/i.test(key)) { for (const candidate of [value, ...value.split(/\r?\n/)]) { const trimmed = candidate.trim(); - if (trimmed.length >= 4) { - output.add(trimmed); - } + addSensitiveValue(output, trimmed); if (/credential/i.test(key)) { const separator = trimmed.indexOf(":"); if (separator > 0) { const login = trimmed.slice(0, separator).trim(); const password = trimmed.slice(separator + 1).trim(); - if (login.length >= 4) { - output.add(login); - } - if (password.length >= 4) { - output.add(password); + addSensitiveValue(output, login); + addSensitiveValue(output, password); + if (/mega.?debrid/i.test(key)) { + addSensitiveValue(output, maskMegaDebridLogin(login)); } } } + if (/debrid.?link.*api.?keys?/i.test(key)) { + for (const entry of parseDebridLinkApiKeys(trimmed)) { + addSensitiveValue(output, entry.token); + addSensitiveValue(output, entry.masked); + } + } } } return output; @@ -80,33 +92,46 @@ function collectSensitiveValues(value: unknown, key = "", output = new Set`; - } catch { - return ""; - } -} - function redactSupportText(value: string, sensitiveValues: ReadonlySet): string { - let output = String(value || "").replace(/\0/g, ""); - const secrets = [...sensitiveValues].sort((a, b) => b.length - a.length); - for (const secret of secrets) { - output = output.replace(new RegExp(escapeRegExp(secret), "g"), ""); - } - output = output.replace(/https?:\/\/[^\s"'<>]+/gi, (url) => redactUrl(url)); - output = output.replace(/(["']?(?:authorization|proxy-authorization|set-cookie|cookie|password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|api[_ -]?key|secret|client[_-]?secret|auth|login|username|user)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*')/gi, "$1\"\""); + const raw = String(value || "").replace(/\0/g, ""); + const findMarker = (source: string, offset: number): string => { + for (let index = offset; index < 0x1900; index += 1) { + const marker = String.fromCharCode(0xe000 + index); + if (!source.includes(marker) && [...sensitiveValues].every((secret) => !secret.includes(marker))) { + return marker; + } + } + return String.fromCharCode(0xf8ff - offset); + }; + const urlMarker = findMarker(raw, 0); + let output = raw.replace(/\b(?:https?|file):\/\/[^\s"'<>]+/gi, urlMarker); + const pathMarker = findMarker(output, 1); + output = output.replace(/\b[A-Z]:[\\/][^\r\n|"<>]+/gi, pathMarker); + output = output.replace(/\\\\[^\r\n|"<>]+/g, pathMarker); + output = output.replace(/\/(?:home|Users|var|tmp)\/[^\r\n|"<>]+/g, pathMarker); output = output.replace(/\b(?:authorization|proxy-authorization)\s*[:=]\s*[^\r\n]+/gi, "Authorization: "); output = output.replace(/\b(?:set-cookie|cookie)\s*[:=]\s*[^\r\n]+/gi, "Cookie: "); - output = output.replace(/\b(password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|api[_ -]?key|secret|client[_-]?secret|auth|login|username|user)\b(\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;|]+)/gi, "$1$2"); + output = output.replace(/\b((?:Account|Key)\s+\d+(?:\/\d+)?)\s*\([^)\r\n]*\)/gi, "$1 ()"); + output = output.replace(/(["']?(?:authorization|proxy-authorization|set-cookie|cookie|password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|api[_ -]?key|secret|client[_-]?secret|auth|login|username|user)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/gi, "$1\"\""); + output = output.replace(/\b(password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|api[_ -]?key|secret|client[_-]?secret|auth|login|username|user)\b(\s*[:=]\s*)[^\s,;|]+/gi, "$1$2"); + const secretVariants = new Set(); + for (const secret of sensitiveValues) { + secretVariants.add(secret); + const jsonEscaped = JSON.stringify(secret).slice(1, -1); + if (jsonEscaped) { + secretVariants.add(jsonEscaped); + } + } + for (const secret of [...secretVariants].sort((a, b) => b.length - a.length)) { + const escaped = escapeRegExp(secret); + output = secret.length >= 4 + ? output.replace(new RegExp(escaped, "g"), "") + : output.replace(new RegExp(`(^|[^A-Za-z0-9])${escaped}(?=$|[^A-Za-z0-9])`, "g"), "$1"); + } output = output.replace(/\b[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\b/g, ""); output = output.replace(/\b(?=[A-Za-z0-9+/_=-]{24,}\b)(?=[A-Za-z0-9+/_=-]*[A-Za-z])(?=[A-Za-z0-9+/_=-]*\d)[A-Za-z0-9+/_=-]+\b/g, ""); - output = output.replace(/\b[A-Z]:\\[^\r\n|"<>]+/gi, ""); - output = output.replace(/\\\\[^\r\n|"<>]+/g, ""); - output = output.replace(/\/(?:home|Users|var|tmp)\/[^\r\n|"<>]+/g, ""); output = output.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, ""); - return output; + return output.replaceAll(urlMarker, "").replaceAll(pathMarker, ""); } function redactSupportValue(value: unknown, sensitiveValues: ReadonlySet, key = ""): unknown { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index fa554fb..56b0c20 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -872,7 +872,7 @@ const AUTO_RENDER_PACKAGE_LIMIT = 260; export function getSnapshotRenderDelay(itemCount: number, running: boolean, activeTab: MainView): number { let delay = running ? 500 : itemCount >= 700 ? 100 : itemCount >= 250 ? 150 : 200; if (!running) delay = Math.min(delay, 200); - if (activeTab !== "downloads") delay = Math.max(delay, 800); + if (!running && activeTab !== "downloads") delay = Math.max(delay, 800); return delay; } @@ -1292,10 +1292,9 @@ const BandwidthChart = memo(function BandwidthChart({ running, paused, speedHist if (!running || paused) { return; } - const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const interval = setInterval(() => { drawChart(); - }, reducedMotion ? 1000 : 500); + }, 500); return () => clearInterval(interval); }, [drawChart, running, paused]); @@ -4726,8 +4725,21 @@ export function App(): ReactElement { }, onStartDownloads: () => { if (snapshot.session.paused) { - setSnapshot((current) => ({ ...current, session: { ...current.session, paused: false } })); - void window.rd.togglePause().catch(() => {}); + void (async () => { + try { + await window.rd.togglePause(); + } catch (error) { + showToast(`Fortsetzen fehlgeschlagen: ${String(error)}`, 3200); + } finally { + try { + const fresh = await window.rd.getSnapshot(); + masterSnapshotRef.current = fresh; + latestStateRef.current = null; + setSnapshot(fresh); + } catch { + } + } + })(); } else { void onStartDownloads(); } diff --git a/src/shared/types.ts b/src/shared/types.ts index 6da3f02..086de9d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -360,10 +360,13 @@ export interface DownloadItem { fileName: string; targetPath: string; resumable: boolean; - attempts: number; - lastError: string; - fullStatus: string; - createdAt: number; + attempts: number; + lastError: string; + fullStatus: string; + resumeLinkRenewalFailures?: number; + resumeHardResetUsed?: boolean; + resumeResetPending?: boolean; + createdAt: number; updatedAt: number; onlineStatus?: "online" | "offline" | "checking"; } diff --git a/tests/account-rotation-log.test.ts b/tests/account-rotation-log.test.ts index 1f8d80a..30953c1 100644 --- a/tests/account-rotation-log.test.ts +++ b/tests/account-rotation-log.test.ts @@ -47,11 +47,26 @@ describe("rotation item-sink (AsyncLocalStorage)", () => { expect(b.map((e) => e.event)).toEqual(["TEST", "FAILED"]); }); - it("still feeds the global UI ring (outcomes only, TEST filtered)", () => { + it("feeds the global UI ring with TEST and outcome events", () => { logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "TEST"); logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" }); const ring = getRecentRotationEvents(10); expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9 (zz)")).toBe(true); - expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9 (zz)")).toBe(false); + expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9 (zz)")).toBe(true); + }); + + it("marks TIMEOUT_COOLDOWN as a failed attempt in the global UI ring without changing its event type", () => { + logAccountRotation("WARN", "Mega-Debrid Web", "Account 10 (xy)", "TIMEOUT_COOLDOWN", { + reason: "aborted:debrid", + cooldownSec: 30, + next: "Account 11 (yz)" + }); + + const event = getRecentRotationEvents(10).find((entry) => entry.accountLabel === "Account 10 (xy)"); + + expect(event).toMatchObject({ + event: "TIMEOUT_COOLDOWN", + reason: "Versuch fehlgeschlagen: aborted:debrid" + }); }); }); diff --git a/tests/app-shell.test.tsx b/tests/app-shell.test.tsx index 23c5bf0..a2d2c66 100644 --- a/tests/app-shell.test.tsx +++ b/tests/app-shell.test.tsx @@ -37,7 +37,7 @@ describe("desktop shell", () => { it("renders active download telemetry at a stable half-second cadence", () => { expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(500); - expect(getSnapshotRenderDelay(2_470, true, "statistics")).toBe(800); + expect(getSnapshotRenderDelay(2_470, true, "statistics")).toBe(500); }); it("keeps support bundle progress tied to the unresolved export", async () => { diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 560b435..4169e57 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -18,6 +18,7 @@ import { createStoragePaths, emptySession, loadSession, saveSession } from "../s import { getMegaDebridAccountCooldownState, primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log"; +import { logAccountRotation } from "../src/main/account-rotation-log"; import { UnrestrictedLink } from "../src/main/realdebrid"; import { resetVideoToolingCache } from "../src/main/video-processor"; import type { AppSettings, DebridProvider, HistoryEntry, PackageEntry } from "../src/shared/types"; @@ -813,6 +814,61 @@ afterEach(async () => { }); describe("download manager", () => { + it("writes the real unrestrict account trail into the active item log", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rotation-item-log-")); + tempDirs.push(root); + const stateDir = path.join(root, "state"); + initItemLogs(stateDir); + const session = emptySession(); + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + retryLimit: 0 + }, + session, + createStoragePaths(stateDir) + ); + manager.addPackages([{ name: "rotation-proof", links: ["https://rapidgator.net/file/rotation-proof"] }]); + const packageId = session.packageOrder[0]; + const itemId = session.packages[packageId].itemIds[0]; + const active = { + itemId, + packageId, + abortController: new AbortController(), + abortReason: "none", + resumable: true, + nonResumableCounted: false, + blockedOnDiskWrite: false, + blockedOnDiskSince: 0 + }; + (manager as any).activeTasks.set(itemId, active); + (manager as any).debridService.unrestrictLink = async () => { + logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/3 (al***ha)", "TEST"); + logAccountRotation("WARN", "Mega-Debrid Web", "Account 1/3 (al***ha)", "FAILED", { reason: "aborted" }); + logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (be***ta)", "TEST"); + logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (be***ta)", "OK"); + throw new Error("rotation-proof-stop"); + }; + + await (manager as any).processItem(active); + const itemLogPath = getItemLogPath(itemId); + expect(itemLogPath).not.toBeNull(); + shutdownItemLogs(); + const content = fs.readFileSync(itemLogPath!, "utf8"); + const firstTest = content.indexOf("Account 1/3 (al***ha)"); + const firstFailure = content.indexOf("event=FAILED"); + const secondTest = content.indexOf("Account 2/3 (be***ta)"); + const secondSuccess = content.indexOf("event=OK"); + expect(firstTest).toBeGreaterThanOrEqual(0); + expect(firstFailure).toBeGreaterThan(firstTest); + expect(secondTest).toBeGreaterThan(firstFailure); + expect(secondSuccess).toBeGreaterThan(secondTest); + }); + it("stores RapidGator metadata before a download starts", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); @@ -2772,7 +2828,7 @@ describe("download manager", () => { } }); - it("restores a persisted Mega-Web partial and hard-resets after two HTTP 200 range rejections", async () => { + it("restores a persisted Mega-Web resume failure and retries transient cleanup before the fresh restart", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-resume-restore-")); tempDirs.push(root); const binary = Buffer.alloc(192 * 1024, 37); @@ -2784,6 +2840,17 @@ describe("download manager", () => { fs.mkdirSync(outputDir, { recursive: true }); fs.writeFileSync(targetPath, binary.subarray(0, partialSize)); const rangeHeaders: string[] = []; + const originalRmSync = fs.rmSync; + let targetRemovalAttempts = 0; + const rmSpy = vi.spyOn(fs, "rmSync").mockImplementation(((candidate, options) => { + if (path.resolve(String(candidate)) === path.resolve(targetPath)) { + targetRemovalAttempts += 1; + if (targetRemovalAttempts === 1) { + throw Object.assign(new Error("target busy"), { code: "EBUSY" }); + } + } + return originalRmSync(candidate, options); + }) as typeof fs.rmSync); const server = http.createServer((req, res) => { if (req.method === "GET") { @@ -2887,14 +2954,15 @@ describe("download manager", () => { downloadedBytes: binary.length, onlineStatus: "online" })); - expect(megaWebUnrestrict).toHaveBeenCalledTimes(3); + expect(megaWebUnrestrict).toHaveBeenCalledTimes(2); expect(rangeHeaders).toEqual([ - `bytes=${partialSize}-`, `bytes=${partialSize}-`, "" ]); + expect(targetRemovalAttempts).toBeGreaterThanOrEqual(2); expect(fs.readFileSync(targetPath)).toEqual(binary); } finally { + rmSpy.mockRestore(); server.close(); await once(server, "close"); } @@ -13639,7 +13707,7 @@ describe("download manager", () => { } }, 30000); - it("bulk-adds large DLC containers without initializing per-item logs (avoids 1-2 min sync-FS freeze)", () => { + it("bulk-adds large DLC containers without initializing per-item logs (avoids 1-2 min sync-FS freeze)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bulk-add-")); tempDirs.push(root); const stateDir = path.join(root, "state"); @@ -13681,10 +13749,42 @@ describe("download manager", () => { const packageLogsDir = path.join(stateDir, "package-logs"); const pkgLogFiles = fs.readdirSync(packageLogsDir).filter((f) => f.startsWith("package_") && f.endsWith(".txt")); - expect(pkgLogFiles.length).toBe(60); - }); - - it("serializes parallel auto-rename invocations for the same package (no Ziel existiert / ENOENT race)", async () => { + expect(pkgLogFiles.length).toBe(60); + }); + + it("emits running large-queue state updates within 500 ms", async () => { + vi.useFakeTimers(); + try { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-state-cadence-")); + tempDirs.push(root); + const manager = new DownloadManager( + defaultSettings(), + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + const internal = manager as unknown as { + session: { running: boolean }; + itemCount: number; + emitState: () => void; + }; + internal.session.running = true; + internal.itemCount = 1_500; + let emitted = 0; + manager.on("state", () => { + emitted += 1; + }); + + internal.emitState(); + await vi.advanceTimersByTimeAsync(499); + expect(emitted).toBe(0); + await vi.advanceTimersByTimeAsync(1); + expect(emitted).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + + it("serializes parallel auto-rename invocations for the same package (no Ziel existiert / ENOENT race)", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rename-race-")); tempDirs.push(root); const stateDir = path.join(root, "state"); diff --git a/tests/download-resume-state.test.tsx b/tests/download-resume-state.test.tsx new file mode 100644 index 0000000..d8bd31f --- /dev/null +++ b/tests/download-resume-state.test.tsx @@ -0,0 +1,178 @@ +import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { defaultSettings } from "../src/main/constants"; +import { createRendererState } from "../src/main/renderer-state"; +import { emptySession } from "../src/main/storage"; +import type { UiSnapshot } from "../src/shared/types"; + +const hookState = vi.hoisted(() => ({ + capturedSnapshot: false, + currentSnapshot: null as unknown, + initialSnapshot: null as unknown +})); + +vi.mock("react", async () => { + const actual = await vi.importActual("react"); + return { + ...actual, + memo: (component: T): T => component, + useCallback: (callback: T): T => callback, + useDeferredValue: (value: T): T => value, + useEffect: (): void => {}, + useLayoutEffect: (): void => {}, + useMemo: (factory: () => T): T => factory(), + useRef: (initial: T): { current: T } => ({ current: initial }), + useState: (initial: T | (() => T)): [T, (next: T | ((current: T) => T)) => void] => { + const initialValue = typeof initial === "function" ? (initial as () => T)() : initial; + const candidate = initialValue as Record | null; + if (!hookState.capturedSnapshot && candidate && "session" in candidate && "canStart" in candidate) { + hookState.capturedSnapshot = true; + const setSnapshot = (next: T | ((current: T) => T)): void => { + const current = hookState.currentSnapshot as T; + hookState.currentSnapshot = typeof next === "function" + ? (next as (value: T) => T)(current) + : next; + }; + return [hookState.initialSnapshot as T, setSnapshot]; + } + let current = initialValue; + return [current, (next): void => { + current = typeof next === "function" ? (next as (value: T) => T)(current) : next; + }]; + } + }; +}); + +import { App } from "../src/renderer/App"; + +function createSnapshot(running: boolean, paused: boolean): UiSnapshot { + const now = Date.now(); + return { + ...createRendererState(defaultSettings()), + session: { + ...emptySession(), + running, + paused, + updatedAt: now + }, + summary: null, + stats: { + totalDownloaded: 0, + totalDownloadedAllTime: 0, + totalFilesSession: 0, + totalFilesAllTime: 0, + totalPackages: 0, + sessionStartedAt: now, + appSessionStartedAt: now, + sessionRuntimeMs: 0, + totalRuntimeMs: 0, + runtimeMeasuredAt: now + }, + speedText: "Geschwindigkeit: 0 B/s", + etaText: "ETA: --", + canStart: !running || paused, + canStop: running, + canPause: running, + clipboardActive: false, + reconnectSeconds: 0, + packageSpeedBps: {} + }; +} + +function findStartAction(node: ReactNode): (() => void) | null { + if (Array.isArray(node)) { + for (const child of node) { + const action = findStartAction(child); + if (action) return action; + } + return null; + } + if (!isValidElement(node)) return null; + const props = node.props as { + actions?: { onStartDownloads?: () => void }; + children?: ReactNode; + toolbar?: ReactNode; + }; + if (typeof props.actions?.onStartDownloads === "function") { + return props.actions.onStartDownloads; + } + return findStartAction(props.toolbar) ?? findStartAction(props.children); +} + +async function flushAsyncAction(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +function renderPausedStartAction( + initialSnapshot: UiSnapshot, + togglePause: () => Promise, + getSnapshot: () => Promise +): () => void { + hookState.capturedSnapshot = false; + hookState.initialSnapshot = initialSnapshot; + hookState.currentSnapshot = initialSnapshot; + vi.stubGlobal("HTMLElement", class {}); + vi.stubGlobal("document", { + activeElement: null, + body: {}, + documentElement: {}, + querySelector: () => null + }); + vi.stubGlobal("window", { + addEventListener: () => {}, + clearInterval, + clearTimeout, + devicePixelRatio: 1, + matchMedia: () => ({ matches: false }), + prompt: () => null, + rd: { getSnapshot, togglePause }, + removeEventListener: () => {}, + setInterval, + setTimeout + }); + const action = findStartAction(App() as ReactElement); + if (!action) throw new Error("Download-Startaktion nicht gefunden"); + return action; +} + +describe("paused download resume reconciliation", () => { + beforeEach(() => { + hookState.capturedSnapshot = false; + hookState.currentSnapshot = null; + hookState.initialSnapshot = null; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("restores the authoritative paused state when togglePause rejects without a state event", async () => { + const initial = createSnapshot(true, true); + const authoritative = createSnapshot(true, true); + const action = renderPausedStartAction( + initial, + async () => { throw new Error("Kein aktiver Download-Account verfügbar"); }, + async () => authoritative + ); + + action(); + await flushAsyncAction(); + + expect(hookState.currentSnapshot).toEqual(authoritative); + }); + + it("replaces stale running state when togglePause returns false without a state event", async () => { + const initial = createSnapshot(true, true); + const authoritative = createSnapshot(false, false); + const action = renderPausedStartAction( + initial, + async () => false, + async () => authoritative + ); + + action(); + await flushAsyncAction(); + + expect(hookState.currentSnapshot).toEqual(authoritative); + }); +}); diff --git a/tests/statistics-view.test.tsx b/tests/statistics-view.test.tsx index 95e16a3..56a1587 100644 --- a/tests/statistics-view.test.tsx +++ b/tests/statistics-view.test.tsx @@ -475,14 +475,14 @@ describe("bandwidth chart palette", () => { expect(palette).toEqual({ accent: "rgb(74, 222, 128)" }); }); - it("labels the live chart and slows redraws when reduced motion is requested", () => { + it("labels the live chart and keeps live redraws at 500 ms", () => { const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8"); const chartBlock = source.slice(source.indexOf("const BandwidthChart"), source.indexOf("interface DownloadSpeedSparklineProps")); expect(chartBlock).toContain('role="img"'); expect(chartBlock).toContain('aria-label="Bandbreitenverlauf der letzten 60 Sekunden"'); - expect(chartBlock).toContain('window.matchMedia("(prefers-reduced-motion: reduce)")'); - expect(chartBlock).toContain("reducedMotion ? 1000 : 500"); + expect(chartBlock).toContain("}, 500);"); + expect(chartBlock).not.toContain("reducedMotion ? 1000 : 500"); }); it("asks for confirmation before deleting all saved download statistics", () => { diff --git a/tests/support-bundle.test.ts b/tests/support-bundle.test.ts index 49e98c5..768fa9a 100644 --- a/tests/support-bundle.test.ts +++ b/tests/support-bundle.test.ts @@ -10,12 +10,14 @@ import { } from "../src/main/support-bundle"; import type { DownloadManager } from "../src/main/download-manager"; import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log"; +import { initAccountRotationLog, logAccountRotation, shutdownAccountRotationLog } from "../src/main/account-rotation-log"; const tempDirs: string[] = []; const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join(""); afterEach(() => { shutdownSessionLog(); + shutdownAccountRotationLog(); for (const dir of tempDirs.splice(0)) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { } } @@ -241,10 +243,17 @@ describe("buildSupportBundle (async, non-blocking)", () => { it("redacts active DTOs, runtime text and logs at the ZIP boundary", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-sensitive-")); tempDirs.push(root); + const escapedSecret = "prefix\"suffix\\trail\tend"; fs.writeFileSync(path.join(root, "rd_downloader_config.json"), JSON.stringify({ - megaDebridWebCredentials: "primary-user:primary-password-secret\nsecondary-user:secondary-password-secret", + megaDebridWebCredentials: `primary-user:primary-password-secret\nsecondary-user:secondary-password-secret\nZ9:Q7!\nAlice:${escapedSecret}`, + debridLinkApiKeys: "abc123456789xyz,def987654321uvw", megaDebridWebEnabled: true }), "utf8"); + initAccountRotationLog(root); + logAccountRotation("INFO", "Debrid-Link", "Key 1 (abc*********xyz)", "TEST"); + logAccountRotation("WARN", "Debrid-Link", "Key 2 (def*********uvw)", "FAILED", { reason: "fixture" }); + logAccountRotation("WARN", "Mega-Debrid", "Account 9/9 (Re*******er)", "FAILED", { next: "Account 8/9 (Al*******ce)" }); + logAccountRotation("WARN", "Debrid-Link", "Key 9/9 (old***********ken)", "FAILED", { next: "Key 8/9 (ret***********key)" }); const itemLogs = path.join(root, "item-logs"); fs.mkdirSync(itemLogs, { recursive: true }); fs.writeFileSync(path.join(itemLogs, "token=filename-secret.log"), [ @@ -254,8 +263,16 @@ describe("buildSupportBundle (async, non-blocking)", () => { "password=log-password-secret", "api_key=log-api-key-secret", "provider rejected secondary-password-secret during rotation", + "rotation rejected account Z9 before fallback Q7!", + "rotation tried Mega account Z*", "https://log-user:log-pass@files.example.test/archive?token=log-query-secret#log-fragment-secret", - "C:\\Users\\Alice\\Downloads\\Private Collection\\private.bin" + "https://private-support-host.invalid/archive?id=private-resource", + "https://private.example/Alice/after-alice-url-segment?token=x", + "C:\\Users\\Alice\\Downloads\\Private Collection\\private.bin", + "C:/Users/Alice/Downloads/Private Collection/forward-private.bin", + "C:/Users/Alice/after-alice-path-segment/private.bin", + "file:///C:/Users/Alice/Downloads/Private%20Collection/file-private.bin", + JSON.stringify({ password: escapedSecret }) ].join("\n"), "utf8"); fs.writeFileSync(path.join(root, "debug_support_manifest.json"), JSON.stringify({ authorization: "Bearer manifest-bearer-secret", @@ -292,8 +309,24 @@ describe("buildSupportBundle (async, non-blocking)", () => { "log-api-key-secret", "primary-password-secret", "secondary-password-secret", + "Z9", + "Q7!", + "Z*", + "abc*********xyz", + "def*********uvw", + "Re*******er", + "Al*******ce", + "old***********ken", + "ret***********key", + escapedSecret, + JSON.stringify(escapedSecret).slice(1, -1), "log-query-secret", "log-fragment-secret", + "private-support-host.invalid", + "after-alice-url-segment", + "after-alice-path-segment", + "forward-private.bin", + "file-private.bin", "manifest-bearer-secret", "manifest-query-secret", "manifest-fragment"