From 314f4e2e00ec753bf284b68ccf276f8c5c8fc444 Mon Sep 17 00:00:00 2001 From: NimBold Date: Thu, 13 Aug 2026 16:40:46 +0330 Subject: [PATCH] fix(settings): harden Torrent network validation - Align the DHT message-timeout range with bundled Aria2 1.37.0. - Validate and canonicalize Torrent network text settings at the native boundary. - Fence delayed input responses and reject contradictory IPv6 bind state. - Add regression coverage for malformed settings and cross-field races. --- src-tauri/src/lib.rs | 33 ++- src-tauri/src/queue.rs | 8 +- src-tauri/src/settings.rs | 57 +++- src/components/SettingsView.tsx | 369 +++++++++++++++----------- src/i18n/catalogs/en.ts | 1 + src/i18n/catalogs/fa.ts | 1 + src/i18n/catalogs/he.ts | 1 + src/i18n/catalogs/ru.ts | 1 + src/i18n/catalogs/uk.ts | 1 + src/i18n/catalogs/zh-CN.ts | 1 + src/ipc.ts | 4 + src/store/useSettingsStore.test.ts | 27 ++ src/store/useSettingsStore.ts | 24 +- src/utils/downloads.ts | 3 +- src/utils/torrentNetworkInput.test.ts | 16 ++ src/utils/torrentNetworkInput.ts | 6 + 16 files changed, 389 insertions(+), 164 deletions(-) create mode 100644 src/utils/torrentNetworkInput.test.ts create mode 100644 src/utils/torrentNetworkInput.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bec60c1..8b7cc95 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10319,6 +10319,16 @@ fn db_load_settings( .transpose() } +#[tauri::command] +fn canonicalize_torrent_network_setting( + caller: tauri::WebviewWindow, + field: String, + value: String, +) -> Result { + properties_window::ensure_main_window(&caller)?; + crate::settings::canonicalize_torrent_network_setting(&field, &value) +} + #[tauri::command] fn db_get_all_downloads( caller: tauri::WebviewWindow, @@ -11350,6 +11360,26 @@ mod tests { .collect::>(), vec!["--dht-message-timeout=10"] ); + + let mut maximum = std::process::Command::new("aria2c"); + apply_aria2_torrent_dht_options(&mut maximum, queue::MAX_TORRENT_DHT_MESSAGE_TIMEOUT); + assert_eq!( + maximum + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(), + vec!["--dht-message-timeout=60"] + ); + + let mut out_of_range = std::process::Command::new("aria2c"); + apply_aria2_torrent_dht_options(&mut out_of_range, 61); + assert_eq!( + out_of_range + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(), + vec!["--dht-message-timeout=10"] + ); } #[test] @@ -15451,7 +15481,8 @@ pub fn run() { properties_window::properties_window_registry_remove_for_download, parity::get_system_proxy, parity::get_file_category, parity::check_for_updates, parity::is_supported_media, parity::get_supported_media_domains, parity::create_category_directories, - db_save_settings, db_load_settings, db_get_all_downloads, db_replace_downloads, + db_save_settings, db_load_settings, canonicalize_torrent_network_setting, + db_get_all_downloads, db_replace_downloads, db_commit_download_state, clear_torrent_removal_paths, reconcile_torrent_removal_reservations, db_get_all_queues, db_replace_queues, diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 8d7cf9c..e12ecb0 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -32,7 +32,10 @@ pub const MIN_TORRENT_MAX_OPEN_FILES: u32 = 1; pub const MAX_TORRENT_MAX_OPEN_FILES: u32 = 4_096; pub const DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT: u32 = 10; pub const MIN_TORRENT_DHT_MESSAGE_TIMEOUT: u32 = 1; -pub const MAX_TORRENT_DHT_MESSAGE_TIMEOUT: u32 = 600; +// Aria2 1.37.0 rejects values above 60 during option parsing. Keep this +// boundary aligned with the bundled engine so a saved setting cannot prevent +// the daemon from starting. +pub const MAX_TORRENT_DHT_MESSAGE_TIMEOUT: u32 = 60; pub const DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS: u32 = 2; pub const MIN_TORRENT_MAX_CONCURRENT_SEEDS: u32 = 1; pub const MAX_TORRENT_MAX_CONCURRENT_SEEDS: u32 = 64; @@ -8509,8 +8512,9 @@ mod tests { #[test] fn torrent_network_limits_and_web_seed_normalization_are_bounded() { assert_eq!(normalize_torrent_dht_message_timeout(1).unwrap(), 1); - assert_eq!(normalize_torrent_dht_message_timeout(600).unwrap(), 600); + assert_eq!(normalize_torrent_dht_message_timeout(60).unwrap(), 60); assert!(normalize_torrent_dht_message_timeout(0).is_err()); + assert!(normalize_torrent_dht_message_timeout(61).is_err()); assert_eq!(normalize_torrent_max_concurrent_seeds(2).unwrap(), 2); assert!(normalize_torrent_max_concurrent_seeds(65).is_err()); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 5873f82..2cdf04e 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -233,6 +233,37 @@ pub fn canonicalize_torrent_network_settings(stored: &str) -> Result Result { + let normalized = match field { + "torrentListenPort" => { + crate::queue::normalize_torrent_port_spec(Some(value), "TCP listen ports")? + } + "torrentDhtListenPort" => { + crate::queue::normalize_torrent_port_spec(Some(value), "UDP listen ports")? + } + "torrentExternalIp" => crate::queue::normalize_torrent_external_ip(Some(value))?, + "torrentDhtEntryPoint" => { + crate::queue::normalize_torrent_dht_entry_point(Some(value), false)? + } + "torrentDhtEntryPoint6" => { + crate::queue::normalize_torrent_dht_entry_point(Some(value), true)? + } + "torrentDhtListenAddr6" => { + crate::queue::normalize_torrent_dht_listen_addr6(Some(value))? + } + "torrentLpdInterface" => crate::queue::normalize_torrent_lpd_interface(Some(value))?, + "torrentPeerIdPrefix" => crate::queue::normalize_torrent_peer_id_prefix(Some(value))?, + "torrentPeerAgent" => crate::queue::normalize_torrent_peer_agent(Some(value))?, + "torrentBindAddress" => crate::queue::normalize_torrent_bind_address(Some(value))?, + "aria2DiskCache" => return crate::queue::normalize_aria2_disk_cache(Some(value)), + _ => return Err("unknown Torrent network setting".to_string()), + }; + Ok(normalized.unwrap_or_default()) +} + pub fn update_settings_state( app_handle: &AppHandle, update: impl FnOnce(&mut Map), @@ -936,7 +967,8 @@ fn default_settings() -> PersistedSettings { mod tests { use crate::ipc::{FontFamily, WindowControlStyle}; use super::{ - canonicalize_torrent_network_settings, decode_stored_settings, default_settings, + canonicalize_torrent_network_setting, canonicalize_torrent_network_settings, + decode_stored_settings, default_settings, preserve_portable_pairing_token, preserve_scheduler_runtime_keys, torrent_startup_settings, }; @@ -1376,6 +1408,29 @@ mod tests { assert_eq!(canonical["state"]["torrentSeparateSeedSlots"], false); } + #[test] + fn canonicalizes_individual_torrent_network_inputs_with_shared_rules() { + assert_eq!( + canonicalize_torrent_network_setting("torrentListenPort", " 6881-6999 ").unwrap(), + "6881-6999" + ); + assert_eq!( + canonicalize_torrent_network_setting("torrentDhtEntryPoint6", "[2001:db8::1]:6881") + .unwrap(), + "[2001:db8::1]:6881" + ); + assert_eq!( + canonicalize_torrent_network_setting("aria2DiskCache", " 256m ").unwrap(), + "256M" + ); + assert_eq!( + canonicalize_torrent_network_setting("torrentBindAddress", " ").unwrap(), + "" + ); + assert!(canonicalize_torrent_network_setting("torrentListenPort", "61").is_err()); + assert!(canonicalize_torrent_network_setting("unknown", "value").is_err()); + } + #[test] fn rejects_ipv6_bind_address_when_transport_is_disabled() { let stored = json!({ diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 9ece69d..34603da 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -34,6 +34,7 @@ import { import { usePlatformInfo } from '../utils/platform'; import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls'; import { normalizeCustomProxy } from '../store/useDownloadStore'; +import { shouldApplyTorrentNetworkInputResult } from '../utils/torrentNetworkInput'; import { DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT, MAX_TORRENT_DHT_MESSAGE_TIMEOUT, @@ -99,6 +100,105 @@ type ManualUpdateStatus = type SystemProxyStatus = 'idle' | 'checking' | 'detected' | 'none' | 'error'; +type TorrentNetworkTextField = + | 'torrentListenPort' + | 'torrentDhtListenPort' + | 'torrentExternalIp' + | 'torrentDhtEntryPoint' + | 'torrentDhtEntryPoint6' + | 'torrentDhtListenAddr6' + | 'torrentLpdInterface' + | 'torrentPeerIdPrefix' + | 'torrentPeerAgent' + | 'torrentBindAddress' + | 'aria2DiskCache'; + +const TorrentNetworkTextInput = ({ + field, + value, + label, + description, + placeholder, + onCommit, + onError, + maxLength, + className = 'app-control settings-network-input' +}: { + field: TorrentNetworkTextField; + value: string; + label: string; + description: string; + placeholder?: string; + onCommit: (value: string) => boolean | void; + onError: (error: unknown) => void; + maxLength?: number; + className?: string; +}) => { + const [draft, setDraft] = useState(value); + const commitId = useRef(0); + const editId = useRef(0); + + useEffect(() => { + editId.current += 1; + setDraft(value); + }, [value]); + + const commit = async () => { + const requestId = ++commitId.current; + const editRequestId = editId.current; + try { + const normalized = await invoke('canonicalize_torrent_network_setting', { + field, + value: draft + }); + if (!shouldApplyTorrentNetworkInputResult( + requestId, + commitId.current, + editRequestId, + editId.current + )) return; + if (onCommit(normalized) === false) { + setDraft(value); + onError(new Error('This Torrent network value conflicts with another setting.')); + return; + } + setDraft(normalized); + } catch (error) { + if (!shouldApplyTorrentNetworkInputResult( + requestId, + commitId.current, + editRequestId, + editId.current + )) return; + setDraft(value); + onError(error); + } + }; + + return ( +
+
+ {label} + {description} +
+ { + editId.current += 1; + setDraft(event.target.value); + }} + onBlur={() => { void commit(); }} + placeholder={placeholder} + maxLength={maxLength} + className={className} + aria-label={label} + /> +
+ ); +}; + export type NetworkSettingsSection = 'general' | 'discovery' | 'connection' | 'limits' | 'advanced'; const networkSettingsSections: NetworkSettingsSection[] = [ @@ -420,6 +520,15 @@ const engineRunId = useRef(0); // Toast notifications const { addToast } = useToast(); + const showTorrentNetworkInputError = (error: unknown) => { + addToast({ + message: t($ => $.settings.network.torrentNetworkInputInvalid, { + detail: error instanceof Error ? error.message : String(error) + }), + variant: 'error', + isActionable: true + }); + }; const commitTorrentMaxOpenFiles = (raw: string) => { const next = normalizeTorrentMaxOpenFiles(raw) ?? settings.torrentMaxOpenFiles; const requestId = ++torrentMaxOpenFilesCommitRef.current; @@ -1442,148 +1551,100 @@ runEngineChecks(false);