mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-23 01:16:47 +00:00
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:
@@ -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
@@ -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!(
|
||||
|
||||
@@ -1563,16 +1563,16 @@ export const AddDownloadsModal = () => {
|
||||
// and must not inherit the generic 1–16 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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user