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
+4 -4
View File
@@ -1563,16 +1563,16 @@ export const AddDownloadsModal = () => {
// and must not inherit the generic 116 HTTP setting.
connections: item.isTorrent ? undefined : Number(connections),
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
username: useAuth ? username.trim() : undefined,
password: useAuth ? password.trim() : undefined,
username: item.isTorrent ? undefined : useAuth ? username.trim() : undefined,
password: item.isTorrent ? undefined : useAuth ? password.trim() : undefined,
sftpHostKeyMd: !item.isTorrent && item.sourceUrl.trim().toLowerCase().startsWith('sftp:')
? sftpHostKeyMd.trim() || undefined
: undefined,
headers: headersForRow(contextUrl) || undefined,
headers: item.isTorrent ? undefined : headersForRow(contextUrl) || undefined,
checksum: checksumEnabled && checksumValue.trim()
? `${checksumAlgo}=${checksumValue.trim()}`
: undefined,
cookies: cookiesForRow(contextUrl, item.downloadUrl) || undefined,
cookies: item.isTorrent ? undefined : cookiesForRow(contextUrl, item.downloadUrl) || undefined,
mirrors: mirrors.trim() || undefined,
destination: useSharedDestination || saveInDedicatedFolder || destinationOverrides[itemIndex]
? await destinationForFile(
+131
View File
@@ -993,6 +993,36 @@ describe('useDownloadStore', () => {
expect(normalized.torrentEncryptionPolicy).toBeUndefined();
});
it('migrates legacy Torrent credential context before restart resume', () => {
const normalized = normalizePersistedDownloadProgress({
id: 'legacy-torrent-credentials',
url: 'torrent:0123456789abcdef0123456789abcdef01234567',
fileName: 'payload',
status: 'paused',
category: 'Other',
dateAdded: '',
isTorrent: true,
torrentPath: '/managed/legacy-torrent.torrent',
torrentInfoHash: '0123456789abcdef0123456789abcdef01234567',
username: 'browser-user',
password: 'secret',
headers: 'User-Agent: browser',
cookies: 'session=metadata-only',
credentialsRequired: true,
});
expect(normalized).toMatchObject({
isTorrent: true,
torrentPath: '/managed/legacy-torrent.torrent',
torrentInfoHash: '0123456789abcdef0123456789abcdef01234567',
});
expect(normalized.username).toBeUndefined();
expect(normalized.password).toBeUndefined();
expect(normalized.headers).toBeUndefined();
expect(normalized.cookies).toBeUndefined();
expect(normalized.credentialsRequired).toBeUndefined();
});
it('recovers an interrupted Torrent move without discarding the native destination marker', () => {
const normalized = normalizePersistedDownloadProgress({
id: 'interrupted-torrent-move',
@@ -1316,6 +1346,52 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().allocationPendingIds.has('allocation-phase')).toBe(false);
});
it('exposes allocation phase for a preallocated Torrent and strips metadata credentials', async () => {
useDownloadStore.setState({
downloads: [{
id: 'torrent-allocation-phase',
url: 'torrent:0123456789abcdef0123456789abcdef01234567',
fileName: 'payload',
destination: '/tmp',
status: 'queued',
category: 'Other',
dateAdded: '',
queueId: 'MAIN',
isTorrent: true,
torrentFileAllocation: 'prealloc',
username: 'browser-user',
password: 'secret',
headers: 'User-Agent: browser',
cookies: 'session=metadata-only',
}] as any[],
backendRegisteredIds: new Set(),
allocationPendingIds: new Set(),
});
let resolveEnqueue!: (value: { id: string; filename: string }) => void;
const enqueue = new Promise<{ id: string; filename: string }>(resolve => {
resolveEnqueue = resolve;
});
vi.mocked(ipc.invokeCommand).mockImplementation((command: string, args?: unknown) => {
if (command === 'enqueue_download') {
expect((args as { item: { username: string | null; password: string | null; headers: string | null; cookies: string | null } }).item)
.toMatchObject({ username: null, password: null, headers: null, cookies: null });
return enqueue as never;
}
if (command === 'get_pending_order') return Promise.resolve(['torrent-allocation-phase']) as never;
return Promise.resolve(undefined) as never;
});
const dispatch = dispatchItem('torrent-allocation-phase');
await vi.waitFor(() => {
expect(useDownloadStore.getState().allocationPendingIds.has('torrent-allocation-phase')).toBe(true);
});
resolveEnqueue({ id: 'torrent-allocation-phase', filename: 'payload' });
await expect(dispatch).resolves.toBe(true);
expect(useDownloadStore.getState().allocationPendingIds.has('torrent-allocation-phase')).toBe(false);
});
it('clears allocation state when a terminal status wins the race', () => {
useDownloadStore.setState({
downloads: [{
@@ -2450,6 +2526,61 @@ describe('useDownloadStore', () => {
});
});
it('shows and clears allocation phase for a blocked startup Torrent batch', async () => {
let releaseEnqueue!: (value: Array<{ id: string; success: boolean; filename: string }>) => void;
const enqueue = new Promise<Array<{ id: string; success: boolean; filename: string }>>(resolve => {
releaseEnqueue = resolve;
});
vi.mocked(ipc.invokeCommand).mockImplementation((cmd: string) => {
if (cmd === 'db_get_all_queues') return Promise.resolve([]) as never;
if (cmd === 'db_get_all_downloads') {
return Promise.resolve([JSON.stringify({
id: 'startup-torrent-allocation',
url: 'torrent:0123456789abcdef0123456789abcdef01234567',
fileName: 'payload',
status: 'queued',
category: 'Other',
dateAdded: '',
queueId: '00000000-0000-0000-0000-000000000001',
hasBeenDispatched: true,
isTorrent: true,
torrentFileAllocation: 'prealloc',
username: 'browser-user',
password: 'secret',
headers: 'User-Agent: browser',
cookies: 'session=metadata-only',
credentialsRequired: true,
})]) as never;
}
if (cmd === 'enqueue_many') return enqueue as never;
if (cmd === 'get_pending_order') return Promise.resolve([]) as never;
return Promise.resolve(undefined) as never;
});
await useDownloadStore.getState().initDB();
const resume = useDownloadStore.getState().resumePendingDownloads();
await vi.waitFor(() => {
expect(useDownloadStore.getState().allocationPendingIds.has('startup-torrent-allocation')).toBe(true);
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_many',
expect.objectContaining({
items: [expect.objectContaining({
username: null,
password: null,
headers: null,
cookies: null,
})]
})
);
});
releaseEnqueue([{ id: 'startup-torrent-allocation', success: true, filename: 'payload' }]);
await resume;
expect(useDownloadStore.getState().allocationPendingIds.has('startup-torrent-allocation')).toBe(false);
expect(useDownloadStore.getState().downloads[0].credentialsRequired).toBeUndefined();
});
it('keeps all startup items retryable when system proxy resolution fails', async () => {
vi.mocked(useSettingsStore.getState).mockReturnValue({
...useSettingsStore.getState(),
+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
+28
View File
@@ -9,6 +9,7 @@ import {
categoryForDownload,
categoryForFileName,
isAllocationPhaseVisible,
isAllocationPhaseEligible,
isValidTorrentExcludeTrackerList,
isValidTorrentTrackerList,
normalizeTorrentEncryptionPolicy,
@@ -88,6 +89,24 @@ describe('download persistence progress snapshots', () => {
expect(persisted.headers).toBeUndefined();
});
it('does not create a credential gate for Torrent metadata context', () => {
const persisted = redactDownloadForPersistence({
...item('paused'),
isTorrent: true,
username: 'browser-user',
password: 'secret',
cookies: 'session=metadata-only',
headers: 'User-Agent: browser',
credentialsRequired: true,
});
expect(persisted.credentialsRequired).toBeUndefined();
expect(persisted.username).toBeUndefined();
expect(persisted.password).toBeUndefined();
expect(persisted.cookies).toBeUndefined();
expect(persisted.headers).toBeUndefined();
});
it.each(['queued', 'staged', 'retrying', 'processing'] as const)(
'keeps byte counters for %s snapshots',
(status) => {
@@ -116,6 +135,15 @@ describe('allocation phase visibility', () => {
expect(isAllocationPhaseVisible(true, 'completed')).toBe(false);
expect(isAllocationPhaseVisible(false, 'downloading')).toBe(false);
});
it('uses Torrent allocation settings and excludes media and verify-only work', () => {
expect(isAllocationPhaseEligible({ isTorrent: true, torrentFileAllocation: undefined })).toBe(true);
expect(isAllocationPhaseEligible({ isTorrent: true, torrentFileAllocation: 'prealloc' })).toBe(true);
expect(isAllocationPhaseEligible({ isTorrent: true, torrentFileAllocation: 'none' })).toBe(false);
expect(isAllocationPhaseEligible({ isTorrent: true, torrentVerifyOnly: true })).toBe(false);
expect(isAllocationPhaseEligible({ isTorrent: true, isMedia: true })).toBe(false);
expect(isAllocationPhaseEligible({ isTorrent: false, isMedia: false })).toBe(true);
});
});
describe('Torrent tracker input validation', () => {
+21 -1
View File
@@ -55,6 +55,20 @@ export const isAllocationPhaseVisible = (
status: DownloadStatus,
): boolean => allocationPending && status !== 'completed' && status !== 'paused';
/**
* Allocation is a transient admission phase. Normal downloads retain the
* existing preallocation behavior; Torrent rows use Aria2's Torrent-specific
* allocation setting and never show the hint for verification-only work.
*/
export const isAllocationPhaseEligible = (
download: Pick<DownloadItem, 'isMedia' | 'isTorrent' | 'torrentFileAllocation' | 'torrentVerifyOnly'>,
): boolean => {
if (download.isMedia === true) return false;
if (download.isTorrent !== true) return true;
return download.torrentVerifyOnly !== true
&& normalizeTorrentFileAllocation(download.torrentFileAllocation) !== 'none';
};
export const DOWNLOAD_CONNECTIONS_MIN = 1;
export const DOWNLOAD_CONNECTIONS_MAX = 16;
@@ -590,7 +604,13 @@ const VOLATILE_PROGRESS_STATUSES = new Set([
*/
export const redactDownloadForPersistence = (item: DownloadItem): DownloadItem => {
const copy: DownloadItem = { ...item };
if (item.credentialsRequired === true
if (item.isTorrent === true) {
// Torrent request credentials belong only to metadata acquisition. A
// legacy row may still carry the marker or username in memory, but neither
// may turn a cached-metadata Torrent into a credential-gated restart.
delete copy.credentialsRequired;
delete copy.username;
} else if (item.credentialsRequired === true
|| DOWNLOAD_SECRET_FIELDS.some(field => Boolean(item[field]))) {
copy.credentialsRequired = true;
}