fix(torrent): harden allocation and credential isolation

- Extend allocation-phase eligibility to preallocated Torrent admission while excluding none, verify-only, and media work.

- Strip Torrent metadata credentials at intake, persistence, renderer, native, and Aria2 header boundaries.

- Add restart, batch-admission, persistence, and native regression coverage.
This commit is contained in:
NimBold
2026-08-19 08:49:02 +03:30
parent 2bce25868c
commit 566632b7ad
7 changed files with 378 additions and 90 deletions
+70 -20
View File
@@ -10,7 +10,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore';
import { useDownloadProgressStore } from './downloadProgressStore';
import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, isActiveDownloadStatus, isAllocationPhaseEligible, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import {
resolveCategoryDestination
} from '../utils/downloadLocations';
@@ -339,7 +339,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
await resolveCategoryDestination(settings, item.category);
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
const login = getSiteLogin(item.url, settings);
const login = item.isTorrent === true ? null : getSiteLogin(item.url, settings);
if (login && !item.password && !settings.keychainAccessReady && !settings.keychainPromptDismissed) {
settings.setShowKeychainModal(true);
return false;
@@ -354,7 +354,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
}
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
if (item.credentialsRequired === true
if (item.isTorrent !== true && item.credentialsRequired === true
&& !hasCredentialMaterial(item.password)
&& !hasCredentialMaterial(item.cookies)
&& !hasCredentialMaterial(item.headers)
@@ -379,12 +379,12 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
? null
: resolveDownloadConnections(item.connections, settings.perServerConnections),
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
sftp_host_key_md: item.sftpHostKeyMd || undefined,
headers: item.headers || null,
username: item.isTorrent === true ? null : item.username || (login ? login.username : null),
password: item.isTorrent === true ? null : item.password || keychainPassword,
sftp_host_key_md: item.isTorrent === true ? undefined : item.sftpHostKeyMd || undefined,
headers: item.isTorrent === true ? null : item.headers || null,
checksum: item.checksum || null,
cookies: item.cookies || null,
cookies: item.isTorrent === true ? null : item.cookies || null,
mirrors: item.mirrors || null,
user_agent: settings.customUserAgent.trim() || null,
max_tries: settings.maxAutomaticRetries,
@@ -434,7 +434,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
) {
return false;
}
const showsAllocationPhase = item.isMedia !== true && item.isTorrent !== true;
const showsAllocationPhase = isAllocationPhaseEligible(admittedItem);
if (showsAllocationPhase) {
useDownloadStore.getState().setAllocationPending(id, true);
}
@@ -834,6 +834,13 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
&& ['paused', 'failed', 'completed'].includes(rawVerifyRestoreStatus)
? rawVerifyRestoreStatus
: undefined;
const torrentCredentialStateChanged = download.isTorrent === true && (
download.username !== undefined
|| download.password !== undefined
|| download.headers !== undefined
|| download.cookies !== undefined
|| download.credentialsRequired !== undefined
);
const normalizedOptions = rawSeedRemaining !== normalizedSeedRemaining ||
download.connections !== normalizedConnections ||
rawUploadedBytes !== normalizedUploadedBytes ||
@@ -858,7 +865,8 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
rawMoveDestination !== normalizedMoveDestination ||
rawMoveRestoreStatus !== normalizedMoveRestoreStatus ||
recoveredMoveStatus !== download.status ||
rawVerifyRestoreStatus !== normalizedVerifyRestoreStatus
rawVerifyRestoreStatus !== normalizedVerifyRestoreStatus ||
torrentCredentialStateChanged
? {
...download,
connections: normalizedConnections,
@@ -885,7 +893,16 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
torrentRelocationCheckPending: normalizedRelocationCheckPending,
torrentMoveDestination: normalizedMoveDestination,
torrentMoveRestoreStatus: normalizedMoveRestoreStatus,
torrentVerifyRestoreStatus: normalizedVerifyRestoreStatus
torrentVerifyRestoreStatus: normalizedVerifyRestoreStatus,
...(download.isTorrent === true
? {
username: undefined,
password: undefined,
headers: undefined,
cookies: undefined,
credentialsRequired: undefined,
}
: {})
}
: recoveredMoveStatus !== download.status
? { ...download, status: recoveredMoveStatus, torrentMoveRestoreStatus: normalizedMoveRestoreStatus }
@@ -1167,6 +1184,15 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
: credentialsUpdated && item.credentialsRequired === true
? { credentialsRequired: true }
: {}),
...(item.isTorrent === true
? {
username: undefined,
password: undefined,
headers: undefined,
cookies: undefined,
credentialsRequired: undefined,
}
: {}),
};
const disablingTorrentRemoval = item.isTorrent === true
&& normalizedUpdates.torrentRemoveUnselectedFile === false
@@ -1261,7 +1287,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
if (!targetItem) return false;
}
if (targetItem.credentialsRequired === true
if (targetItem.isTorrent !== true && targetItem.credentialsRequired === true
&& !hasCredentialMaterial(targetItem.password)
&& !hasCredentialMaterial(targetItem.cookies)
&& !hasCredentialMaterial(targetItem.headers)) {
@@ -1285,6 +1311,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
}
}
clearCredentialsRequired(id);
} else if (targetItem.isTorrent === true && targetItem.credentialsRequired === true) {
clearCredentialsRequired(id);
}
setDownloadControlIntent(id, 'resume');
@@ -1784,6 +1812,15 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const settings = useSettingsStore.getState();
const normalizedItem = {
...item,
...(item.isTorrent === true
? {
username: undefined,
password: undefined,
headers: undefined,
cookies: undefined,
credentialsRequired: undefined,
}
: {}),
fileName: canonicalizeDownloadFileName(item.fileName),
category: categoryForFileName(item.fileName, item.isTorrent === true)
};
@@ -2628,7 +2665,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const item = get().downloads.find(download => download.id === pendingItem.id);
if (!item || item.status !== 'queued' || get().backendRegisteredIds.has(item.id)) continue;
const login = getSiteLogin(item.url, settings);
const login = item.isTorrent === true ? null : getSiteLogin(item.url, settings);
let keychainPassword = null;
if (login && !item.password && settings.keychainAccessReady) {
try {
@@ -2637,7 +2674,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
console.warn("Could not fetch keychain password for login:", e);
}
}
if (item.credentialsRequired === true
if (item.isTorrent !== true && item.credentialsRequired === true
&& !hasCredentialMaterial(item.password)
&& !hasCredentialMaterial(item.cookies)
&& !hasCredentialMaterial(item.headers)
@@ -2658,12 +2695,12 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
? null
: resolveDownloadConnections(item.connections, settings.perServerConnections),
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
sftp_host_key_md: item.sftpHostKeyMd || undefined,
headers: item.headers || null,
username: item.isTorrent === true ? null : item.username || (login ? login.username : null),
password: item.isTorrent === true ? null : item.password || keychainPassword,
sftp_host_key_md: item.isTorrent === true ? undefined : item.sftpHostKeyMd || undefined,
headers: item.isTorrent === true ? null : item.headers || null,
checksum: item.checksum || null,
cookies: item.cookies || null,
cookies: item.isTorrent === true ? null : item.cookies || null,
mirrors: item.mirrors || null,
user_agent: settings.customUserAgent.trim() || null,
max_tries: settings.maxAutomaticRetries,
@@ -2724,7 +2761,20 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
currentDownloadLifecycle(item.id).toString() === item.lifecycle_generation;
});
if (dispatchableItems.length === 0) return;
const results = await invoke('enqueue_many', { items: dispatchableItems });
const allocationPendingIds = dispatchableItems
.filter(item => {
const current = latestItems.get(item.id);
return current !== undefined && isAllocationPhaseEligible(current);
})
.map(item => item.id);
allocationPendingIds.forEach(id => get().setAllocationPending(id, true));
let results;
try {
results = await invoke('enqueue_many', { items: dispatchableItems });
} finally {
allocationPendingIds.forEach(id => get().setAllocationPending(id, false));
}
const registeredIds = results.filter(result => result.success).map(result => result.id);
const failedErrors = new Map(
results