feat(torrents): harden network identity settings

This commit is contained in:
NimBold
2026-08-02 22:39:05 +03:30
parent d171f736c5
commit 7da67c15b5
15 changed files with 1118 additions and 23 deletions
+18
View File
@@ -499,6 +499,24 @@ pub struct PersistedSettings {
pub torrent_enable_lpd: bool,
#[serde(default = "default_torrent_max_open_files")]
pub torrent_max_open_files: u32,
#[serde(default)]
pub torrent_listen_port: String,
#[serde(default)]
pub torrent_dht_listen_port: String,
#[serde(default)]
pub torrent_external_ip: String,
#[serde(default)]
pub torrent_dht_entry_point: String,
#[serde(default)]
pub torrent_dht_entry_point6: String,
#[serde(default)]
pub torrent_dht_listen_addr6: String,
#[serde(default)]
pub torrent_lpd_interface: String,
#[serde(default)]
pub torrent_peer_id_prefix: String,
#[serde(default)]
pub torrent_peer_agent: String,
pub custom_user_agent: String,
pub ask_where_to_save_each_file: bool,
pub remember_last_used_download_directory: bool,
+189 -8
View File
@@ -6626,6 +6626,52 @@ fn apply_aria2_torrent_peer_discovery_options(
.arg(format!("--bt-enable-lpd={enable_lpd}"));
}
fn apply_aria2_torrent_network_options(
command: &mut std::process::Command,
listen_port: &str,
dht_listen_port: &str,
external_ip: &str,
dht_entry_point: &str,
dht_entry_point6: &str,
dht_listen_addr6: &str,
lpd_interface: &str,
) {
for (option, value) in [
("--listen-port", listen_port),
("--dht-listen-port", dht_listen_port),
("--bt-external-ip", external_ip),
("--dht-entry-point", dht_entry_point),
("--dht-entry-point6", dht_entry_point6),
("--dht-listen-addr6", dht_listen_addr6),
("--bt-lpd-interface", lpd_interface),
] {
let value = value.trim();
if !value.is_empty() {
command.arg(format!("{option}={value}"));
}
}
}
fn apply_aria2_torrent_peer_identity_options(
command: &mut std::process::Command,
peer_id_prefix: &str,
peer_agent: &str,
) {
for (option, value) in [
("--peer-id-prefix", peer_id_prefix),
("--peer-agent", peer_agent),
] {
let value = value.trim();
if !value.is_empty() {
command.arg(format!("{option}={value}"));
}
}
}
fn aria2_rpc_port_is_occupied(port: u16) -> bool {
std::net::TcpListener::bind(("127.0.0.1", port)).is_err()
}
fn apply_aria2_torrent_global_options(
command: &mut std::process::Command,
max_open_files: u32,
@@ -7265,6 +7311,7 @@ fn db_save_settings(
let sanitized = crate::db::strip_pairing_token_from_settings(&merged)?;
crate::db::preserve_legacy_pairing_token(existing.as_deref(), &sanitized)?
};
let merged = crate::settings::canonicalize_torrent_network_settings(&merged)?;
crate::db::save_settings(&connection, &merged)?;
let decoded = crate::settings::decode_stored_settings(&serde_json::Value::String(merged))?;
let prevent_system_sleep = decoded.prevents_sleep_while_downloading;
@@ -7286,10 +7333,14 @@ fn db_load_settings(state: tauri::State<'_, crate::db::DbState>) -> Result<Optio
let connection = state.lock()?;
let settings = crate::db::load_settings(&connection)?;
if state.is_portable() {
return Ok(settings);
return settings
.map(|data| crate::settings::canonicalize_torrent_network_settings(&data))
.transpose();
}
settings
.map(|data| crate::db::strip_pairing_token_from_settings(&data))
.transpose()?
.map(|data| crate::settings::canonicalize_torrent_network_settings(&data))
.transpose()
}
@@ -7745,7 +7796,10 @@ mod tests {
metadata_headers, metadata_response_error,
normalize_speed_limit_for_aria2,
apply_aria2_torrent_global_options,
apply_aria2_torrent_network_options,
apply_aria2_torrent_peer_identity_options,
apply_aria2_torrent_peer_discovery_options,
aria2_rpc_port_is_occupied,
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line,
redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value,
has_resumable_download_assets, is_media_artifact_name,
@@ -7821,6 +7875,74 @@ mod tests {
);
}
#[test]
fn aria2_torrent_network_options_are_explicit_and_omit_defaults() {
let mut command = std::process::Command::new("aria2c");
apply_aria2_torrent_network_options(
&mut command,
"6881-6999",
"6881",
"203.0.113.7",
"router.example:6881",
"[2001:db8::1]:6881",
"2001:db8::2",
"en0",
);
assert_eq!(
command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>(),
vec![
"--listen-port=6881-6999",
"--dht-listen-port=6881",
"--bt-external-ip=203.0.113.7",
"--dht-entry-point=router.example:6881",
"--dht-entry-point6=[2001:db8::1]:6881",
"--dht-listen-addr6=2001:db8::2",
"--bt-lpd-interface=en0",
]
);
let mut defaults = std::process::Command::new("aria2c");
apply_aria2_torrent_network_options(&mut defaults, "", "", "", "", "", "", "");
assert!(defaults.get_args().next().is_none());
}
#[test]
fn aria2_torrent_peer_identity_options_are_explicit_and_omit_defaults() {
let mut command = std::process::Command::new("aria2c");
apply_aria2_torrent_peer_identity_options(
&mut command,
"-FL-1-3-1-",
"Firelink/1.3.1",
);
let args = command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert_eq!(
args,
vec![
"--peer-id-prefix=-FL-1-3-1-",
"--peer-agent=Firelink/1.3.1",
]
);
let mut defaults = std::process::Command::new("aria2c");
apply_aria2_torrent_peer_identity_options(&mut defaults, "", "");
assert!(defaults.get_args().next().is_none());
}
#[test]
fn aria2_rpc_port_occupancy_is_detected_without_claiming_free_ports() {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
assert!(aria2_rpc_port_is_occupied(port));
drop(listener);
assert!(!aria2_rpc_port_is_occupied(port));
}
#[test]
fn retained_torrent_metadata_survives_unrelated_persisted_field_corruption() {
let record = json!({
@@ -10487,6 +10609,8 @@ pub fn run() {
queue::DEFAULT_TORRENT_MAX_OPEN_FILES
}
};
let torrent_startup_settings =
crate::settings::torrent_startup_settings(persisted_settings.as_ref());
let aria2_secret_clone = aria2_secret.clone();
let app_handle_bg = app.handle().clone();
@@ -10496,7 +10620,20 @@ pub fn run() {
match resolve_bundled_binary_path(&app_handle_bg, "aria2c") {
Ok(binary_path) => {
let mut success = false;
let mut attempted_rpc_port = false;
let mut startup_failure = None;
for attempt_port in 6800..6900 {
if queue::torrent_port_spec_contains(
if torrent_startup_settings.listen_port.is_empty() {
queue::DEFAULT_TORRENT_LISTEN_PORT_SPEC
} else {
&torrent_startup_settings.listen_port
},
attempt_port,
) {
continue;
}
attempted_rpc_port = true;
let mut cmd = std::process::Command::new(&binary_path);
crate::platform::hide_child_console(&mut cmd);
crate::engines::apply_aria2_environment(&mut cmd, &binary_path);
@@ -10530,6 +10667,21 @@ pub fn run() {
torrent_peer_discovery.2,
torrent_peer_discovery.3,
);
apply_aria2_torrent_network_options(
&mut cmd,
&torrent_startup_settings.listen_port,
&torrent_startup_settings.dht_listen_port,
&torrent_startup_settings.external_ip,
&torrent_startup_settings.dht_entry_point,
&torrent_startup_settings.dht_entry_point6,
&torrent_startup_settings.dht_listen_addr6,
&torrent_startup_settings.lpd_interface,
);
apply_aria2_torrent_peer_identity_options(
&mut cmd,
&torrent_startup_settings.peer_id_prefix,
&torrent_startup_settings.peer_agent,
);
if let Some(limit) = normalize_speed_limit_for_aria2(&global_speed_limit) {
cmd.arg(format!("--max-overall-download-limit={}", limit));
@@ -10542,9 +10694,34 @@ pub fn run() {
Ok(mut child) => {
// Give it a moment to fail if port is in use
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
if let Ok(Some(_)) = child.try_wait() {
// Process exited, likely port collision, try next
continue;
if let Ok(Some(status)) = child.try_wait() {
use std::io::Read;
let mut stderr = String::new();
if let Some(pipe) = child.stderr.take() {
// A failed child is not trusted to produce a bounded
// diagnostic. Keep startup error handling bounded so a
// pathological stderr stream cannot stall or exhaust
// the launcher while it is deciding whether to retry.
let _ = pipe.take(4096).read_to_string(&mut stderr);
}
let stderr = stderr.trim().to_string();
if aria2_rpc_port_is_occupied(attempt_port) {
// The RPC port is occupied by another process; retry
// with the next candidate. A configured Torrent port
// cannot cause this branch because overlapping RPC
// candidates are skipped above.
continue;
}
startup_failure = Some(if stderr.is_empty() {
format!(
"aria2 exited before startup on RPC port {attempt_port} with status {status}"
)
} else {
format!(
"aria2 exited before startup on RPC port {attempt_port}: {stderr}"
)
});
break;
}
log::info!("aria2c spawned successfully on port {}", attempt_port);
@@ -10605,16 +10782,20 @@ pub fn run() {
break;
}
Err(e) => {
log::error!("Failed to spawn aria2c: {}", e);
let guard = app_handle_bg.state::<Aria2DaemonGuard>();
*guard.startup_error.lock().unwrap() = Some(format!("Failed to spawn aria2c: {e}"));
startup_failure = Some(format!("Failed to spawn aria2c: {e}"));
break;
}
}
}
if !success {
let guard = app_handle_bg.state::<Aria2DaemonGuard>();
*guard.startup_error.lock().unwrap() = Some("Failed to find open port for aria2c".to_string());
*guard.startup_error.lock().unwrap() = Some(startup_failure.unwrap_or_else(|| {
if attempted_rpc_port {
"Failed to find open RPC port for aria2c".to_string()
} else {
"No Aria2 RPC port is available outside the configured Torrent TCP listen ports".to_string()
}
}));
}
}
Err(e) => {
+253
View File
@@ -27,6 +27,11 @@ pub const MAX_TORRENT_TRACKER_INTERVAL: u32 = 604_800;
pub const DEFAULT_TORRENT_MAX_OPEN_FILES: u32 = 100;
pub const MIN_TORRENT_MAX_OPEN_FILES: u32 = 1;
pub const MAX_TORRENT_MAX_OPEN_FILES: u32 = 4_096;
pub const MAX_TORRENT_NETWORK_VALUE_LENGTH: usize = 256;
pub const MAX_TORRENT_PEER_ID_PREFIX_BYTES: usize = 20;
pub const MAX_TORRENT_PEER_AGENT_LENGTH: usize = 128;
pub const MIN_TORRENT_LISTEN_PORT: u16 = 1024;
pub const DEFAULT_TORRENT_LISTEN_PORT_SPEC: &str = "6881-6999";
pub fn clamp_download_connections(connections: i32) -> i32 {
connections.clamp(DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MAX)
@@ -41,6 +46,184 @@ pub fn normalize_torrent_max_open_files(value: u32) -> Result<u32, String> {
Ok(value)
}
fn normalize_optional_torrent_network_value(
value: Option<&str>,
field: &str,
) -> Result<Option<String>, String> {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
if value.len() > MAX_TORRENT_NETWORK_VALUE_LENGTH
|| value.chars().any(|character| character.is_control())
{
return Err(format!("{field} is too long or contains control characters"));
}
Ok(Some(value.to_string()))
}
fn normalize_torrent_port(value: &str, field: &str) -> Result<u16, String> {
let port = value
.trim()
.parse::<u32>()
.map_err(|_| format!("{field} contains an invalid port"))?;
if !(1..=u16::MAX as u32).contains(&port) {
return Err(format!("{field} ports must be between 1 and 65535"));
}
Ok(port as u16)
}
fn normalize_torrent_listen_port(value: &str, field: &str) -> Result<u16, String> {
let port = normalize_torrent_port(value, field)?;
if port < MIN_TORRENT_LISTEN_PORT {
return Err(format!(
"{field} ports must be between {MIN_TORRENT_LISTEN_PORT} and 65535"
));
}
Ok(port)
}
pub(crate) fn normalize_torrent_port_spec(
value: Option<&str>,
field: &str,
) -> Result<Option<String>, String> {
let Some(value) = normalize_optional_torrent_network_value(value, field)? else {
return Ok(None);
};
let mut normalized = Vec::new();
for entry in value.split(',') {
let entry = entry.trim();
if entry.is_empty() {
return Err(format!("{field} contains an empty port entry"));
}
if let Some((start, end)) = entry.split_once('-') {
let start = normalize_torrent_listen_port(start, field)?;
let end = normalize_torrent_listen_port(end, field)?;
if start > end {
return Err(format!("{field} contains a reversed port range"));
}
normalized.push(format!("{start}-{end}"));
} else {
normalized.push(normalize_torrent_listen_port(entry, field)?.to_string());
}
}
Ok(Some(normalized.join(",")))
}
pub(crate) fn torrent_port_spec_contains(spec: &str, port: u16) -> bool {
spec.split(',').any(|entry| {
let entry = entry.trim();
if let Some((start, end)) = entry.split_once('-') {
matches!((start.parse::<u16>(), end.parse::<u16>()), (Ok(start), Ok(end)) if start <= port && port <= end)
} else {
entry.parse::<u16>() == Ok(port)
}
})
}
pub(crate) fn normalize_torrent_external_ip(
value: Option<&str>,
) -> Result<Option<String>, String> {
let Some(value) = normalize_optional_torrent_network_value(value, "Torrent external IP")?
else {
return Ok(None);
};
let address = value
.parse::<std::net::IpAddr>()
.map_err(|_| "Torrent external IP must be a valid IPv4 or IPv6 address".to_string())?;
Ok(Some(address.to_string()))
}
pub(crate) fn normalize_torrent_dht_entry_point(
value: Option<&str>,
ipv6: bool,
) -> Result<Option<String>, String> {
let field = if ipv6 {
"IPv6 DHT entry point"
} else {
"IPv4 DHT entry point"
};
let Some(value) = normalize_optional_torrent_network_value(value, field)? else {
return Ok(None);
};
let (host, port) = if let Some(rest) = value.strip_prefix('[') {
let (host, port) = rest
.split_once(']')
.and_then(|(host, suffix)| suffix.strip_prefix(':').map(|port| (host, port)))
.ok_or_else(|| format!("{field} must use host:port syntax"))?;
if host.parse::<std::net::Ipv6Addr>().is_err() {
return Err(format!("{field} has an invalid IPv6 host"));
}
(format!("[{host}]"), normalize_torrent_port(port, field)?)
} else {
let (host, port) = value
.rsplit_once(':')
.ok_or_else(|| format!("{field} must use host:port syntax"))?;
if host.is_empty() || host.contains(':') || host.contains(['/', '\\', '@']) {
return Err(format!("{field} has an invalid host"));
}
if url::Host::parse(host).is_err() {
return Err(format!("{field} has an invalid host"));
}
(host.to_ascii_lowercase(), normalize_torrent_port(port, field)?)
};
if ipv6 && !host.starts_with('[') {
return Err(format!("{field} must use an IPv6 host"));
}
if !ipv6 && host.starts_with('[') {
return Err(format!("{field} must use an IPv4 or hostname host"));
}
Ok(Some(format!("{host}:{port}")))
}
pub(crate) fn normalize_torrent_dht_listen_addr6(
value: Option<&str>,
) -> Result<Option<String>, String> {
let Some(value) = normalize_optional_torrent_network_value(value, "IPv6 DHT listen address")?
else {
return Ok(None);
};
let address = value
.parse::<std::net::Ipv6Addr>()
.map_err(|_| "IPv6 DHT listen address must be a valid IPv6 address".to_string())?;
Ok(Some(address.to_string()))
}
pub(crate) fn normalize_torrent_lpd_interface(
value: Option<&str>,
) -> Result<Option<String>, String> {
normalize_optional_torrent_network_value(value, "Torrent LPD interface")
}
pub(crate) fn normalize_torrent_peer_id_prefix(
value: Option<&str>,
) -> Result<Option<String>, String> {
let Some(value) = normalize_optional_torrent_network_value(value, "Torrent peer ID prefix")?
else {
return Ok(None);
};
if !value.is_ascii() || value.len() > MAX_TORRENT_PEER_ID_PREFIX_BYTES {
return Err(format!(
"Torrent peer ID prefix must be printable ASCII and at most {MAX_TORRENT_PEER_ID_PREFIX_BYTES} bytes"
));
}
Ok(Some(value))
}
pub(crate) fn normalize_torrent_peer_agent(
value: Option<&str>,
) -> Result<Option<String>, String> {
let Some(value) = normalize_optional_torrent_network_value(value, "Torrent peer agent")?
else {
return Ok(None);
};
if value.len() > MAX_TORRENT_PEER_AGENT_LENGTH {
return Err(format!(
"Torrent peer agent must be at most {MAX_TORRENT_PEER_AGENT_LENGTH} bytes"
));
}
Ok(Some(value))
}
fn reorder_selected_queue_tasks(
queue_tasks: &[QueuedTask],
ids: &[String],
@@ -4730,6 +4913,76 @@ mod tests {
assert_eq!(payload.torrent_tracker_interval, Some(33));
}
#[test]
fn torrent_network_settings_are_normalized_and_bounded() {
assert_eq!(
normalize_torrent_port_spec(Some(" 6881-6999, 7000 "), "TCP listen ports").unwrap(),
Some("6881-6999,7000".to_string())
);
assert_eq!(
normalize_torrent_external_ip(Some(" 2001:db8::1 ")).unwrap(),
Some("2001:db8::1".to_string())
);
assert_eq!(
normalize_torrent_dht_entry_point(Some("Bootstrap.Example:6881"), false).unwrap(),
Some("bootstrap.example:6881".to_string())
);
assert_eq!(
normalize_torrent_dht_entry_point(Some("[2001:db8::1]:6881"), true).unwrap(),
Some("[2001:db8::1]:6881".to_string())
);
assert_eq!(
normalize_torrent_dht_listen_addr6(Some("2001:db8::2")).unwrap(),
Some("2001:db8::2".to_string())
);
assert_eq!(
normalize_torrent_lpd_interface(Some("en0")).unwrap(),
Some("en0".to_string())
);
assert_eq!(
normalize_torrent_peer_id_prefix(Some("-FL-1-3-1-")).unwrap(),
Some("-FL-1-3-1-".to_string())
);
assert_eq!(
normalize_torrent_peer_agent(Some("Firelink/1.3.1")).unwrap(),
Some("Firelink/1.3.1".to_string())
);
assert!(torrent_port_spec_contains("6800-6802,6881", 6801));
assert!(!torrent_port_spec_contains("6800-6802,6881", 6803));
assert_eq!(normalize_torrent_port_spec(Some(" "), "TCP listen ports").unwrap(), None);
assert_eq!(
normalize_torrent_port_spec(Some("1024"), "TCP listen ports").unwrap(),
Some("1024".to_string())
);
}
#[test]
fn torrent_network_settings_reject_unsafe_or_malformed_values() {
for value in ["0", "1023", "65536", "7000-6999", "6881,", "6881-"] {
assert!(
normalize_torrent_port_spec(Some(value), "TCP listen ports").is_err(),
"{value}"
);
}
assert!(normalize_torrent_external_ip(Some("example.com")).is_err());
assert!(normalize_torrent_dht_entry_point(Some("example.com"), false).is_err());
assert!(normalize_torrent_dht_entry_point(Some("[2001:db8::1]:6881"), false).is_err());
assert!(normalize_torrent_dht_entry_point(Some("2001:db8::1:6881"), true).is_err());
assert!(normalize_torrent_dht_listen_addr6(Some("127.0.0.1")).is_err());
assert!(normalize_torrent_lpd_interface(Some("en0\n--bad")).is_err());
assert!(normalize_torrent_peer_id_prefix(Some("é")).is_err());
assert!(normalize_torrent_peer_id_prefix(Some("123456789012345678901")).is_err());
assert!(normalize_torrent_peer_agent(Some("agent\nname")).is_err());
assert!(normalize_torrent_peer_agent(Some(&"a".repeat(MAX_TORRENT_PEER_AGENT_LENGTH + 1))).is_err());
assert!(
normalize_torrent_port_spec(
Some(&"1".repeat(MAX_TORRENT_NETWORK_VALUE_LENGTH + 1)),
"TCP listen ports"
)
.is_err()
);
}
#[test]
fn torrent_trackers_are_normalized_and_deduplicated() {
assert_eq!(
+311 -2
View File
@@ -7,6 +7,87 @@ use serde_json::{Map, Value};
use std::collections::HashMap;
use tauri::{AppHandle, Manager};
#[derive(Clone, Debug, Default)]
pub struct TorrentStartupSettings {
pub listen_port: String,
pub dht_listen_port: String,
pub external_ip: String,
pub dht_entry_point: String,
pub dht_entry_point6: String,
pub dht_listen_addr6: String,
pub lpd_interface: String,
pub peer_id_prefix: String,
pub peer_agent: String,
}
fn normalize_torrent_startup_value(
field: &str,
value: &str,
normalize: impl Fn(Option<&str>) -> Result<Option<String>, String>,
) -> String {
match normalize(Some(value)) {
Ok(Some(value)) => value,
Ok(None) => String::new(),
Err(error) => {
log::error!("invalid persisted {field}; using Aria2 default: {error}");
String::new()
}
}
}
pub fn torrent_startup_settings(settings: Option<&PersistedSettings>) -> TorrentStartupSettings {
let Some(settings) = settings else {
return TorrentStartupSettings::default();
};
TorrentStartupSettings {
listen_port: normalize_torrent_startup_value(
"TCP listen ports",
&settings.torrent_listen_port,
|value| crate::queue::normalize_torrent_port_spec(value, "TCP listen ports"),
),
dht_listen_port: normalize_torrent_startup_value(
"UDP listen ports",
&settings.torrent_dht_listen_port,
|value| crate::queue::normalize_torrent_port_spec(value, "UDP listen ports"),
),
external_ip: normalize_torrent_startup_value(
"Torrent external IP",
&settings.torrent_external_ip,
crate::queue::normalize_torrent_external_ip,
),
dht_entry_point: normalize_torrent_startup_value(
"IPv4 DHT entry point",
&settings.torrent_dht_entry_point,
|value| crate::queue::normalize_torrent_dht_entry_point(value, false),
),
dht_entry_point6: normalize_torrent_startup_value(
"IPv6 DHT entry point",
&settings.torrent_dht_entry_point6,
|value| crate::queue::normalize_torrent_dht_entry_point(value, true),
),
dht_listen_addr6: normalize_torrent_startup_value(
"IPv6 DHT listen address",
&settings.torrent_dht_listen_addr6,
crate::queue::normalize_torrent_dht_listen_addr6,
),
lpd_interface: normalize_torrent_startup_value(
"Torrent LPD interface",
&settings.torrent_lpd_interface,
crate::queue::normalize_torrent_lpd_interface,
),
peer_id_prefix: normalize_torrent_startup_value(
"Torrent peer ID prefix",
&settings.torrent_peer_id_prefix,
crate::queue::normalize_torrent_peer_id_prefix,
),
peer_agent: normalize_torrent_startup_value(
"Torrent peer agent",
&settings.torrent_peer_agent,
crate::queue::normalize_torrent_peer_agent,
),
}
}
pub fn load_settings<R: tauri::Runtime>(
app_handle: &AppHandle<R>,
) -> Result<PersistedSettings, String> {
@@ -32,6 +113,42 @@ pub fn decode_stored_settings(stored: &Value) -> Result<PersistedSettings, Strin
Ok(settings)
}
fn canonicalize_torrent_network_value(
state: &mut Map<String, Value>,
key: &str,
normalize: impl Fn(Option<&str>) -> Result<Option<String>, String>,
) {
let Some(value) = state.get(key).and_then(Value::as_str) else {
return;
};
let normalized = normalize(Some(value)).ok().flatten().unwrap_or_default();
state.insert(key.to_string(), Value::String(normalized));
}
pub fn canonicalize_torrent_network_settings(stored: &str) -> Result<String, String> {
let mut document = decode_document(&Value::String(stored.to_string()))?;
let state = settings_state_mut(&mut document)?;
canonicalize_torrent_network_value(state, "torrentListenPort", |value| {
crate::queue::normalize_torrent_port_spec(value, "TCP listen ports")
});
canonicalize_torrent_network_value(state, "torrentDhtListenPort", |value| {
crate::queue::normalize_torrent_port_spec(value, "UDP listen ports")
});
canonicalize_torrent_network_value(state, "torrentExternalIp", crate::queue::normalize_torrent_external_ip);
canonicalize_torrent_network_value(state, "torrentDhtEntryPoint", |value| {
crate::queue::normalize_torrent_dht_entry_point(value, false)
});
canonicalize_torrent_network_value(state, "torrentDhtEntryPoint6", |value| {
crate::queue::normalize_torrent_dht_entry_point(value, true)
});
canonicalize_torrent_network_value(state, "torrentDhtListenAddr6", crate::queue::normalize_torrent_dht_listen_addr6);
canonicalize_torrent_network_value(state, "torrentLpdInterface", crate::queue::normalize_torrent_lpd_interface);
canonicalize_torrent_network_value(state, "torrentPeerIdPrefix", crate::queue::normalize_torrent_peer_id_prefix);
canonicalize_torrent_network_value(state, "torrentPeerAgent", crate::queue::normalize_torrent_peer_agent);
serde_json::to_string(&document)
.map_err(|error| format!("failed to encode canonical settings: {error}"))
}
pub fn update_settings_state(
app_handle: &AppHandle,
update: impl FnOnce(&mut Map<String, Value>),
@@ -205,6 +322,33 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
] {
sanitize_boolean_setting(state, key);
}
sanitize_torrent_network_string(state, "torrentListenPort", |value| {
crate::queue::normalize_torrent_port_spec(Some(value), "TCP listen ports").is_ok()
});
sanitize_torrent_network_string(state, "torrentDhtListenPort", |value| {
crate::queue::normalize_torrent_port_spec(Some(value), "UDP listen ports").is_ok()
});
sanitize_torrent_network_string(state, "torrentExternalIp", |value| {
crate::queue::normalize_torrent_external_ip(Some(value)).is_ok()
});
sanitize_torrent_network_string(state, "torrentDhtEntryPoint", |value| {
crate::queue::normalize_torrent_dht_entry_point(Some(value), false).is_ok()
});
sanitize_torrent_network_string(state, "torrentDhtEntryPoint6", |value| {
crate::queue::normalize_torrent_dht_entry_point(Some(value), true).is_ok()
});
sanitize_torrent_network_string(state, "torrentDhtListenAddr6", |value| {
crate::queue::normalize_torrent_dht_listen_addr6(Some(value)).is_ok()
});
sanitize_torrent_network_string(state, "torrentLpdInterface", |value| {
crate::queue::normalize_torrent_lpd_interface(Some(value)).is_ok()
});
sanitize_torrent_network_string(state, "torrentPeerIdPrefix", |value| {
crate::queue::normalize_torrent_peer_id_prefix(Some(value)).is_ok()
});
sanitize_torrent_network_string(state, "torrentPeerAgent", |value| {
crate::queue::normalize_torrent_peer_agent(Some(value)).is_ok()
});
sanitize_allowed_string(
state,
"theme",
@@ -296,6 +440,20 @@ fn sanitize_boolean_setting(state: &mut serde_json::Map<String, Value>, key: &st
}
}
fn sanitize_torrent_network_string(
state: &mut serde_json::Map<String, Value>,
key: &str,
is_valid: impl Fn(&str) -> bool,
) {
if state
.get(key)
.and_then(Value::as_str)
.is_some_and(|value| !is_valid(value))
{
state.remove(key);
}
}
fn sanitize_allowed_string(
state: &mut serde_json::Map<String, Value>,
key: &str,
@@ -321,6 +479,64 @@ fn validate_settings(settings: &mut PersistedSettings) {
settings.torrent_max_open_files,
)
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES);
settings.torrent_listen_port = crate::queue::normalize_torrent_port_spec(
Some(&settings.torrent_listen_port),
"TCP listen ports",
)
.ok()
.flatten()
.unwrap_or_default();
settings.torrent_dht_listen_port = crate::queue::normalize_torrent_port_spec(
Some(&settings.torrent_dht_listen_port),
"UDP listen ports",
)
.ok()
.flatten()
.unwrap_or_default();
settings.torrent_external_ip = crate::queue::normalize_torrent_external_ip(
Some(&settings.torrent_external_ip),
)
.ok()
.flatten()
.unwrap_or_default();
settings.torrent_dht_entry_point = crate::queue::normalize_torrent_dht_entry_point(
Some(&settings.torrent_dht_entry_point),
false,
)
.ok()
.flatten()
.unwrap_or_default();
settings.torrent_dht_entry_point6 = crate::queue::normalize_torrent_dht_entry_point(
Some(&settings.torrent_dht_entry_point6),
true,
)
.ok()
.flatten()
.unwrap_or_default();
settings.torrent_dht_listen_addr6 = crate::queue::normalize_torrent_dht_listen_addr6(
Some(&settings.torrent_dht_listen_addr6),
)
.ok()
.flatten()
.unwrap_or_default();
settings.torrent_lpd_interface = crate::queue::normalize_torrent_lpd_interface(
Some(&settings.torrent_lpd_interface),
)
.ok()
.flatten()
.unwrap_or_default();
settings.torrent_peer_id_prefix = crate::queue::normalize_torrent_peer_id_prefix(
Some(&settings.torrent_peer_id_prefix),
)
.ok()
.flatten()
.unwrap_or_default();
settings.torrent_peer_agent = crate::queue::normalize_torrent_peer_agent(
Some(&settings.torrent_peer_agent),
)
.ok()
.flatten()
.unwrap_or_default();
if !matches!(
settings.last_custom_speed_limit_unit.as_str(),
"KB/s" | "MB/s"
@@ -514,6 +730,15 @@ fn default_settings() -> PersistedSettings {
torrent_enable_pex: true,
torrent_enable_lpd: false,
torrent_max_open_files: crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES,
torrent_listen_port: String::new(),
torrent_dht_listen_port: String::new(),
torrent_external_ip: String::new(),
torrent_dht_entry_point: String::new(),
torrent_dht_entry_point6: String::new(),
torrent_dht_listen_addr6: String::new(),
torrent_lpd_interface: String::new(),
torrent_peer_id_prefix: String::new(),
torrent_peer_agent: String::new(),
custom_user_agent: String::new(),
ask_where_to_save_each_file: false,
remember_last_used_download_directory: false,
@@ -530,8 +755,9 @@ fn default_settings() -> PersistedSettings {
mod tests {
use crate::ipc::{FontFamily, WindowControlStyle};
use super::{
decode_stored_settings, default_settings, preserve_portable_pairing_token,
preserve_scheduler_runtime_keys,
canonicalize_torrent_network_settings, decode_stored_settings, default_settings,
preserve_portable_pairing_token, preserve_scheduler_runtime_keys,
torrent_startup_settings,
};
use serde_json::{json, Value};
@@ -807,6 +1033,15 @@ mod tests {
"torrentEnablePex": null,
"torrentEnableLpd": [],
"torrentMaxOpenFiles": 0,
"torrentListenPort": "7000-6999",
"torrentDhtListenPort": "6881,\n",
"torrentExternalIp": "not-an-ip",
"torrentDhtEntryPoint": "bootstrap.example",
"torrentDhtEntryPoint6": "2001:db8::1:6881",
"torrentDhtListenAddr6": "127.0.0.1",
"torrentLpdInterface": "en0\n--bad",
"torrentPeerIdPrefix": "123456789012345678901",
"torrentPeerAgent": "agent\nname",
"theme": "not-a-theme",
"calendarPreference": "lunar",
"siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}]
@@ -828,6 +1063,15 @@ mod tests {
settings.torrent_max_open_files,
crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES
);
assert!(settings.torrent_listen_port.is_empty());
assert!(settings.torrent_dht_listen_port.is_empty());
assert!(settings.torrent_external_ip.is_empty());
assert!(settings.torrent_dht_entry_point.is_empty());
assert!(settings.torrent_dht_entry_point6.is_empty());
assert!(settings.torrent_dht_listen_addr6.is_empty());
assert!(settings.torrent_lpd_interface.is_empty());
assert!(settings.torrent_peer_id_prefix.is_empty());
assert!(settings.torrent_peer_agent.is_empty());
assert!(matches!(settings.theme, crate::ipc::Theme::System));
assert!(matches!(
settings.calendar_preference,
@@ -837,6 +1081,71 @@ mod tests {
assert_eq!(settings.site_logins[0].id, "valid");
}
#[test]
fn preserves_valid_torrent_network_settings() {
let stored = json!({
"state": {
"torrentListenPort": " 6881-6999 ",
"torrentDhtListenPort": "6881",
"torrentExternalIp": "203.0.113.7",
"torrentDhtEntryPoint": "Bootstrap.Example:6881",
"torrentDhtEntryPoint6": "[2001:db8::1]:6881",
"torrentDhtListenAddr6": "2001:db8::2",
"torrentLpdInterface": "en0",
"torrentPeerIdPrefix": "-FL-1-3-1-",
"torrentPeerAgent": "Firelink/1.3.1"
}
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert_eq!(settings.torrent_listen_port, "6881-6999");
assert_eq!(settings.torrent_dht_listen_port, "6881");
assert_eq!(settings.torrent_external_ip, "203.0.113.7");
assert_eq!(settings.torrent_dht_entry_point, "bootstrap.example:6881");
assert_eq!(settings.torrent_dht_entry_point6, "[2001:db8::1]:6881");
assert_eq!(settings.torrent_dht_listen_addr6, "2001:db8::2");
assert_eq!(settings.torrent_lpd_interface, "en0");
assert_eq!(settings.torrent_peer_id_prefix, "-FL-1-3-1-");
assert_eq!(settings.torrent_peer_agent, "Firelink/1.3.1");
}
#[test]
fn canonicalizes_torrent_network_settings_for_frontend_hydration() {
let stored = json!({
"state": {
"torrentListenPort": " 6881-6999 ",
"torrentExternalIp": "not-an-ip",
"torrentPeerIdPrefix": "123456789012345678901",
"torrentPeerAgent": " Firelink/1.3.1 "
},
"version": 6
});
let canonical = canonicalize_torrent_network_settings(&stored.to_string()).unwrap();
let canonical: Value = serde_json::from_str(&canonical).unwrap();
assert_eq!(canonical["state"]["torrentListenPort"], "6881-6999");
assert_eq!(canonical["state"]["torrentExternalIp"], "");
assert_eq!(canonical["state"]["torrentPeerIdPrefix"], "");
assert_eq!(canonical["state"]["torrentPeerAgent"], "Firelink/1.3.1");
}
#[test]
fn startup_settings_revalidate_values_at_the_aria2_boundary() {
let stored = json!({
"state": {
"torrentListenPort": "not-a-port",
"torrentPeerIdPrefix": "123456789012345678901",
"torrentPeerAgent": "Firelink/1.3.1"
}
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
let startup = torrent_startup_settings(Some(&settings));
assert!(startup.listen_port.is_empty());
assert!(startup.peer_id_prefix.is_empty());
assert_eq!(startup.peer_agent, "Firelink/1.3.1");
}
#[test]
fn opt_in_defaults_match_the_frontend_defaults() {
assert!(!default_settings().play_completion_sound);