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.
This commit is contained in:
NimBold
2026-08-13 16:40:46 +03:30
parent 4b43e8ed5c
commit 314f4e2e00
16 changed files with 389 additions and 164 deletions
+32 -1
View File
@@ -10319,6 +10319,16 @@ fn db_load_settings(
.transpose() .transpose()
} }
#[tauri::command]
fn canonicalize_torrent_network_setting(
caller: tauri::WebviewWindow,
field: String,
value: String,
) -> Result<String, String> {
properties_window::ensure_main_window(&caller)?;
crate::settings::canonicalize_torrent_network_setting(&field, &value)
}
#[tauri::command] #[tauri::command]
fn db_get_all_downloads( fn db_get_all_downloads(
caller: tauri::WebviewWindow, caller: tauri::WebviewWindow,
@@ -11350,6 +11360,26 @@ mod tests {
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
vec!["--dht-message-timeout=10"] 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<_>>(),
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<_>>(),
vec!["--dht-message-timeout=10"]
);
} }
#[test] #[test]
@@ -15451,7 +15481,8 @@ pub fn run() {
properties_window::properties_window_registry_remove_for_download, 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::get_system_proxy, parity::get_file_category, parity::check_for_updates, parity::is_supported_media, parity::get_supported_media_domains,
parity::create_category_directories, 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, db_commit_download_state,
clear_torrent_removal_paths, reconcile_torrent_removal_reservations, clear_torrent_removal_paths, reconcile_torrent_removal_reservations,
db_get_all_queues, db_replace_queues, db_get_all_queues, db_replace_queues,
+6 -2
View File
@@ -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 MAX_TORRENT_MAX_OPEN_FILES: u32 = 4_096;
pub const DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT: u32 = 10; pub const DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT: u32 = 10;
pub const MIN_TORRENT_DHT_MESSAGE_TIMEOUT: u32 = 1; 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 DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS: u32 = 2;
pub const MIN_TORRENT_MAX_CONCURRENT_SEEDS: u32 = 1; pub const MIN_TORRENT_MAX_CONCURRENT_SEEDS: u32 = 1;
pub const MAX_TORRENT_MAX_CONCURRENT_SEEDS: u32 = 64; pub const MAX_TORRENT_MAX_CONCURRENT_SEEDS: u32 = 64;
@@ -8509,8 +8512,9 @@ mod tests {
#[test] #[test]
fn torrent_network_limits_and_web_seed_normalization_are_bounded() { 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(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(0).is_err());
assert!(normalize_torrent_dht_message_timeout(61).is_err());
assert_eq!(normalize_torrent_max_concurrent_seeds(2).unwrap(), 2); assert_eq!(normalize_torrent_max_concurrent_seeds(2).unwrap(), 2);
assert!(normalize_torrent_max_concurrent_seeds(65).is_err()); assert!(normalize_torrent_max_concurrent_seeds(65).is_err());
+56 -1
View File
@@ -233,6 +233,37 @@ pub fn canonicalize_torrent_network_settings(stored: &str) -> Result<String, Str
.map_err(|error| format!("failed to encode canonical settings: {error}")) .map_err(|error| format!("failed to encode canonical settings: {error}"))
} }
/// Normalize one text setting before the frontend commits it to durable state.
/// Keep this on the native boundary so interactive validation and persisted
/// settings use exactly the same Aria2-compatible rules.
pub fn canonicalize_torrent_network_setting(field: &str, value: &str) -> Result<String, String> {
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( pub fn update_settings_state(
app_handle: &AppHandle, app_handle: &AppHandle,
update: impl FnOnce(&mut Map<String, Value>), update: impl FnOnce(&mut Map<String, Value>),
@@ -936,7 +967,8 @@ fn default_settings() -> PersistedSettings {
mod tests { mod tests {
use crate::ipc::{FontFamily, WindowControlStyle}; use crate::ipc::{FontFamily, WindowControlStyle};
use super::{ 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, preserve_portable_pairing_token, preserve_scheduler_runtime_keys,
torrent_startup_settings, torrent_startup_settings,
}; };
@@ -1376,6 +1408,29 @@ mod tests {
assert_eq!(canonical["state"]["torrentSeparateSeedSlots"], false); 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] #[test]
fn rejects_ipv6_bind_address_when_transport_is_disabled() { fn rejects_ipv6_bind_address_when_transport_is_disabled() {
let stored = json!({ let stored = json!({
+213 -156
View File
@@ -34,6 +34,7 @@ import {
import { usePlatformInfo } from '../utils/platform'; import { usePlatformInfo } from '../utils/platform';
import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls'; import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls';
import { normalizeCustomProxy } from '../store/useDownloadStore'; import { normalizeCustomProxy } from '../store/useDownloadStore';
import { shouldApplyTorrentNetworkInputResult } from '../utils/torrentNetworkInput';
import { import {
DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT, DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
MAX_TORRENT_DHT_MESSAGE_TIMEOUT, MAX_TORRENT_DHT_MESSAGE_TIMEOUT,
@@ -99,6 +100,105 @@ type ManualUpdateStatus =
type SystemProxyStatus = 'idle' | 'checking' | 'detected' | 'none' | 'error'; 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 (
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{label}</span>
<small>{description}</small>
</div>
<input
type="text"
dir="ltr"
value={draft}
onChange={(event) => {
editId.current += 1;
setDraft(event.target.value);
}}
onBlur={() => { void commit(); }}
placeholder={placeholder}
maxLength={maxLength}
className={className}
aria-label={label}
/>
</div>
);
};
export type NetworkSettingsSection = 'general' | 'discovery' | 'connection' | 'limits' | 'advanced'; export type NetworkSettingsSection = 'general' | 'discovery' | 'connection' | 'limits' | 'advanced';
const networkSettingsSections: NetworkSettingsSection[] = [ const networkSettingsSections: NetworkSettingsSection[] = [
@@ -420,6 +520,15 @@ const engineRunId = useRef(0);
// Toast notifications // Toast notifications
const { addToast } = useToast(); 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 commitTorrentMaxOpenFiles = (raw: string) => {
const next = normalizeTorrentMaxOpenFiles(raw) ?? settings.torrentMaxOpenFiles; const next = normalizeTorrentMaxOpenFiles(raw) ?? settings.torrentMaxOpenFiles;
const requestId = ++torrentMaxOpenFilesCommitRef.current; const requestId = ++torrentMaxOpenFilesCommitRef.current;
@@ -1442,148 +1551,100 @@ runEngineChecks(false);
<div id="network-settings-panel-connection" role="tabpanel" aria-labelledby="network-settings-tab-connection" hidden={networkSection !== 'connection'} tabIndex={0}> <div id="network-settings-panel-connection" role="tabpanel" aria-labelledby="network-settings-tab-connection" hidden={networkSection !== 'connection'} tabIndex={0}>
<h2 className="settings-section-title">{t($ => $.settings.network.torrentNetwork)}</h2> <h2 className="settings-section-title">{t($ => $.settings.network.torrentNetwork)}</h2>
<div className="mac-settings-group"> <div className="mac-settings-group">
<div className="mac-settings-row settings-network-row"> <TorrentNetworkTextInput
<div className="settings-row-label"> field="torrentListenPort"
<span>{t($ => $.settings.network.torrentListenPort)}</span> value={settings.torrentListenPort}
<small>{t($ => $.settings.network.torrentListenPortDescription)}</small> label={t($ => $.settings.network.torrentListenPort)}
</div> description={t($ => $.settings.network.torrentListenPortDescription)}
<input placeholder="6881-6999"
type="text" onCommit={settings.setTorrentListenPort}
value={settings.torrentListenPort} onError={showTorrentNetworkInputError}
onChange={(event) => settings.setTorrentListenPort(event.target.value)} className="app-control settings-port-input text-center"
placeholder="6881-6999" />
className="app-control settings-port-input text-center" <TorrentNetworkTextInput
aria-label={t($ => $.settings.network.torrentListenPort)} field="torrentBindAddress"
/> value={settings.torrentBindAddress}
</div> label={t($ => $.settings.network.torrentBindAddress)}
<div className="mac-settings-row settings-network-row"> description={t($ => $.settings.network.torrentBindAddressDescription)}
<div className="settings-row-label"> placeholder="192.0.2.10 or 2001:db8::10"
<span>{t($ => $.settings.network.torrentBindAddress)}</span> onCommit={settings.setTorrentBindAddress}
<small>{t($ => $.settings.network.torrentBindAddressDescription)}</small> onError={showTorrentNetworkInputError}
</div> />
<input <TorrentNetworkTextInput
type="text" field="torrentDhtListenPort"
value={settings.torrentBindAddress} value={settings.torrentDhtListenPort}
onChange={(event) => settings.setTorrentBindAddress(event.target.value)} label={t($ => $.settings.network.torrentDhtListenPort)}
placeholder="192.0.2.10 or 2001:db8::10" description={t($ => $.settings.network.torrentDhtListenPortDescription)}
className="app-control settings-network-input" placeholder="6881-6999"
aria-label={t($ => $.settings.network.torrentBindAddress)} onCommit={settings.setTorrentDhtListenPort}
/> onError={showTorrentNetworkInputError}
</div> className="app-control settings-port-input text-center"
<div className="mac-settings-row settings-network-row"> />
<div className="settings-row-label"> <TorrentNetworkTextInput
<span>{t($ => $.settings.network.torrentDhtListenPort)}</span> field="torrentExternalIp"
<small>{t($ => $.settings.network.torrentDhtListenPortDescription)}</small> value={settings.torrentExternalIp}
</div> label={t($ => $.settings.network.torrentExternalIp)}
<input description={t($ => $.settings.network.torrentExternalIpDescription)}
type="text" placeholder={t($ => $.settings.network.torrentExternalIpPlaceholder)}
value={settings.torrentDhtListenPort} onCommit={settings.setTorrentExternalIp}
onChange={(event) => settings.setTorrentDhtListenPort(event.target.value)} onError={showTorrentNetworkInputError}
placeholder="6881-6999" />
className="app-control settings-port-input text-center" <TorrentNetworkTextInput
aria-label={t($ => $.settings.network.torrentDhtListenPort)} field="torrentDhtEntryPoint"
/> value={settings.torrentDhtEntryPoint}
</div> label={t($ => $.settings.network.torrentDhtEntryPoint)}
<div className="mac-settings-row settings-network-row"> description={t($ => $.settings.network.torrentDhtEntryPointDescription)}
<div className="settings-row-label"> placeholder="router.example:6881"
<span>{t($ => $.settings.network.torrentExternalIp)}</span> onCommit={settings.setTorrentDhtEntryPoint}
<small>{t($ => $.settings.network.torrentExternalIpDescription)}</small> onError={showTorrentNetworkInputError}
</div> />
<input <TorrentNetworkTextInput
type="text" field="torrentDhtEntryPoint6"
value={settings.torrentExternalIp} value={settings.torrentDhtEntryPoint6}
onChange={(event) => settings.setTorrentExternalIp(event.target.value)} label={t($ => $.settings.network.torrentDhtEntryPoint6)}
placeholder={t($ => $.settings.network.torrentExternalIpPlaceholder)} description={t($ => $.settings.network.torrentDhtEntryPoint6Description)}
className="app-control settings-network-input" placeholder="[2001:db8::1]:6881"
aria-label={t($ => $.settings.network.torrentExternalIp)} onCommit={settings.setTorrentDhtEntryPoint6}
/> onError={showTorrentNetworkInputError}
</div> />
<div className="mac-settings-row settings-network-row"> <TorrentNetworkTextInput
<div className="settings-row-label"> field="torrentDhtListenAddr6"
<span>{t($ => $.settings.network.torrentDhtEntryPoint)}</span> value={settings.torrentDhtListenAddr6}
<small>{t($ => $.settings.network.torrentDhtEntryPointDescription)}</small> label={t($ => $.settings.network.torrentDhtListenAddr6)}
</div> description={t($ => $.settings.network.torrentDhtListenAddr6Description)}
<input placeholder="2001:db8::2"
type="text" onCommit={settings.setTorrentDhtListenAddr6}
value={settings.torrentDhtEntryPoint} onError={showTorrentNetworkInputError}
onChange={(event) => settings.setTorrentDhtEntryPoint(event.target.value)} />
placeholder="router.example:6881" <TorrentNetworkTextInput
className="app-control settings-network-input" field="torrentLpdInterface"
aria-label={t($ => $.settings.network.torrentDhtEntryPoint)} value={settings.torrentLpdInterface}
/> label={t($ => $.settings.network.torrentLpdInterface)}
</div> description={t($ => $.settings.network.torrentLpdInterfaceDescription)}
<div className="mac-settings-row settings-network-row"> placeholder="en0"
<div className="settings-row-label"> onCommit={settings.setTorrentLpdInterface}
<span>{t($ => $.settings.network.torrentDhtEntryPoint6)}</span> onError={showTorrentNetworkInputError}
<small>{t($ => $.settings.network.torrentDhtEntryPoint6Description)}</small> />
</div> <TorrentNetworkTextInput
<input field="torrentPeerIdPrefix"
type="text" value={settings.torrentPeerIdPrefix}
value={settings.torrentDhtEntryPoint6} label={t($ => $.settings.network.torrentPeerIdPrefix)}
onChange={(event) => settings.setTorrentDhtEntryPoint6(event.target.value)} description={t($ => $.settings.network.torrentPeerIdPrefixDescription)}
placeholder="[2001:db8::1]:6881" placeholder="-FL-1-3-1-"
className="app-control settings-network-input" maxLength={20}
aria-label={t($ => $.settings.network.torrentDhtEntryPoint6)} onCommit={settings.setTorrentPeerIdPrefix}
/> onError={showTorrentNetworkInputError}
</div> />
<div className="mac-settings-row settings-network-row"> <TorrentNetworkTextInput
<div className="settings-row-label"> field="torrentPeerAgent"
<span>{t($ => $.settings.network.torrentDhtListenAddr6)}</span> value={settings.torrentPeerAgent}
<small>{t($ => $.settings.network.torrentDhtListenAddr6Description)}</small> label={t($ => $.settings.network.torrentPeerAgent)}
</div> description={t($ => $.settings.network.torrentPeerAgentDescription)}
<input placeholder="Firelink/1.3.1"
type="text" maxLength={128}
value={settings.torrentDhtListenAddr6} onCommit={settings.setTorrentPeerAgent}
onChange={(event) => settings.setTorrentDhtListenAddr6(event.target.value)} onError={showTorrentNetworkInputError}
placeholder="2001:db8::2" />
className="app-control settings-network-input"
aria-label={t($ => $.settings.network.torrentDhtListenAddr6)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentLpdInterface)}</span>
<small>{t($ => $.settings.network.torrentLpdInterfaceDescription)}</small>
</div>
<input
type="text"
value={settings.torrentLpdInterface}
onChange={(event) => settings.setTorrentLpdInterface(event.target.value)}
placeholder="en0"
className="app-control settings-network-input"
aria-label={t($ => $.settings.network.torrentLpdInterface)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentPeerIdPrefix)}</span>
<small>{t($ => $.settings.network.torrentPeerIdPrefixDescription)}</small>
</div>
<input
type="text"
value={settings.torrentPeerIdPrefix}
onChange={(event) => settings.setTorrentPeerIdPrefix(event.target.value)}
placeholder="-FL-1-3-1-"
maxLength={20}
className="app-control settings-network-input"
aria-label={t($ => $.settings.network.torrentPeerIdPrefix)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentPeerAgent)}</span>
<small>{t($ => $.settings.network.torrentPeerAgentDescription)}</small>
</div>
<input
type="text"
value={settings.torrentPeerAgent}
onChange={(event) => settings.setTorrentPeerAgent(event.target.value)}
placeholder="Firelink/1.3.1"
maxLength={128}
className="app-control settings-network-input"
aria-label={t($ => $.settings.network.torrentPeerAgent)}
/>
</div>
<div className="mac-settings-row settings-network-row"> <div className="mac-settings-row settings-network-row">
<div className="settings-row-label"> <div className="settings-row-label">
<span>{t($ => $.settings.network.torrentSeparateSeedSlots)}</span> <span>{t($ => $.settings.network.torrentSeparateSeedSlots)}</span>
@@ -1639,20 +1700,16 @@ runEngineChecks(false);
aria-label={t($ => $.settings.network.torrentMaxOpenFiles)} aria-label={t($ => $.settings.network.torrentMaxOpenFiles)}
/> />
</div> </div>
<div className="mac-settings-row settings-network-row"> <TorrentNetworkTextInput
<div className="settings-row-label"> field="aria2DiskCache"
<span>{t($ => $.settings.network.aria2DiskCache)}</span> value={settings.aria2DiskCache}
<small>{t($ => $.settings.network.aria2DiskCacheDescription)}</small> label={t($ => $.settings.network.aria2DiskCache)}
</div> description={t($ => $.settings.network.aria2DiskCacheDescription)}
<input placeholder="16M"
type="text" onCommit={settings.setAria2DiskCache}
value={settings.aria2DiskCache} onError={showTorrentNetworkInputError}
onChange={(event) => settings.setAria2DiskCache(event.target.value)} className="app-control settings-network-input text-center"
placeholder="16M" />
className="app-control settings-network-input text-center"
aria-label={t($ => $.settings.network.aria2DiskCache)}
/>
</div>
<div className="mac-settings-row settings-network-row"> <div className="mac-settings-row settings-network-row">
<div className="settings-row-label"> <div className="settings-row-label">
<span>{t($ => $.settings.network.torrentOverallUploadLimit)}</span> <span>{t($ => $.settings.network.torrentOverallUploadLimit)}</span>
+1
View File
@@ -1009,6 +1009,7 @@ const common = {
aria2DiskCache: 'Aria2 disk cache', aria2DiskCache: 'Aria2 disk cache',
aria2DiskCacheDescription: 'Cache size for Aria2, using 0 or a positive value such as 16M. Accepts K/M values up to 1024M and applies after restart.', aria2DiskCacheDescription: 'Cache size for Aria2, using 0 or a positive value such as 16M. Accepts K/M values up to 1024M and applies after restart.',
torrentMaxOpenFilesUpdateFailed: 'Could not apply the Torrent open-file limit: {{detail}}', torrentMaxOpenFilesUpdateFailed: 'Could not apply the Torrent open-file limit: {{detail}}',
torrentNetworkInputInvalid: 'Could not apply this Torrent network setting: {{detail}}',
torrentOverallUploadLimit: 'Overall Aria2 upload limit', torrentOverallUploadLimit: 'Overall Aria2 upload limit',
torrentOverallUploadLimitDescription: 'Caps combined Aria2 upload traffic, primarily active Torrent seeding in Firelink. Leave blank for unlimited; the value is applied live and restored when Firelink restarts.', torrentOverallUploadLimitDescription: 'Caps combined Aria2 upload traffic, primarily active Torrent seeding in Firelink. Leave blank for unlimited; the value is applied live and restored when Firelink restarts.',
torrentOverallUploadLimitInvalid: 'Enter a valid upload limit, such as 512K or 2M.', torrentOverallUploadLimitInvalid: 'Enter a valid upload limit, such as 512K or 2M.',
+1
View File
@@ -1009,6 +1009,7 @@ const fa = {
aria2DiskCache: 'کش دیسک Aria2', aria2DiskCache: 'کش دیسک Aria2',
aria2DiskCacheDescription: 'اندازهٔ کش Aria2؛ صفر یا مقداری مانند 16M وارد کنید. مقادیر K/M تا 1024M پذیرفته می‌شوند و پس از راه‌اندازی مجدد اعمال می‌شوند.', aria2DiskCacheDescription: 'اندازهٔ کش Aria2؛ صفر یا مقداری مانند 16M وارد کنید. مقادیر K/M تا 1024M پذیرفته می‌شوند و پس از راه‌اندازی مجدد اعمال می‌شوند.',
torrentMaxOpenFilesUpdateFailed: 'اعمال محدودیت فایل‌های باز تورنت ممکن نشد: {{detail}}', torrentMaxOpenFilesUpdateFailed: 'اعمال محدودیت فایل‌های باز تورنت ممکن نشد: {{detail}}',
torrentNetworkInputInvalid: 'اعمال این تنظیم شبکه تورنت ممکن نیست: {{detail}}',
torrentOverallUploadLimit: 'محدودیت کلی آپلود Aria2', torrentOverallUploadLimit: 'محدودیت کلی آپلود Aria2',
torrentOverallUploadLimitDescription: 'سرعت کلی آپلود Aria2 را محدود می‌کند؛ در Firelink این مقدار عمدتاً برای سیدینگ تورنت‌هاست. برای نامحدود بودن خالی بگذارید؛ مقدار جدید زنده اعمال می‌شود و پس از راه‌اندازی مجدد Firelink برمی‌گردد.', torrentOverallUploadLimitDescription: 'سرعت کلی آپلود Aria2 را محدود می‌کند؛ در Firelink این مقدار عمدتاً برای سیدینگ تورنت‌هاست. برای نامحدود بودن خالی بگذارید؛ مقدار جدید زنده اعمال می‌شود و پس از راه‌اندازی مجدد Firelink برمی‌گردد.',
torrentOverallUploadLimitInvalid: 'یک محدودیت معتبر مثل 512K یا 2M برای آپلود وارد کنید.', torrentOverallUploadLimitInvalid: 'یک محدودیت معتبر مثل 512K یا 2M برای آپلود وارد کنید.',
+1
View File
@@ -1009,6 +1009,7 @@ const he = {
aria2DiskCache: 'מטמון דיסק של Aria2', aria2DiskCache: 'מטמון דיסק של Aria2',
aria2DiskCacheDescription: 'גודל מטמון Aria2: 0 או ערך כמו 16M. ערכי K/M עד 1024M מתקבלים; חל לאחר הפעלה מחדש.', aria2DiskCacheDescription: 'גודל מטמון Aria2: 0 או ערך כמו 16M. ערכי K/M עד 1024M מתקבלים; חל לאחר הפעלה מחדש.',
torrentMaxOpenFilesUpdateFailed: 'לא ניתן להחיל את מגבלת הקבצים הפתוחים של Torrent: {{detail}}', torrentMaxOpenFilesUpdateFailed: 'לא ניתן להחיל את מגבלת הקבצים הפתוחים של Torrent: {{detail}}',
torrentNetworkInputInvalid: 'לא ניתן להחיל את הגדרת רשת הטורנט: {{detail}}',
torrentOverallUploadLimit: 'מגבלת העלאה כוללת של Aria2', torrentOverallUploadLimit: 'מגבלת העלאה כוללת של Aria2',
torrentOverallUploadLimitDescription: 'מגבילה את מהירות ההעלאה המשולבת של Aria2, בעיקר עבור העלאת טורנטים פעילים ב-Firelink. השאר ריק ללא הגבלה; הערך מוחל מיד ומשוחזר לאחר הפעלה מחדש של Firelink.', torrentOverallUploadLimitDescription: 'מגבילה את מהירות ההעלאה המשולבת של Aria2, בעיקר עבור העלאת טורנטים פעילים ב-Firelink. השאר ריק ללא הגבלה; הערך מוחל מיד ומשוחזר לאחר הפעלה מחדש של Firelink.',
torrentOverallUploadLimitInvalid: 'הזן מגבלת העלאה תקפה, למשל 512K או 2M.', torrentOverallUploadLimitInvalid: 'הזן מגבלת העלאה תקפה, למשל 512K או 2M.',
+1
View File
@@ -1009,6 +1009,7 @@ const ru = {
aria2DiskCache: 'Дисковый кэш Aria2', aria2DiskCache: 'Дисковый кэш Aria2',
aria2DiskCacheDescription: 'Размер кэша Aria2: 0 или значение вроде 16M. Допустимы K/M до 1024M; применяется после перезапуска.', aria2DiskCacheDescription: 'Размер кэша Aria2: 0 или значение вроде 16M. Допустимы K/M до 1024M; применяется после перезапуска.',
torrentMaxOpenFilesUpdateFailed: 'Не удалось применить лимит открытых файлов Torrent: {{detail}}', torrentMaxOpenFilesUpdateFailed: 'Не удалось применить лимит открытых файлов Torrent: {{detail}}',
torrentNetworkInputInvalid: 'Не удалось применить сетевую настройку Torrent: {{detail}}',
torrentOverallUploadLimit: 'Общий лимит отдачи Aria2', torrentOverallUploadLimit: 'Общий лимит отдачи Aria2',
torrentOverallUploadLimitDescription: 'Ограничивает суммарную скорость отдачи Aria2; в Firelink это в основном раздача активных торрентов. Оставьте поле пустым для снятия ограничения; значение применяется сразу и восстанавливается после перезапуска Firelink.', torrentOverallUploadLimitDescription: 'Ограничивает суммарную скорость отдачи Aria2; в Firelink это в основном раздача активных торрентов. Оставьте поле пустым для снятия ограничения; значение применяется сразу и восстанавливается после перезапуска Firelink.',
torrentOverallUploadLimitInvalid: 'Введите корректный лимит отдачи, например 512K или 2M.', torrentOverallUploadLimitInvalid: 'Введите корректный лимит отдачи, например 512K или 2M.',
+1
View File
@@ -1009,6 +1009,7 @@ const uk = {
aria2DiskCache: 'Дисковий кеш Aria2', aria2DiskCache: 'Дисковий кеш Aria2',
aria2DiskCacheDescription: 'Розмір кешу Aria2: 0 або значення на кшталт 16M. Допустимі K/M до 1024M; застосовується після перезапуску.', aria2DiskCacheDescription: 'Розмір кешу Aria2: 0 або значення на кшталт 16M. Допустимі K/M до 1024M; застосовується після перезапуску.',
torrentMaxOpenFilesUpdateFailed: 'Не вдалося застосувати обмеження відкритих файлів Torrent: {{detail}}', torrentMaxOpenFilesUpdateFailed: 'Не вдалося застосувати обмеження відкритих файлів Torrent: {{detail}}',
torrentNetworkInputInvalid: 'Не вдалося застосувати мережеве налаштування Torrent: {{detail}}',
torrentOverallUploadLimit: 'Загальне обмеження віддачі Aria2', torrentOverallUploadLimit: 'Загальне обмеження віддачі Aria2',
torrentOverallUploadLimitDescription: 'Обмежує сумарну швидкість віддачі Aria2; у Firelink це переважно роздача активних торрентів. Залиште поле порожнім без обмеження; значення застосовується одразу й відновлюється після перезапуску Firelink.', torrentOverallUploadLimitDescription: 'Обмежує сумарну швидкість віддачі Aria2; у Firelink це переважно роздача активних торрентів. Залиште поле порожнім без обмеження; значення застосовується одразу й відновлюється після перезапуску Firelink.',
torrentOverallUploadLimitInvalid: 'Введіть коректне обмеження віддачі, наприклад 512K або 2M.', torrentOverallUploadLimitInvalid: 'Введіть коректне обмеження віддачі, наприклад 512K або 2M.',
+1
View File
@@ -1009,6 +1009,7 @@ const zhCN = {
aria2DiskCache: 'Aria2 磁盘缓存', aria2DiskCache: 'Aria2 磁盘缓存',
aria2DiskCacheDescription: 'Aria2 缓存大小:0 或类似 16M 的值。接受最大 1024M 的 K/M 值,重启后生效。', aria2DiskCacheDescription: 'Aria2 缓存大小:0 或类似 16M 的值。接受最大 1024M 的 K/M 值,重启后生效。',
torrentMaxOpenFilesUpdateFailed: '无法应用 Torrent 打开文件数限制:{{detail}}', torrentMaxOpenFilesUpdateFailed: '无法应用 Torrent 打开文件数限制:{{detail}}',
torrentNetworkInputInvalid: '无法应用此 Torrent 网络设置:{{detail}}',
torrentOverallUploadLimit: 'Aria2 总上传限制', torrentOverallUploadLimit: 'Aria2 总上传限制',
torrentOverallUploadLimitDescription: '限制 Aria2 的总上传速度,在 Firelink 中主要用于活动 Torrent 做种。留空表示不限速;新值会立即应用,并在 Firelink 重启后恢复。', torrentOverallUploadLimitDescription: '限制 Aria2 的总上传速度,在 Firelink 中主要用于活动 Torrent 做种。留空表示不限速;新值会立即应用,并在 Firelink 重启后恢复。',
torrentOverallUploadLimitInvalid: '请输入有效的上传限制,例如 512K 或 2M。', torrentOverallUploadLimitInvalid: '请输入有效的上传限制,例如 512K 或 2M。',
+4
View File
@@ -144,6 +144,10 @@ type CommandMap = {
get_supported_media_domains: { args: undefined; result: string[] }; get_supported_media_domains: { args: undefined; result: string[] };
db_save_settings: { args: { data: string }; result: void }; db_save_settings: { args: { data: string }; result: void };
db_load_settings: { args: undefined; result: string | null }; db_load_settings: { args: undefined; result: string | null };
canonicalize_torrent_network_setting: {
args: { field: string; value: string };
result: string;
};
db_get_all_downloads: { args: undefined; result: string[] }; db_get_all_downloads: { args: undefined; result: string[] };
db_replace_downloads: { args: { data: string }; result: void }; db_replace_downloads: { args: { data: string }; result: void };
db_commit_download_state: { db_commit_download_state: {
+27
View File
@@ -133,6 +133,33 @@ describe('Torrent peer discovery preferences', () => {
}); });
}); });
it('clears an IPv6 bind address when IPv6 transport is disabled', () => {
useSettingsStore.setState({
torrentIpv6Enabled: true,
torrentBindAddress: '2001:db8::10'
});
useSettingsStore.getState().setTorrentIpv6Enabled(false);
expect(useSettingsStore.getState()).toMatchObject({
torrentIpv6Enabled: false,
torrentBindAddress: ''
});
});
it('rejects an IPv6 bind address entered after IPv6 transport is disabled', () => {
useSettingsStore.setState({
torrentIpv6Enabled: false,
torrentBindAddress: ''
});
expect(useSettingsStore.getState().setTorrentBindAddress('2001:db8::10')).toBe(false);
expect(useSettingsStore.getState().torrentBindAddress).toBe('');
expect(useSettingsStore.getState().setTorrentBindAddress('192.0.2.10')).toBe(true);
expect(useSettingsStore.getState().torrentBindAddress).toBe('192.0.2.10');
});
it('matches Aria2 defaults and persists explicit changes', async () => { it('matches Aria2 defaults and persists explicit changes', async () => {
expect(useSettingsStore.getState().torrentEnableDht).toBe(true); expect(useSettingsStore.getState().torrentEnableDht).toBe(true);
expect(useSettingsStore.getState().torrentEnableDht6).toBe(false); expect(useSettingsStore.getState().torrentEnableDht6).toBe(false);
+21 -3
View File
@@ -364,7 +364,7 @@ export interface SettingsState {
setTorrentLpdInterface: (value: string) => void; setTorrentLpdInterface: (value: string) => void;
setTorrentPeerIdPrefix: (value: string) => void; setTorrentPeerIdPrefix: (value: string) => void;
setTorrentPeerAgent: (value: string) => void; setTorrentPeerAgent: (value: string) => void;
setTorrentBindAddress: (value: string) => void; setTorrentBindAddress: (value: string) => boolean;
setAria2DiskCache: (value: string) => void; setAria2DiskCache: (value: string) => void;
setCustomUserAgent: (userAgent: string) => void; setCustomUserAgent: (userAgent: string) => void;
setAskWhereToSaveEachFile: (ask: boolean) => void; setAskWhereToSaveEachFile: (ask: boolean) => void;
@@ -612,7 +612,17 @@ export const useSettingsStore = create<SettingsState>()(
setTorrentLpdInterface: (torrentLpdInterface) => set({ torrentLpdInterface }), setTorrentLpdInterface: (torrentLpdInterface) => set({ torrentLpdInterface }),
setTorrentPeerIdPrefix: (torrentPeerIdPrefix) => set({ torrentPeerIdPrefix }), setTorrentPeerIdPrefix: (torrentPeerIdPrefix) => set({ torrentPeerIdPrefix }),
setTorrentPeerAgent: (torrentPeerAgent) => set({ torrentPeerAgent }), setTorrentPeerAgent: (torrentPeerAgent) => set({ torrentPeerAgent }),
setTorrentBindAddress: (torrentBindAddress) => set({ torrentBindAddress }), setTorrentBindAddress: (torrentBindAddress) => {
let accepted = true;
set(state => {
if (!state.torrentIpv6Enabled && torrentBindAddress.includes(':')) {
accepted = false;
return state;
}
return { torrentBindAddress };
});
return accepted;
},
setAria2DiskCache: (aria2DiskCache) => set({ aria2DiskCache }), setAria2DiskCache: (aria2DiskCache) => set({ aria2DiskCache }),
setTorrentMaxOpenFiles: (value) => { setTorrentMaxOpenFiles: (value) => {
const normalized = normalizeTorrentMaxOpenFiles(value); const normalized = normalizeTorrentMaxOpenFiles(value);
@@ -643,7 +653,15 @@ export const useSettingsStore = create<SettingsState>()(
? value ? value
: DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS : DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
}), }),
setTorrentIpv6Enabled: (torrentIpv6Enabled) => set({ torrentIpv6Enabled }), setTorrentIpv6Enabled: (torrentIpv6Enabled) => set(state => ({
torrentIpv6Enabled,
// An IPv6 bind address is invalid once IPv6 transport is disabled.
// Clear it as part of the same state transition so the next durable
// settings save cannot fail on a cross-field contradiction.
...(torrentIpv6Enabled || !state.torrentBindAddress.includes(':')
? {}
: { torrentBindAddress: '' })
})),
setCustomUserAgent: (customUserAgent) => set({ customUserAgent }), setCustomUserAgent: (customUserAgent) => set({ customUserAgent }),
setAskWhereToSaveEachFile: (askWhereToSaveEachFile) => set({ askWhereToSaveEachFile }), setAskWhereToSaveEachFile: (askWhereToSaveEachFile) => set({ askWhereToSaveEachFile }),
setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => { setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => {
+2 -1
View File
@@ -81,7 +81,8 @@ export const MIN_TORRENT_MAX_OPEN_FILES = 1;
export const MAX_TORRENT_MAX_OPEN_FILES = 4096; export const MAX_TORRENT_MAX_OPEN_FILES = 4096;
export const DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT = 10; export const DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT = 10;
export const MIN_TORRENT_DHT_MESSAGE_TIMEOUT = 1; export const MIN_TORRENT_DHT_MESSAGE_TIMEOUT = 1;
export const MAX_TORRENT_DHT_MESSAGE_TIMEOUT = 600; // Aria2 1.37.0 accepts DHT message timeouts only from 1 through 60 seconds.
export const MAX_TORRENT_DHT_MESSAGE_TIMEOUT = 60;
export const DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS = 2; export const DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS = 2;
export const MIN_TORRENT_MAX_CONCURRENT_SEEDS = 1; export const MIN_TORRENT_MAX_CONCURRENT_SEEDS = 1;
export const MAX_TORRENT_MAX_CONCURRENT_SEEDS = 64; export const MAX_TORRENT_MAX_CONCURRENT_SEEDS = 64;
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { shouldApplyTorrentNetworkInputResult } from './torrentNetworkInput';
describe('Torrent network input request fencing', () => {
it('rejects a response from an older request', () => {
expect(shouldApplyTorrentNetworkInputResult(1, 2, 4, 4)).toBe(false);
});
it('rejects a response after the user edits the draft', () => {
expect(shouldApplyTorrentNetworkInputResult(2, 2, 3, 4)).toBe(false);
});
it('accepts only the current request for the current draft', () => {
expect(shouldApplyTorrentNetworkInputResult(3, 3, 5, 5)).toBe(true);
});
});
+6
View File
@@ -0,0 +1,6 @@
export const shouldApplyTorrentNetworkInputResult = (
requestId: number,
currentRequestId: number,
editRequestId: number,
currentEditId: number
): boolean => requestId === currentRequestId && editRequestId === currentEditId;