#![doc(hidden)] #![expect( clippy::redundant_pub_crate, reason = "this private projection module centralizes CLI/config coercion helpers and reuses the split CLI surface without repeating a long import list" )] use super::{ BtStatusReport, CliError, CommandSurface, ConfigDocument, ConfigLoadReport, ConfigLocationKind, ConfigProfile, ConfigScope, ConfigSource, HeaderKind, HttpHeader, HttpSessionModel, Invocation, ParsedArguments, Path, PathBuf, Protocol, RpcValue, RuntimeConfig, RuntimeMode, StartupProfile, TransferSelection, fs, }; use aria2_rust_pro_compat::{parse_config, parse_config_lenient}; use aria2_rust_pro_protocol::{ProxyConfig, RetryPolicy, RetryStrategy, TlsConfig}; /// Extracts a string-like RPC field from a generic RPC value. fn rpc_string(value: Option<&RpcValue>) -> Option { match value { Some(RpcValue::String(value)) => Some(value.clone()), Some(RpcValue::Number(value)) => Some(value.to_string()), Some(RpcValue::Bool(value)) => Some(value.to_string()), _ => None, } } /// Extracts a boolean-like RPC field from a generic RPC value. pub(crate) fn rpc_bool(value: Option<&RpcValue>) -> Option { match value { Some(RpcValue::Bool(value)) => Some(*value), Some(RpcValue::String(value)) => parse_bool_text(value), Some(RpcValue::Number(value)) => Some(*value != 0), _ => None, } } /// Extracts an unsigned integer-like RPC field from a generic RPC value. pub(crate) fn rpc_u64(value: Option<&RpcValue>) -> Option { match value { Some(RpcValue::Number(value)) => (*value).try_into().ok(), Some(RpcValue::String(value)) => value.parse().ok(), Some(RpcValue::Bool(value)) => Some(u64::from(*value)), _ => None, } } /// Returns the length of an RPC array field when the value is an array. const fn rpc_array_len(value: Option<&RpcValue>) -> Option { match value { Some(RpcValue::Array(values)) => Some(values.len()), _ => None, } } /// Projects `BitTorrent`-specific tellStatus fields into the CLI report model. pub(crate) fn parse_bt_status_report( status: &std::collections::BTreeMap, ) -> Option { let is_bt = rpc_bool(status.get("isBt")); let metadata_only = rpc_bool(status.get("metadataOnly")); let magnet_uri = rpc_string(status.get("magnetUri")); let announce_list_tier_count = rpc_array_len(status.get("announceList")); let seeder = rpc_bool(status.get("seeder")); let num_seeders = rpc_u64(status.get("numSeeders")); let share_ratio = rpc_string(status.get("shareRatio")); let share_ratio_progress = rpc_string(status.get("shareRatioProgress")); let share_ratio_remaining = rpc_string(status.get("shareRatioRemaining")); let share_time = rpc_u64(status.get("shareTime")); if is_bt.is_none() && metadata_only.is_none() && magnet_uri.is_none() && announce_list_tier_count.is_none() && seeder.is_none() && num_seeders.is_none() && share_ratio.is_none() && share_ratio_progress.is_none() && share_ratio_remaining.is_none() && share_time.is_none() { return None; } Some(BtStatusReport { is_bt, metadata_only, magnet_uri, announce_list_tier_count, seeder, num_seeders, share_ratio, share_ratio_progress, share_ratio_remaining, share_time, }) } /// Returns whether an input ends with an ASCII suffix, ignoring case. fn has_ascii_case_insensitive_suffix(input: &str, suffix: &str) -> bool { input .get(input.len().saturating_sub(suffix.len())..) .is_some_and(|tail| tail.eq_ignore_ascii_case(suffix)) } #[must_use] /// Classifies a transfer input by the user-visible download surface it implies. pub fn classify_transfer(input: &str) -> TransferSelection { if input .get(.."magnet:?".len()) .is_some_and(|scheme| scheme.eq_ignore_ascii_case("magnet:?")) { TransferSelection::Magnet } else if has_ascii_case_insensitive_suffix(input, ".torrent") { TransferSelection::Torrent } else if has_ascii_case_insensitive_suffix(input, ".meta4") || has_ascii_case_insensitive_suffix(input, ".metalink") { TransferSelection::Metalink } else { TransferSelection::Uri } } #[must_use] /// Parses a supported transfer protocol from a URI-like input. pub fn parse_protocol(input: &str) -> Option { let (scheme, _) = input.split_once(':')?; if scheme.eq_ignore_ascii_case("http") { Some(Protocol::Http) } else if scheme.eq_ignore_ascii_case("https") { Some(Protocol::Https) } else if scheme.eq_ignore_ascii_case("ftp") { Some(Protocol::Ftp) } else if scheme.eq_ignore_ascii_case("sftp") { Some(Protocol::Sftp) } else if scheme.eq_ignore_ascii_case("magnet") { Some(Protocol::Magnet) } else if scheme.eq_ignore_ascii_case("file") { Some(Protocol::File) } else { None } } #[must_use] /// Chooses the execution surface implied by a parsed invocation. pub fn command_surface(parsed: &ParsedArguments) -> CommandSurface { match &parsed.invocation { Invocation::Version => CommandSurface::PrintVersion, Invocation::Help { query } => CommandSurface::PrintHelp { query: query.clone(), }, Invocation::Run { config_path, uris } => { if parsed.profile.dry_run { config_path.clone().map_or_else( || CommandSurface::Foreground(parsed.invocation.clone()), |config_path| CommandSurface::ValidateConfig { config_path, strict: true, }, ) } else if parsed.profile.rpc.enabled || parsed.profile.mode != RuntimeMode::Foreground { CommandSurface::RpcDaemon { config_path: config_path.clone(), inputs: uris.clone(), } } else { CommandSurface::Foreground(parsed.invocation.clone()) } } } } /// Loads a config file and returns a summary report. /// /// # Errors /// /// Returns I/O or parse errors while reading the config file. pub fn load_config_report(path: &Path, strict: bool) -> Result { let config = fs::read_to_string(path).map_err(|error| CliError::Io(error.to_string()))?; let directives = if strict { parse_config(&config).map_err(CliError::Config)? } else { parse_config_lenient(&config).map_err(CliError::Config)? }; let directive_count = directives.len(); Ok(ConfigLoadReport { path: path.to_path_buf(), directive_count, strict, profile: ConfigProfile { name: path.to_string_lossy().into_owned(), source: ConfigSource::UserConfig, document: ConfigDocument { directives, location: ConfigLocationKind::File, scope: ConfigScope::Mixed, }, }, }) } #[must_use] /// Projects the effective config directives into a simple option map. pub fn profile_option_map(profile: &ConfigProfile) -> std::collections::BTreeMap { profile .document .directives .iter() .filter_map(|directive| { directive .value .as_ref() .map(|value| (directive.name.clone(), value.clone())) }) .collect() } #[must_use] /// Returns the effective last-wins directive value for one option name. pub(crate) fn profile_option_value<'a>( profile: &'a ConfigProfile, option_name: &str, ) -> Option<&'a str> { for directive in profile.document.directives.iter().rev() { if directive.name == option_name { return directive.value.as_deref(); } } None } /// Parses aria2-style boolean text accepted by config and RPC surfaces. pub(crate) fn parse_bool_text(value: &str) -> Option { let value = value.trim(); if matches!(value, "1") || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes") || value.eq_ignore_ascii_case("on") { Some(true) } else if matches!(value, "0") || value.eq_ignore_ascii_case("false") || value.eq_ignore_ascii_case("no") || value.eq_ignore_ascii_case("off") { Some(false) } else { None } } /// Parses a trimmed unsigned integer from text. pub(crate) fn parse_u64_text(value: &str) -> Option { value.trim().parse().ok() } /// Parses a trimmed TCP/UDP port from text. pub(crate) fn parse_u16_text(value: &str) -> Option { value.trim().parse().ok() } /// Parses an aria2-style size literal such as `4M`. pub(crate) fn parse_size_text(value: &str) -> Option { let trimmed = value.trim(); let digits = trimmed.trim_end_matches(|c: char| c.is_ascii_alphabetic()); let suffix = &trimmed[digits.len()..]; let base: u64 = digits.parse().ok()?; let multiplier = if suffix.is_empty() { 1 } else if suffix.eq_ignore_ascii_case("k") { 1024 } else if suffix.eq_ignore_ascii_case("m") { 1024 * 1024 } else if suffix.eq_ignore_ascii_case("g") { 1024 * 1024 * 1024 } else { return None; }; Some(base.saturating_mul(multiplier)) } /// Splits a comma-separated option value into trimmed entries. pub(crate) fn parse_csv_text(value: &str) -> Vec { value .split(',') .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_owned) .collect() } /// Derives the effective RPC listen host from config and startup overrides. pub(crate) fn derive_rpc_listen_host( profile: Option<&ConfigProfile>, startup: &StartupProfile, ) -> String { let mut listen_host = startup.rpc.listen_host.clone(); if let Some(profile) = profile && parse_bool_text(profile_option_value(profile, "rpc-listen-all").unwrap_or_default()) == Some(true) { "0.0.0.0".clone_into(&mut listen_host); } listen_host } /// Derives the effective RPC secret from startup overrides or config. pub(crate) fn derive_rpc_secret( profile: Option<&ConfigProfile>, startup: &StartupProfile, ) -> Option { if let Some(secret) = startup.rpc.secret.clone() { return Some(secret); } profile .and_then(|profile| profile_option_value(profile, "rpc-secret")) .map(ToOwned::to_owned) } /// Parses a proxy URL into the protocol-layer proxy model. pub(crate) fn parse_proxy_text(value: &str, bypass_hosts: Vec) -> Option { let (scheme, rest) = value.split_once("://").unwrap_or(("http", value)); let (auth_part, host_part) = match rest.rsplit_once('@') { Some((auth, host)) => (Some(auth), host), None => (None, rest), }; let (host, port) = host_part.rsplit_once(':')?; let (username, password) = match auth_part.and_then(|auth| auth.split_once(':')) { Some((username, password)) => (Some(username.to_owned()), Some(password.to_owned())), None => (auth_part.map(str::to_owned), None), }; Some(ProxyConfig { scheme: scheme.to_owned(), host: host.to_owned(), port: port.parse().ok()?, username, password, bypass_hosts, no_proxy: false, }) } /// Returns the layered proxy-auth override value for a selected proxy family. pub(crate) fn layered_proxy_auth_override( profile: &ConfigProfile, primary_key: &str, fallback_key: &str, ) -> Option { profile_option_value(profile, primary_key) .map(ToOwned::to_owned) .or_else(|| { if primary_key == fallback_key { None } else { profile_option_value(profile, fallback_key).map(ToOwned::to_owned) } }) } /// Applies aria2-style proxy auth overrides on top of a parsed proxy endpoint. pub(crate) fn apply_proxy_auth_overrides( proxy: &mut ProxyConfig, profile: &ConfigProfile, user_key: &str, password_key: &str, ) { if let Some(username) = layered_proxy_auth_override(profile, user_key, "all-proxy-user") { proxy.username = Some(username); } if let Some(password) = layered_proxy_auth_override(profile, password_key, "all-proxy-passwd") { proxy.password = Some(password); } } /// Saturating conversion from `u64` to `usize`. pub(crate) fn saturating_usize_from_u64(value: u64) -> usize { usize::try_from(value).unwrap_or(usize::MAX) } /// Saturating conversion from `u64` to `u32`. pub(crate) fn saturating_u32_from_u64(value: u64) -> u32 { u32::try_from(value).unwrap_or(u32::MAX) } /// Saturating conversion from `usize` to `u32`. pub(crate) fn saturating_u32_from_usize(value: usize) -> u32 { u32::try_from(value).unwrap_or(u32::MAX) } /// Saturating conversion from `usize` to `u16`. pub(crate) fn saturating_u16_from_usize(value: usize) -> u16 { u16::try_from(value).unwrap_or(u16::MAX) } /// Fallible-in-practice conversion from `usize` to `u64` with saturation fallback. pub(crate) fn lossless_u64_from_usize(value: usize) -> u64 { u64::try_from(value).unwrap_or(u64::MAX) } /// Derives the core runtime configuration from CLI and config inputs. #[must_use] pub fn derive_runtime_config( profile: Option<&ConfigProfile>, startup: &StartupProfile, ) -> RuntimeConfig { let mut runtime = RuntimeConfig::default(); runtime.allow_jsonrpc = startup.rpc.enabled || startup.mode == RuntimeMode::RpcOnly; runtime.allow_xmlrpc = runtime.allow_jsonrpc; runtime.rpc_port = startup.rpc.listen_port; if let Some(profile) = profile { let option = |name| profile_option_value(profile, name); if let Some(value) = option("rpc-listen-port").and_then(parse_u16_text) { runtime.rpc_port = value; } if let Some(value) = option("listen-port").and_then(parse_u16_text) { runtime.listen_port = value; } if let Some(value) = option("dht-listen-port").and_then(parse_u16_text) { runtime.listen_port = value; } if let Some(value) = option("max-concurrent-downloads").and_then(parse_u64_text) { runtime.max_active_downloads = saturating_usize_from_u64(value.max(1)); } if let Some(value) = option("max-connection-per-server").and_then(parse_u64_text) { let connection_budget = saturating_usize_from_u64(value); runtime.max_connections_per_server = connection_budget; runtime.max_connection_per_server = connection_budget; } if let Some(value) = option("max-overall-download-limit").and_then(parse_size_text) { runtime.max_overall_download_limit = (value > 0).then_some(value); } if let Some(value) = option("max-download-limit").and_then(parse_size_text) { runtime.max_download_limit = (value > 0).then_some(value); } if let Some(value) = option("max-overall-upload-limit").and_then(parse_size_text) { runtime.max_overall_upload_limit = (value > 0).then_some(value); } if let Some(value) = option("max-upload-limit").and_then(parse_size_text) { runtime.max_upload_limit = (value > 0).then_some(value); } if let Some(value) = option("split").and_then(parse_u64_text) { runtime.split = saturating_usize_from_u64(value.max(1)); } if let Some(value) = option("disk-cache").and_then(parse_size_text) { runtime.disk_cache_bytes = value; } if let Some(value) = option("min-split-size").and_then(parse_size_text) { runtime.min_split_size = value; } if let Some(value) = option("piece-length").and_then(parse_size_text) { runtime.piece_length = value; } if let Some(value) = option("save-session") { runtime.session_path = Some(PathBuf::from(value)); } if let Some(value) = option("save-session-interval").and_then(parse_u64_text) { runtime.save_session_interval_secs = value; } if let Some(value) = option("enable-rpc").and_then(parse_bool_text) { runtime.allow_jsonrpc = value; runtime.allow_xmlrpc = value; } if let Some(value) = option("disable-ipv6").and_then(parse_bool_text) { runtime.enable_ipv6 = !value; } if let Some(value) = option("retry-on-400").and_then(parse_bool_text) { runtime.retry_on_400 = value; } if let Some(value) = option("retry-on-403").and_then(parse_bool_text) { runtime.retry_on_403 = value; } if let Some(value) = option("retry-on-406").and_then(parse_bool_text) { runtime.retry_on_406 = value; } if let Some(value) = option("retry-on-unknown").and_then(parse_bool_text) { runtime.retry_on_unknown = value; } } runtime } /// Derives the HTTP session model from CLI and config inputs. #[must_use] #[expect( clippy::too_many_lines, reason = "session derivation intentionally keeps option-to-field mapping in one audit-friendly routine" )] pub fn derive_http_session( profile: Option<&ConfigProfile>, startup: &StartupProfile, ) -> HttpSessionModel { let mut session = HttpSessionModel { session_id: "local-http-session".to_owned(), user_agent: None, default_headers: Vec::new(), cookies: Vec::new(), auth: None, proxy: None, tls: Some(TlsConfig { verify_peer: true, verify_host: true, min_version: None, max_version: None, ca_file: None, cert_file: None, key_file: None, }), retry: default_retry_strategy(), }; let _ = startup; if let Some(profile) = profile { let option = |name| profile_option_value(profile, name); if let Some(value) = option("user-agent") { session.user_agent = Some(value.to_owned()); } if let Some(value) = option("header") { session.default_headers = parse_csv_text(value) .into_iter() .filter_map(|header| { header.split_once(':').map(|(name, value)| HttpHeader { name: name.trim().to_owned(), value: value.trim().to_owned(), kind: HeaderKind::Request, }) }) .collect(); } let bypass_hosts = option("no-proxy").map_or_else(Vec::new, parse_csv_text); if let Some(proxy_text) = option("https-proxy") { session.proxy = parse_proxy_text(proxy_text, bypass_hosts); if let Some(proxy) = &mut session.proxy { apply_proxy_auth_overrides( proxy, profile, "https-proxy-user", "https-proxy-passwd", ); } } else if let Some(proxy_text) = option("http-proxy") { session.proxy = parse_proxy_text(proxy_text, bypass_hosts); if let Some(proxy) = &mut session.proxy { apply_proxy_auth_overrides(proxy, profile, "http-proxy-user", "http-proxy-passwd"); } } else if let Some(proxy_text) = option("all-proxy") { session.proxy = parse_proxy_text(proxy_text, bypass_hosts); if let Some(proxy) = &mut session.proxy { apply_proxy_auth_overrides(proxy, profile, "all-proxy-user", "all-proxy-passwd"); } } if let Some(check) = option("check-certificate").and_then(parse_bool_text) && let Some(tls) = &mut session.tls { tls.verify_peer = check; tls.verify_host = check; } if let Some(value) = option("ca-certificate") && let Some(tls) = &mut session.tls { tls.ca_file = Some(value.to_owned()); } if let Some(value) = option("certificate") && let Some(tls) = &mut session.tls { tls.cert_file = Some(value.to_owned()); } if let Some(value) = option("private-key") && let Some(tls) = &mut session.tls { tls.key_file = Some(value.to_owned()); } if let Some(value) = option("retry-wait").and_then(parse_u64_text) { session.retry.policy.initial_backoff_ms = value.saturating_mul(1000); session.retry.policy.max_backoff_ms = value.saturating_mul(1000); if value > 0 { session.retry.policy.retry_on_5xx = true; session.retry.policy.retry_on_timeout = true; session.retry.policy.retry_on_network_error = true; } } if let Some(value) = option("max-tries").and_then(parse_u64_text) { session.retry.policy.max_attempts = saturating_u32_from_u64(value); if value > 1 { session.retry.policy.retry_on_5xx = true; session.retry.policy.retry_on_timeout = true; session.retry.policy.retry_on_network_error = true; } } if let Some(value) = option("retry-on-400").and_then(parse_bool_text) { session.retry.policy.retry_on_4xx |= value; } if let Some(value) = option("retry-on-403").and_then(parse_bool_text) { session.retry.policy.retry_on_4xx |= value; } if let Some(value) = option("retry-on-406").and_then(parse_bool_text) { session.retry.policy.retry_on_4xx |= value; } if let Some(value) = option("retry-on-unknown").and_then(parse_bool_text) { session.retry.policy.retry_on_network_error |= value; } } session } /// Returns the default retry strategy used for one-shot synthetic transfer tasks. pub(crate) const fn default_retry_strategy() -> RetryStrategy { RetryStrategy { policy: RetryPolicy { max_attempts: 1, initial_backoff_ms: 0, max_backoff_ms: 0, retry_on_3xx: false, retry_on_4xx: false, retry_on_5xx: false, retry_on_network_error: false, retry_on_timeout: false, }, jitter: None, max_elapsed_ms: None, } } #[cfg(test)] mod tests { use super::{load_config_report, profile_option_value}; use crate::CliError; use aria2_rust_pro_compat::{ ConfigDirective, ConfigDocument, ConfigLocationKind, ConfigProfile, ConfigScope, ConfigSource, }; use std::fs; #[test] fn load_config_report_strict_rejects_unknown_options() { let temp_dir = std::env::temp_dir().join("aria2-rust-pro-projection-strict"); let _ = fs::create_dir_all(&temp_dir); let config_path = temp_dir.join("aria2.conf"); fs::write(&config_path, "split=4\nunknown-option=yes\n") .expect("config should be writable"); let error = load_config_report(&config_path, true).expect_err("strict config load should fail"); assert!(matches!(error, CliError::Config(_))); let _ = fs::remove_dir_all(temp_dir); } #[test] fn load_config_report_lenient_preserves_known_directives() { let temp_dir = std::env::temp_dir().join("aria2-rust-pro-projection-lenient"); let _ = fs::create_dir_all(&temp_dir); let config_path = temp_dir.join("aria2.conf"); fs::write( &config_path, "split=4\nunknown-option=yes\nmin-split-size=1M\n", ) .expect("config should be writable"); let report = load_config_report(&config_path, false).expect("lenient config load should work"); assert_eq!(report.directive_count, 3); assert_eq!(report.profile.document.directives.len(), 3); let directive_names = report .profile .document .directives .iter() .map(|directive| directive.name.as_str()) .collect::>(); assert_eq!( directive_names, ["split", "unknown-option", "min-split-size"] ); let _ = fs::remove_dir_all(temp_dir); } #[test] fn profile_option_value_uses_last_wins_directive_order() { let profile = ConfigProfile { name: "test".to_owned(), source: ConfigSource::RuntimeOverride, document: ConfigDocument { directives: vec![ ConfigDirective { name: "dir".to_owned(), value: Some("downloads-a".to_owned()), }, ConfigDirective { name: "split".to_owned(), value: Some("2".to_owned()), }, ConfigDirective { name: "dir".to_owned(), value: Some("downloads-b".to_owned()), }, ], location: ConfigLocationKind::Inline, scope: ConfigScope::Mixed, }, }; assert_eq!(profile_option_value(&profile, "dir"), Some("downloads-b")); assert_eq!(profile_option_value(&profile, "split"), Some("2")); assert_eq!(profile_option_value(&profile, "missing"), None); } }