chore: initial sanitized public snapshot
This commit is contained in:
@@ -0,0 +1,708 @@
|
||||
#![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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user