use std::collections::{BTreeMap, BTreeSet}; use aria2_rust_pro_compat::{global_option_specs, per_download_option_specs}; use aria2_rust_pro_core::{DownloadHandle, RequestGroup}; use base64::Engine; use crate::{ model::RpcValue, xmlrpc::{XmlRpcMember, XmlRpcValue}, }; /// Builds the unified option surface exposed by `getGlobalOption`. pub(super) fn option_specs_for_global_view() -> Vec<&'static aria2_rust_pro_compat::OptionSpec> { let mut specs = global_option_specs(); let mut seen = specs.iter().map(|spec| spec.name).collect::>(); for spec in per_download_option_specs() { if seen.insert(spec.name) { specs.push(spec); } } specs } /// Lossily converts a `usize` into an `i64` for RPC payload rendering. pub(super) fn i64_from_usize(value: usize) -> i64 { i64::try_from(value).unwrap_or(i64::MAX) } /// Lossily converts a `usize` into a `u32` for engine-facing counters. pub(super) fn u32_from_usize(value: usize) -> u32 { u32::try_from(value).unwrap_or(u32::MAX) } /// Lossily converts a `usize` into a `u64` for RPC payload rendering. pub(super) fn u64_from_usize(value: usize) -> u64 { u64::try_from(value).unwrap_or(u64::MAX) } /// Converts a non-negative JSON-RPC integer into a platform `usize`. pub(super) fn usize_from_i64(value: i64) -> Option { usize::try_from(value).ok() } /// Lossily converts a `u64` into a `usize` for local indexing. pub(super) fn usize_from_u64(value: u64) -> usize { usize::try_from(value).unwrap_or(usize::MAX) } /// Returns the first per-download option key that aria2 forbids through `changeOption`. pub(super) fn first_forbidden_change_option_key(map: &BTreeMap) -> Option<&str> { const FORBIDDEN: &[&str] = &[ "dry-run", "metalink-base-uri", "parameterized-uri", "pause", "piece-length", "rpc-save-upload-metadata", ]; FORBIDDEN .iter() .find_map(|name| map.contains_key(*name).then_some(*name)) } /// Returns the first global option key that aria2 forbids through `changeGlobalOption`. pub(super) fn first_forbidden_change_global_option_key( map: &BTreeMap, ) -> Option<&str> { const FORBIDDEN: &[&str] = &["checksum", "index-out", "out", "pause", "select-file"]; FORBIDDEN .iter() .find_map(|name| map.contains_key(*name).then_some(*name)) } /// Parses a required RPC URI parameter into a normalized URI list. pub(super) fn parse_uri_list_param(value: &RpcValue) -> Result, String> { match value { RpcValue::String(uri) => Ok(vec![uri.clone()]), RpcValue::Array(items) => { let mut uris = Vec::with_capacity(items.len()); for item in items { match item { RpcValue::String(uri) => uris.push(uri.clone()), _ => return Err("uri array must contain only strings".to_owned()), } } if uris.is_empty() { return Err("uri array must not be empty".to_owned()); } Ok(uris) } _ => Err("uris must be an array of strings".to_owned()), } } /// Parses a URI array parameter that may legally be empty. pub(super) fn parse_uri_array_allow_empty( value: &RpcValue, label: &str, ) -> Result, String> { let RpcValue::Array(items) = value else { return Err(format!("{label} must be an array of strings")); }; let mut uris = Vec::with_capacity(items.len()); for item in items { if let RpcValue::String(uri) = item { uris.push(uri.clone()); } } Ok(uris) } /// Parses an optional webseed URI array for add-torrent style methods. pub(super) fn parse_optional_uri_array( value: &RpcValue, method: &str, ) -> Result, String> { match value { RpcValue::Array(items) => { let mut uris = Vec::with_capacity(items.len()); for item in items { match item { RpcValue::String(uri) => uris.push(uri.clone()), _ => { return Err(format!("{method} webseed uris must contain only strings")); } } } Ok(uris) } _ => Err(format!("{method} webseed uris must be an array of strings")), } } /// Parses an optional RPC options object into owned key-value entries. pub(super) fn parse_optional_option_object( value: Option<&RpcValue>, method: &str, ) -> Result, String> { let Some(value) = value else { return Ok(Vec::new()); }; let RpcValue::Object(options) = value else { return Err(format!("{method} options must be a struct/object")); }; Ok(options .iter() .map(|(key, value)| (key.clone(), value.clone())) .collect()) } /// Parses an optional queue position parameter. pub(super) fn parse_optional_position( value: Option<&RpcValue>, method: &str, ) -> Result, String> { let Some(value) = value else { return Ok(None); }; match value { RpcValue::Number(value) if *value >= 0 => Ok(usize_from_i64(*value)), _ => Err(format!("{method} position must be a non-negative integer")), } } /// Parses a required 1-based file index parameter. pub(super) fn parse_required_file_index(value: &RpcValue) -> Option { match value { RpcValue::Number(value) if *value >= 1 => usize_from_i64(*value), _ => None, } } /// Returns whether a URI looks actionable for aria2-style add methods. pub(super) fn is_rpc_uri_candidate(uri: &str) -> bool { rpc_uri_has_ascii_prefix(uri, "magnet:?") || uri.contains("://") || rpc_uri_has_ascii_suffix(uri, ".torrent") } /// Returns whether a URI starts with an ASCII prefix, ignoring case. pub(super) fn rpc_uri_has_ascii_prefix(uri: &str, prefix: &str) -> bool { uri.get(..prefix.len()) .is_some_and(|head| head.eq_ignore_ascii_case(prefix)) } /// Returns whether a URI ends with an ASCII suffix, ignoring case. pub(super) fn rpc_uri_has_ascii_suffix(uri: &str, suffix: &str) -> bool { uri.get(uri.len().saturating_sub(suffix.len())..) .is_some_and(|tail| tail.eq_ignore_ascii_case(suffix)) } /// Extracts a display file name from a URI when one is obvious. pub(super) fn rpc_uri_file_name(uri: &str) -> Option { let trimmed = uri .split(['?', '#']) .next() .unwrap_or(uri) .trim_end_matches('/'); let candidate = trimmed.rsplit('/').next()?; if candidate.is_empty() { None } else { Some(candidate.to_owned()) } } /// Parses an optional status-field allowlist parameter. pub(super) fn parse_optional_status_keys( value: Option<&RpcValue>, method: &str, ) -> Result>, String> { let Some(value) = value else { return Ok(None); }; let RpcValue::Array(items) = value else { return Err(format!("{method} keys must be an array of strings")); }; if items.is_empty() { return Ok(None); } let mut keys = BTreeSet::new(); for item in items { match item { RpcValue::String(key) => { keys.insert(key.clone()); } _ => return Err(format!("{method} keys must contain only strings")), } } Ok(Some(keys)) } /// Filters a status payload down to the requested field set. pub(super) fn filter_status_payload( payload: RpcValue, keys: Option<&BTreeSet>, ) -> RpcValue { let Some(keys) = keys else { return payload; }; match payload { RpcValue::Object(fields) => RpcValue::Object( fields .into_iter() .filter(|(key, _)| keys.contains(key)) .collect(), ), other => other, } } /// Applies RPC option values to a request group using aria2's stringly option model. pub(super) fn apply_group_options(group: &mut RequestGroup, options: Vec<(String, RpcValue)>) { for (key, value) in options { match value { RpcValue::String(value) => group.set_option(key, value), RpcValue::Number(value) => group.set_option(key, value.to_string()), RpcValue::Bool(value) => group.set_option(key, if value { "true" } else { "false" }), RpcValue::Null => group.set_option(key, ""), RpcValue::Array(_) | RpcValue::Object(_) => {} } } } /// Applies already-normalized string options to a request group directly. pub(super) fn apply_group_string_options(group: &mut RequestGroup, options: Vec<(String, String)>) { for (key, value) in options { group.set_option(key, value); } } /// Builds implied request-group options from a metalink plan entry. pub(super) fn metalink_default_options( entry: &aria2_rust_pro_protocol::metalink::MetalinkDownloadPlanEntry, ) -> Vec<(String, RpcValue)> { let mut options = Vec::new(); if !entry.file_name.trim().is_empty() { options.push(("out".to_owned(), RpcValue::String(entry.file_name.clone()))); } if let Some(checksum) = &entry.checksum { options.push(( "checksum".to_owned(), RpcValue::String(format!("{}={}", checksum.algorithm, checksum.expected_hex)), )); } options } /// Decodes a base64 metalink payload when the caller did not send raw XML. pub(super) fn decode_metalink_payload(value: &str) -> Option { let bytes = base64::engine::general_purpose::STANDARD .decode(value.as_bytes()) .ok()?; let text = String::from_utf8(bytes).ok()?; text.contains(", offset: i64, max: usize, ) -> Vec { if max == 0 || handles.is_empty() { return Vec::new(); } if offset >= 0 { return handles .into_iter() .skip(usize_from_i64(offset).unwrap_or(usize::MAX)) .take(max) .collect(); } let reversed = handles.into_iter().rev().collect::>(); let start = offset .checked_neg() .and_then(|value| value.checked_sub(1)) .and_then(usize_from_i64) .unwrap_or_default(); reversed.into_iter().skip(start).take(max).collect() } /// Looks up a named XML-RPC struct member. pub(super) fn xmlrpc_member_value<'a>( members: &'a [XmlRpcMember], name: &str, ) -> Option<&'a XmlRpcValue> { members .iter() .find(|member| member.name == name) .map(|member| &member.value) } /// Parses the completed byte count from a `Content-Range` header value. pub(super) fn parse_content_range_completed_length(value: &str) -> Option { let mut parts = value.split_whitespace(); let unit = parts.next()?; if !unit.eq_ignore_ascii_case("bytes") { return None; } let range = parts.next()?; let (start, end) = range.split_once('-')?; let start = start.parse::().ok()?; let end = end.parse::().ok()?; if end < start { return None; } Some(end - start + 1) } /// Returns whether an HTTP status should preserve retry eligibility. pub(super) fn is_retry_relevant_status(status: u16) -> bool { matches!(status, 408 | 425 | 429 | 500 | 502 | 503 | 504) }