mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-12 20:47:22 +00:00
feat(torrents): harden network identity settings
This commit is contained in:
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user