Files
aria2-rust-pro/crates/aria2-rust-pro-cli/src/projection.rs
T

709 lines
25 KiB
Rust

#![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<String> {
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<bool> {
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<u64> {
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<usize> {
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<String, RpcValue>,
) -> Option<BtStatusReport> {
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<Protocol> {
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<ConfigLoadReport, CliError> {
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<String, String> {
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<bool> {
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<u64> {
value.trim().parse().ok()
}
/// Parses a trimmed TCP/UDP port from text.
pub(crate) fn parse_u16_text(value: &str) -> Option<u16> {
value.trim().parse().ok()
}
/// Parses an aria2-style size literal such as `4M`.
pub(crate) fn parse_size_text(value: &str) -> Option<u64> {
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<String> {
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<String> {
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<String>) -> Option<ProxyConfig> {
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<String> {
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::<Vec<_>>();
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);
}
}