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
+1
View File
@@ -6855,6 +6855,7 @@ async fn validate_torrent_enqueue(
app_handle: &tauri::AppHandle,
item: &mut queue::EnqueueItem,
) -> Result<(), String> {
item.strip_torrent_credentials();
if item.is_media.unwrap_or(false) {
return Err("torrent transfer cannot be a media download".to_string());
}
+123 -65
View File
@@ -6948,6 +6948,30 @@ fn apply_aria2_torrent_options(
Ok(())
}
fn apply_aria2_header_options(
options: &mut serde_json::Map<String, serde_json::Value>,
payload: &SpawnPayload,
credentials_allowed: bool,
) {
if !credentials_allowed {
return;
}
let mut header_list = Vec::new();
if let Some(cookies) = &payload.cookies {
header_list.push(format!("Cookie: {cookies}"));
}
if let Some(headers) = &payload.headers {
for line in headers.lines() {
if !line.trim().is_empty() {
header_list.push(line.trim().to_string());
}
}
}
if !header_list.is_empty() {
options.insert("header".to_string(), serde_json::json!(header_list));
}
}
impl ProductionSpawner {
pub fn new(app_handle: AppHandle<tauri::Wry>) -> Self {
Self { app_handle }
@@ -7055,7 +7079,10 @@ impl SidecarSpawner for ProductionSpawner {
}
let (transfer_uris, requested_connections, transfer_connections, credentials_allowed) =
if payload.is_torrent {
(Vec::new(), DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MIN, true)
// Torrent metadata acquisition credentials are never transfer
// credentials. Torrent trackers and web seeds use their own
// validated URI/options paths below.
(Vec::new(), DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MIN, false)
} else {
let requested_uris =
crate::collect_download_uris(&payload.url, payload.mirrors.as_deref());
@@ -7134,22 +7161,7 @@ impl SidecarSpawner for ProductionSpawner {
if let Some(ua) = &payload.user_agent {
options.insert("user-agent".to_string(), serde_json::json!(ua));
}
let mut header_list = Vec::new();
if payload.is_torrent || credentials_allowed {
if let Some(cook) = &payload.cookies {
header_list.push(format!("Cookie: {}", cook));
}
if let Some(hdrs) = &payload.headers {
for line in hdrs.lines() {
if !line.trim().is_empty() {
header_list.push(line.trim().to_string());
}
}
}
}
if !header_list.is_empty() {
options.insert("header".to_string(), serde_json::json!(header_list));
}
apply_aria2_header_options(&mut options, payload, credentials_allowed);
if let Some(prox) = proxy_value {
options.insert("all-proxy".to_string(), serde_json::json!(prox));
}
@@ -7715,7 +7727,7 @@ impl SidecarSpawner for ProductionSpawner {
}
}
#[derive(Debug, Clone, Deserialize, TS)]
#[derive(Debug, Clone, Default, Deserialize, TS)]
#[ts(export, export_to = "../../src/bindings/")]
pub struct EnqueueItem {
pub id: String,
@@ -7827,73 +7839,84 @@ pub struct EnqueueItem {
}
impl EnqueueItem {
pub fn strip_torrent_credentials(&mut self) {
if self.is_torrent.unwrap_or(false) {
self.username = None;
self.password = None;
self.headers = None;
self.cookies = None;
}
}
pub fn into_task(self) -> QueuedTask {
let media = self.is_media.unwrap_or(false);
let mut item = self;
item.strip_torrent_credentials();
let media = item.is_media.unwrap_or(false);
let kind = if media {
TaskKind::Media
} else {
TaskKind::Aria2
};
let id = self.id.clone();
let id = item.id.clone();
QueuedTask {
id,
queue_id: self.queue_id,
queue_id: item.queue_id,
kind,
lifecycle_generation: self
lifecycle_generation: item
.lifecycle_generation
.as_deref()
.and_then(|generation| generation.parse().ok())
.unwrap_or_default(),
payload: SpawnPayload {
url: self.url,
destination: self.destination,
filename: self.filename,
connections: self.connections,
speed_limit: self.speed_limit,
username: self.username,
password: self.password,
sftp_host_key_md: self.sftp_host_key_md,
headers: self.headers,
checksum: self.checksum,
cookies: self.cookies,
mirrors: self.mirrors,
user_agent: self.user_agent,
max_tries: self.max_tries,
minimum_normal_download_speed_kib: self
url: item.url,
destination: item.destination,
filename: item.filename,
connections: item.connections,
speed_limit: item.speed_limit,
username: item.username,
password: item.password,
sftp_host_key_md: item.sftp_host_key_md,
headers: item.headers,
checksum: item.checksum,
cookies: item.cookies,
mirrors: item.mirrors,
user_agent: item.user_agent,
max_tries: item.max_tries,
minimum_normal_download_speed_kib: item
.minimum_normal_download_speed_kib
.unwrap_or_default(),
retry_not_found_errors: self.retry_not_found_errors.unwrap_or(false),
adaptive_mirror_selection: self.adaptive_mirror_selection.unwrap_or(true),
proxy: self.proxy,
retry_not_found_errors: item.retry_not_found_errors.unwrap_or(false),
adaptive_mirror_selection: item.adaptive_mirror_selection.unwrap_or(true),
proxy: item.proxy,
aria2_resolver_mode: Aria2ResolverMode::Automatic,
format_selector: self.format_selector,
cookie_source: self.cookie_source,
format_selector: item.format_selector,
cookie_source: item.cookie_source,
is_media: media,
is_torrent: self.is_torrent.unwrap_or(false),
torrent_path: self.torrent_path,
torrent_file_indices: self.torrent_file_indices,
torrent_seed_time: self.torrent_seed_remaining.or(self.torrent_seed_time),
torrent_seed_ratio: self.torrent_seed_ratio,
torrent_seed_remaining: self.torrent_seed_remaining,
torrent_web_seeds: self.torrent_web_seeds,
torrent_upload_limit: self.torrent_upload_limit,
torrent_max_peers: self.torrent_max_peers,
torrent_peer_speed_limit: self.torrent_peer_speed_limit,
torrent_check_integrity: self.torrent_check_integrity.unwrap_or(false),
torrent_trackers: self.torrent_trackers,
torrent_exclude_trackers: self.torrent_exclude_trackers,
torrent_tracker_connect_timeout: self.torrent_tracker_connect_timeout,
torrent_tracker_timeout: self.torrent_tracker_timeout,
torrent_tracker_interval: self.torrent_tracker_interval,
torrent_stop_timeout: self.torrent_stop_timeout,
torrent_prioritize_piece: self.torrent_prioritize_piece,
torrent_remove_unselected_file: self
is_torrent: item.is_torrent.unwrap_or(false),
torrent_path: item.torrent_path,
torrent_file_indices: item.torrent_file_indices,
torrent_seed_time: item.torrent_seed_remaining.or(item.torrent_seed_time),
torrent_seed_ratio: item.torrent_seed_ratio,
torrent_seed_remaining: item.torrent_seed_remaining,
torrent_web_seeds: item.torrent_web_seeds,
torrent_upload_limit: item.torrent_upload_limit,
torrent_max_peers: item.torrent_max_peers,
torrent_peer_speed_limit: item.torrent_peer_speed_limit,
torrent_check_integrity: item.torrent_check_integrity.unwrap_or(false),
torrent_trackers: item.torrent_trackers,
torrent_exclude_trackers: item.torrent_exclude_trackers,
torrent_tracker_connect_timeout: item.torrent_tracker_connect_timeout,
torrent_tracker_timeout: item.torrent_tracker_timeout,
torrent_tracker_interval: item.torrent_tracker_interval,
torrent_stop_timeout: item.torrent_stop_timeout,
torrent_prioritize_piece: item.torrent_prioritize_piece,
torrent_remove_unselected_file: item
.torrent_remove_unselected_file
.unwrap_or(false),
torrent_encryption_policy: self.torrent_encryption_policy,
torrent_file_allocation: self.torrent_file_allocation,
torrent_verify_only: self.torrent_verify_only.unwrap_or(false),
torrent_verify_restore_status: self.torrent_verify_restore_status,
torrent_encryption_policy: item.torrent_encryption_policy,
torrent_file_allocation: item.torrent_file_allocation,
torrent_verify_only: item.torrent_verify_only.unwrap_or(false),
torrent_verify_restore_status: item.torrent_verify_restore_status,
torrent_verified_length: None,
},
}
@@ -8075,6 +8098,41 @@ mod tests {
assert_eq!(normal_options.get("max-connection-per-server"), Some(&serde_json::json!("16")));
}
#[test]
fn torrent_payloads_do_not_emit_generic_headers_or_cookies() {
let payload = SpawnPayload {
is_torrent: true,
headers: Some("User-Agent: browser\nAuthorization: secret".to_string()),
cookies: Some("session=secret".to_string()),
..Default::default()
};
let mut options = serde_json::Map::new();
apply_aria2_header_options(&mut options, &payload, false);
assert!(!options.contains_key("header"));
}
#[test]
fn torrent_enqueue_items_strip_generic_credentials_before_task_creation() {
let mut item = EnqueueItem {
is_torrent: Some(true),
username: Some("browser-user".to_string()),
password: Some("secret".to_string()),
headers: Some("Authorization: secret".to_string()),
cookies: Some("session=secret".to_string()),
..Default::default()
};
item.strip_torrent_credentials();
let task = item.into_task();
assert!(task.payload.username.is_none());
assert!(task.payload.password.is_none());
assert!(task.payload.headers.is_none());
assert!(task.payload.cookies.is_none());
}
#[test]
fn torrent_network_and_storage_settings_are_normalized_at_the_boundary() {
assert_eq!(
+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;
}