chore: initial sanitized public snapshot

This commit is contained in:
Aria2 Rust Pro Contributors
2026-07-18 16:01:12 +08:00
commit 7c6b6a3746
321 changed files with 76896 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "aria2-rust-pro-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
description.workspace = true
readme.workspace = true
keywords.workspace = true
categories.workspace = true
rust-version.workspace = true
[[bin]]
name = "aria2-rust-pro"
path = "src/main.rs"
[dependencies]
aria2-rust-pro-compat.workspace = true
aria2-rust-pro-core.workspace = true
aria2-rust-pro-protocol.workspace = true
aria2-rust-pro-rpc.workspace = true
aria2-rust-pro-storage.workspace = true
[dev-dependencies]
ssh2 = "0.9.5"
[lints]
workspace = true
+511
View File
@@ -0,0 +1,511 @@
#![doc(hidden)]
#![expect(
clippy::redundant_pub_crate,
reason = "this private CLI parsing module exposes parent-only helpers while keeping documentation focused on the public command surface"
)]
use super::*;
/// Parses aria2-compatible command-line arguments into a high-level invocation.
///
/// # Errors
///
/// Returns an error when an option is unknown or when an option requiring a
/// value is missing that value.
pub fn parse_args(args: impl IntoIterator<Item = OsString>) -> Result<Invocation, CliError> {
Ok(parse_cli(args)?.invocation)
}
/// Returns whether a token can be consumed as aria2-compatible boolean text.
fn looks_like_bool_value(value: &str) -> bool {
parse_bool_text(value).is_some()
}
/// Builds a transient compat profile from CLI-originated directives.
fn build_cli_profile(directives: Vec<ConfigDirective>) -> Option<ConfigProfile> {
(!directives.is_empty()).then(|| ConfigProfile {
name: "cli".to_owned(),
source: ConfigSource::RuntimeOverride,
document: ConfigDocument {
directives,
location: ConfigLocationKind::Cli,
scope: ConfigScope::Mixed,
},
})
}
/// Merges a file-backed config profile with CLI overrides, keeping CLI values last.
pub(crate) fn merged_profile(
file_profile: Option<&ConfigProfile>,
cli_profile: Option<&ConfigProfile>,
) -> Option<ConfigProfile> {
match (file_profile, cli_profile) {
(None, None) => None,
(Some(profile), None) | (None, Some(profile)) => Some(profile.clone()),
(Some(file_profile), Some(cli_profile)) => {
let mut directives = file_profile.document.directives.clone();
directives.extend(cli_profile.document.directives.clone());
Some(ConfigProfile {
name: format!("{}+cli", file_profile.name),
source: ConfigSource::RuntimeOverride,
document: ConfigDocument {
directives,
location: ConfigLocationKind::Cli,
scope: ConfigScope::Mixed,
},
})
}
}
}
/// Reads text for an aria2 input file from disk or stdin.
fn read_input_file_text(path: &str) -> Result<String, CliError> {
if path == "-" {
let text = {
let stdin_handle = io::stdin();
let mut stdin = stdin_handle.lock();
let mut text = String::new();
stdin.read_to_string(&mut text).map_err(|error| {
CliError::Io(format!("failed to read input-file from stdin: {error}"))
})?;
text
};
Ok(text)
} else {
fs::read_to_string(path)
.map_err(|error| CliError::Io(format!("failed to read input-file {path}: {error}")))
}
}
/// Builds a profile for one logical input-file entity.
fn build_input_entry_profile(
name: &str,
directives: Vec<ConfigDirective>,
) -> Option<ConfigProfile> {
(!directives.is_empty()).then(|| ConfigProfile {
name: name.to_owned(),
source: ConfigSource::InputFile,
document: ConfigDocument {
directives,
location: ConfigLocationKind::Inline,
scope: ConfigScope::Mixed,
},
})
}
/// Parses aria2 input-file text into logical transfer entities.
fn parse_input_file_entries(name: &str, text: &str) -> Result<Vec<TransferInputEntry>, CliError> {
let mut entries = Vec::new();
let mut current_uris: Option<Vec<String>> = None;
let mut current_directives = Vec::new();
let finalize_current =
|entries: &mut Vec<TransferInputEntry>,
current_uris: &mut Option<Vec<String>>,
current_directives: &mut Vec<ConfigDirective>| {
if let Some(uris) = current_uris.take()
&& !uris.is_empty()
{
let profile = build_input_entry_profile(name, std::mem::take(current_directives));
entries.push(TransferInputEntry {
uris,
implied_profile: None,
profile,
});
}
};
for raw_line in text.lines() {
let trimmed = raw_line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
continue;
}
if raw_line.chars().next().is_some_and(char::is_whitespace) {
let Some(_) = current_uris else {
return Err(CliError::Config(ConfigParseError::InvalidDirective(
raw_line.to_owned(),
)));
};
if let Some(directive) = parse_config_line_strict(trimmed).map_err(CliError::Config)? {
current_directives.push(directive);
}
continue;
}
finalize_current(&mut entries, &mut current_uris, &mut current_directives);
let uris = raw_line
.split('\t')
.map(str::trim)
.filter(|uri| !uri.is_empty())
.map(str::to_owned)
.collect::<Vec<_>>();
if uris.is_empty() {
return Err(CliError::Config(ConfigParseError::InvalidDirective(
raw_line.to_owned(),
)));
}
current_uris = Some(uris);
}
finalize_current(&mut entries, &mut current_uris, &mut current_directives);
Ok(entries)
}
/// Expands CLI positional URIs plus any configured input-file into logical transfer entries.
pub(crate) fn expand_transfer_entries(
uris: &[String],
profile: Option<&ConfigProfile>,
cli_sources: Option<&[CliTransferSource]>,
) -> Result<Vec<TransferInputEntry>, CliError> {
let mut entries = Vec::new();
if let Some(cli_sources) = cli_sources
&& !cli_sources.is_empty()
{
for source in cli_sources {
match source {
CliTransferSource::Uri(uri) => entries.push(TransferInputEntry {
uris: vec![uri.clone()],
implied_profile: None,
profile: None,
}),
CliTransferSource::InputFile(path) => {
let text = read_input_file_text(path)?;
entries.extend(parse_input_file_entries(path, &text)?);
}
}
}
} else {
entries.extend(uris.iter().map(|uri| TransferInputEntry {
uris: vec![uri.clone()],
implied_profile: None,
profile: None,
}));
}
if let Some(path) = profile
.and_then(|profile| profile_option_value(profile, "input-file"))
.map(ToOwned::to_owned)
&& !cli_sources.unwrap_or(&[]).iter().any(
|source| matches!(source, CliTransferSource::InputFile(existing) if existing == &path),
)
{
let text = read_input_file_text(&path)?;
entries.extend(parse_input_file_entries(&path, &text)?);
}
Ok(entries)
}
/// Converts compat directives into an RPC option object.
pub(crate) fn rpc_option_object(profile: Option<&ConfigProfile>) -> Option<RpcValue> {
let profile = profile?;
let mut options = std::collections::BTreeMap::new();
for directive in &profile.document.directives {
let Some(value) = directive.value.as_ref() else {
continue;
};
options.insert(directive.name.clone(), RpcValue::String(value.clone()));
}
if options.is_empty() {
return None;
}
Some(RpcValue::Object(options))
}
/// Builds an inline compat profile from a small directive set.
pub(crate) fn config_profile_from_directives(
name: &str,
source: ConfigSource,
directives: Vec<ConfigDirective>,
) -> Option<ConfigProfile> {
(!directives.is_empty()).then(|| ConfigProfile {
name: name.to_owned(),
source,
document: ConfigDocument {
directives,
location: ConfigLocationKind::Inline,
scope: ConfigScope::PerDownload,
},
})
}
/// Parses aria2 checksum option text into an enabled checksum hook.
pub(crate) fn parse_checksum_hook_text(value: &str) -> Option<ChecksumHookModel> {
let (algorithm, expected_hex) = value.split_once('=')?;
let algorithm = algorithm.trim().to_ascii_lowercase();
let expected_hex = expected_hex.trim().to_ascii_lowercase();
if algorithm.is_empty() || expected_hex.is_empty() {
return None;
}
Some(ChecksumHookModel {
spec: ChecksumSpec {
algorithm,
expected_hex,
actual_hex: None,
},
enabled: true,
})
}
/// Synthesizes per-download defaults implied by a Metalink file entry.
pub(crate) fn metalink_entry_implied_profile(
file_name: &str,
checksum: Option<&ChecksumSpec>,
) -> Option<ConfigProfile> {
let mut directives = Vec::new();
if !file_name.trim().is_empty() {
directives.push(ConfigDirective {
name: "out".to_owned(),
value: Some(file_name.trim().to_owned()),
});
}
if let Some(checksum) = checksum {
directives.push(ConfigDirective {
name: "checksum".to_owned(),
value: Some(format!("{}={}", checksum.algorithm, checksum.expected_hex)),
});
}
config_profile_from_directives(
"metalink-implied",
ConfigSource::RuntimeOverride,
directives,
)
}
/// Parses an aria2-style CLI option into a compat directive.
///
/// Returns the parsed directive plus whether the next argv item was consumed.
fn parse_cli_override_argument(
arg: &str,
next: Option<&str>,
) -> Result<(ConfigDirective, bool), CliError> {
let option_name = arg.trim_start_matches('-');
if option_name.is_empty() {
return Err(CliError::UnknownOption(arg.to_owned()));
}
let parse_directive = |text: &str| -> Result<ConfigDirective, CliError> {
parse_config_line_strict(text)
.map_err(CliError::Config)?
.ok_or_else(|| CliError::UnknownOption(arg.to_owned()))
};
if arg.contains('=') {
return Ok((parse_directive(arg)?, false));
}
let spec = option_spec(option_name).ok_or_else(|| CliError::UnknownOption(arg.to_owned()))?;
if spec.kind == OptionKind::Bool {
if let Some(value) = next.filter(|candidate| looks_like_bool_value(candidate)) {
Ok((parse_directive(&format!("{arg}={value}"))?, true))
} else {
Ok((parse_directive(&format!("{arg}=true"))?, false))
}
} else {
let value = next.ok_or_else(|| CliError::MissingValue(arg.to_owned()))?;
Ok((parse_directive(&format!("{arg}={value}"))?, true))
}
}
/// Consumes the next CLI token as a required value-bearing argument.
fn next_cli_value<I>(args: &mut std::iter::Peekable<I>, option: &str) -> Result<String, CliError>
where
I: Iterator<Item = String>,
{
args.next()
.ok_or_else(|| CliError::MissingValue(option.to_owned()))
}
/// Parses aria2-compatible command-line arguments into a richer startup model.
///
/// # Errors
///
/// Returns an error when an option is unknown or when an option requiring a
/// value is missing that value.
#[expect(
clippy::too_many_lines,
reason = "CLI flag parsing stays linear to preserve aria2-compatible option precedence"
)]
pub fn parse_cli(args: impl IntoIterator<Item = OsString>) -> Result<ParsedArguments, CliError> {
let mut args = args
.into_iter()
.map(|arg| arg.into_string().map_err(|_| CliError::InvalidUtf8Argument))
.collect::<Result<Vec<_>, _>>()?
.into_iter();
let _program = args.next();
let mut args = args.peekable();
let mut config_path = None;
let mut uris = Vec::new();
let mut profile = StartupProfile::default();
let mut cli_directives = Vec::new();
let mut cli_transfer_sources = Vec::new();
while let Some(arg) = args.next() {
match arg.as_str() {
"--version" | "-v" => {
return Ok(ParsedArguments {
invocation: Invocation::Version,
profile,
cli_profile: None,
cli_transfer_sources: Vec::new(),
});
}
"--help" | "-h" | "--help=#all" => {
return Ok(ParsedArguments {
invocation: Invocation::Help { query: None },
profile,
cli_profile: None,
cli_transfer_sources: Vec::new(),
});
}
_ if arg.starts_with("--help=") || arg.starts_with("-h=") => {
let query = arg
.strip_prefix("--help=")
.or_else(|| arg.strip_prefix("-h="))
.map(str::to_owned);
return Ok(ParsedArguments {
invocation: Invocation::Help { query },
profile,
cli_profile: None,
cli_transfer_sources: Vec::new(),
});
}
"--conf-path" => {
config_path = Some(PathBuf::from(next_cli_value(&mut args, &arg)?));
}
"--enable-rpc" => {
let (directive, consumed_next) =
parse_cli_override_argument(&arg, args.peek().map(String::as_str))?;
if directive
.value
.as_deref()
.and_then(parse_bool_text)
.unwrap_or(true)
{
profile.rpc.enabled = true;
}
cli_directives.push(directive);
if consumed_next {
let _ = args.next();
}
}
"--rpc-listen-all" => {
let (directive, consumed_next) =
parse_cli_override_argument(&arg, args.peek().map(String::as_str))?;
if directive
.value
.as_deref()
.and_then(parse_bool_text)
.unwrap_or(true)
{
"0.0.0.0".clone_into(&mut profile.rpc.listen_host);
}
cli_directives.push(directive);
if consumed_next {
let _ = args.next();
}
}
"--daemon" | "-D" => {
profile.mode = RuntimeMode::Daemon;
profile.daemonize = true;
}
"--rpc-only" => profile.mode = RuntimeMode::RpcOnly,
"--dry-run" => profile.dry_run = true,
_ if arg.starts_with("--conf-path=") => {
config_path = Some(PathBuf::from(arg.trim_start_matches("--conf-path=")));
}
_ if arg.starts_with("--rpc-listen-port=") => {
let value = arg.trim_start_matches("--rpc-listen-port=");
profile.rpc.listen_port = value
.parse()
.map_err(|_| CliError::UnknownOption(arg.clone()))?;
cli_directives.push(parse_cli_override_argument(&arg, None)?.0);
}
"--rpc-listen-port" => {
let value = next_cli_value(&mut args, &arg)?;
profile.rpc.listen_port = value
.parse()
.map_err(|_| CliError::UnknownOption(arg.clone()))?;
cli_directives.push(parse_cli_override_argument(&arg, Some(value.as_str()))?.0);
}
_ if arg.starts_with("--rpc-secret=") => {
profile.rpc.secret = Some(arg.trim_start_matches("--rpc-secret=").to_owned());
}
"--rpc-secret" => {
let value = next_cli_value(&mut args, &arg)?;
profile.rpc.secret = Some(value.clone());
}
_ if arg.starts_with("--rpc-path=") => {
arg.trim_start_matches("--rpc-path=")
.clone_into(&mut profile.rpc.path);
}
"--rpc-path" => {
next_cli_value(&mut args, &arg)?.clone_into(&mut profile.rpc.path);
}
"--input-file" | "-i" => {
let value = next_cli_value(&mut args, &arg)?;
cli_directives.push(parse_cli_override_argument(&arg, Some(value.as_str()))?.0);
cli_transfer_sources.push(CliTransferSource::InputFile(value));
}
_ if arg.starts_with("--input-file=") || arg.starts_with("-i=") => {
let value = arg
.split_once('=')
.map(|(_, value)| value.to_owned())
.ok_or_else(|| CliError::MissingValue(arg.clone()))?;
cli_directives.push(parse_cli_override_argument(&arg, None)?.0);
cli_transfer_sources.push(CliTransferSource::InputFile(value));
}
_ if arg.starts_with('-') => {
let (directive, consumed_next) =
parse_cli_override_argument(&arg, args.peek().map(String::as_str))?;
cli_directives.push(directive);
if consumed_next {
let _ = args.next();
}
}
_ => {
uris.push(arg.clone());
cli_transfer_sources.push(CliTransferSource::Uri(arg.clone()));
}
}
}
Ok(ParsedArguments {
invocation: Invocation::Run { config_path, uris },
profile,
cli_profile: build_cli_profile(cli_directives),
cli_transfer_sources,
})
}
#[must_use]
/// Renders the CLI help surface, optionally filtered by a help query.
pub fn render_help(query: Option<&str>) -> String {
query.map_or_else(help_text, |query| help_text_for_query(Some(query)))
}
#[must_use]
/// Renders the compatibility-oriented help surface.
pub fn render_compatibility_help() -> String {
compatibility_help_text()
}
#[must_use]
/// Renders the CLI version banner.
pub fn render_version() -> String {
cli_version_text()
}
#[must_use]
/// Captures a small compatibility snapshot for smoke tests and docs.
pub fn compatibility_snapshot() -> CompatibilitySnapshot {
CompatibilitySnapshot {
version_banner: render_version(),
help_sections: help_sections().len(),
tracked_protocol_count: compat_ledger().entries.len(),
}
}
@@ -0,0 +1,39 @@
#![doc(hidden)]
#![expect(
clippy::redundant_pub_crate,
reason = "this private HTTP runtime helper facade keeps parent-only transfer helpers available without forcing public-facing docs onto every internal step"
)]
use std::{
borrow::Cow,
collections::VecDeque,
fs,
io::{self, Write as _},
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use aria2_rust_pro_compat::ConfigProfile;
use aria2_rust_pro_core::{RequestGroup, RuntimeConfig};
use aria2_rust_pro_protocol::http::HttpResponseSinkTarget;
use aria2_rust_pro_protocol::{
Downloader, HeaderKind, HttpBody, HttpCompletionState, HttpHeader, HttpMethod,
HttpRequestHeaders, HttpRequestModel, HttpResponseHeaders, HttpResponseModel, HttpSessionModel,
HttpTransferTaskModel, HttpVersion, RangeSpec, RangeUnit, ResponseBody, RetryPolicy,
};
use super::{
CliError, lossless_u64_from_usize, parse_checksum_hook_text, profile_option_value,
saturating_u16_from_usize, saturating_u32_from_usize, saturating_usize_from_u64,
};
pub(crate) use self::{build::*, execution::*, persist::*, planning::*};
#[doc(hidden)]
mod build;
#[doc(hidden)]
mod execution;
#[doc(hidden)]
mod persist;
#[doc(hidden)]
mod planning;
@@ -0,0 +1,98 @@
#![doc(hidden)]
use super::{
ConfigProfile, HeaderKind, HttpBody, HttpHeader, HttpMethod, HttpRequestHeaders,
HttpRequestModel, HttpResponseHeaders, HttpResponseSinkTarget, HttpSessionModel,
HttpTransferTaskModel, HttpVersion, PathBuf, RangeSpec, RangeUnit, RequestGroup, ResponseBody,
RuntimeConfig, parse_checksum_hook_text, profile_option_value, saturating_u16_from_usize,
};
pub(crate) fn build_http_transfer_task(
task_id: String,
uri: String,
session: &HttpSessionModel,
runtime: &RuntimeConfig,
profile: Option<&ConfigProfile>,
) -> HttpTransferTaskModel {
build_http_transfer_task_with_target(task_id, uri, session, runtime, profile, None)
}
pub(crate) fn build_http_transfer_task_with_target(
task_id: String,
uri: String,
session: &HttpSessionModel,
runtime: &RuntimeConfig,
profile: Option<&ConfigProfile>,
target_path: Option<PathBuf>,
) -> HttpTransferTaskModel {
let mut headers = session.default_headers.clone();
if let Some(user_agent) = &session.user_agent {
headers.push(HttpHeader {
name: "user-agent".to_owned(),
value: user_agent.clone(),
kind: HeaderKind::Request,
});
}
let connection_budget =
saturating_u16_from_usize(runtime.max_connections_per_server.min(runtime.split.max(1)));
let checksum_hook = profile
.and_then(|profile| profile_option_value(profile, "checksum"))
.and_then(parse_checksum_hook_text);
let response_sink = target_path
.filter(|_| checksum_hook.is_none())
.map(|target_path| HttpResponseSinkTarget { target_path });
let request = HttpRequestModel {
method: HttpMethod::Get,
url: uri,
version: HttpVersion::Http11,
headers: HttpRequestHeaders { headers },
query: std::collections::HashMap::new(),
range: None,
body: HttpBody::Empty,
retry: session.retry,
auth: session.auth.clone(),
proxy: session.proxy.clone(),
response_sink,
};
HttpTransferTaskModel {
task_id,
request,
response_headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Empty,
resume_state: None,
retry_attempts: Vec::new(),
checksum_hook,
max_connections: connection_budget.max(1),
retry: session.retry,
}
}
pub(crate) fn build_segment_transfer_tasks(
base_task: &HttpTransferTaskModel,
group: &RequestGroup,
) -> Vec<HttpTransferTaskModel> {
if group.segment_assignments().is_empty() {
return vec![base_task.clone()];
}
group
.segment_assignments()
.iter()
.map(|assignment| {
let mut task = base_task.clone();
task.request.range = Some(RangeSpec {
start: assignment.range.start,
end_inclusive: Some(assignment.range.end.saturating_sub(1)),
unit: RangeUnit::Bytes,
});
task.resume_state = Some(aria2_rust_pro_protocol::ResumeState {
requested_offset: assignment.range.start,
accepted_offset: None,
resumed: assignment.range.start > 0,
});
task
})
.collect()
}
@@ -0,0 +1,409 @@
#![doc(hidden)]
use super::{
Arc, Cow, Downloader, HttpResponseModel, HttpTransferTaskModel, Mutex, RangeSpec, RangeUnit,
RetryPolicy, RuntimeConfig, VecDeque, planned_segment_span, saturating_u32_from_usize,
saturating_usize_from_u64,
};
const fn should_retry_response(response: &HttpResponseModel, policy: &RetryPolicy) -> bool {
match response.status {
300..=399 => policy.retry_on_3xx,
400..=499 => policy.retry_on_4xx,
500..=599 => policy.retry_on_5xx,
_ => false,
}
}
fn response_total_length(response: &HttpResponseModel) -> Option<u64> {
response.total_length()
}
fn response_completed_length(response: &HttpResponseModel) -> u64 {
response.completed_length()
}
pub(crate) struct HttpTransferExecution {
pub(crate) response: Option<HttpResponseModel>,
pub(crate) retry_count: u32,
pub(crate) retry_attempts: Vec<aria2_rust_pro_protocol::RetryAttempt>,
pub(crate) planned_ranges: Vec<Option<RangeSpec>>,
pub(crate) checksum_observed: bool,
pub(crate) checksum_complete: bool,
}
#[expect(
clippy::single_match_else,
clippy::too_many_lines,
reason = "retry, resume, checksum, and terminal-status branches are kept together to preserve transfer semantics"
)]
pub(crate) fn execute_http_transfer_with_retry<D: Downloader>(
downloader: &D,
base_task: &HttpTransferTaskModel,
runtime: &RuntimeConfig,
) -> HttpTransferExecution {
let max_attempts = base_task.retry.policy.max_attempts.max(1);
let requested_start = base_task
.request
.range
.as_ref()
.map_or(0, |range| range.start);
let requested_end_exclusive = base_task
.request
.range
.as_ref()
.and_then(|range| range.end_inclusive.map(|end| end.saturating_add(1)));
let mut completed_length = requested_start;
let mut retry_attempts = Vec::new();
let mut current_total_length = 0_u64;
let mut planned_ranges = Vec::new();
let mut checksum_observed = false;
let mut checksum_complete = false;
for attempt in 0..max_attempts {
let next_attempt = attempt.saturating_add(1);
let task = if completed_length > requested_start || !retry_attempts.is_empty() {
let mut owned_task = base_task.clone();
if completed_length > requested_start {
let end_inclusive = requested_end_exclusive
.map(|end| end.saturating_sub(1))
.or_else(|| {
planned_segment_span(current_total_length, runtime).and_then(|span| {
let next_end = completed_length.saturating_add(span).saturating_sub(1);
(current_total_length > 0)
.then_some(next_end.min(current_total_length.saturating_sub(1)))
})
});
owned_task.request.range = Some(RangeSpec {
start: completed_length,
end_inclusive,
unit: RangeUnit::Bytes,
});
owned_task.resume_state = Some(aria2_rust_pro_protocol::ResumeState {
requested_offset: completed_length,
accepted_offset: None,
resumed: true,
});
}
if !retry_attempts.is_empty() {
owned_task.retry_attempts.clone_from(&retry_attempts);
}
Cow::Owned(owned_task)
} else {
Cow::Borrowed(base_task)
};
planned_ranges.push(task.request.range);
match downloader.start_http_transfer(task.as_ref()) {
Ok(response) => {
let success = (200..=299).contains(&response.status);
let total_length = response_total_length(&response).unwrap_or(0);
if response.checksum.is_some() {
checksum_observed = true;
}
if total_length > 0 {
current_total_length = total_length;
}
let completed_after = response_completed_length(&response);
if success {
completed_length = completed_length.max(completed_after);
if response.checksum.is_some()
&& total_length > 0
&& completed_length >= total_length
&& response.completion_model().checksum_verified
{
checksum_complete = true;
}
}
let terminal_success = if let Some(segment_end) = requested_end_exclusive {
success && completed_length >= segment_end
} else {
success
&& (!response.partial_content
|| (total_length > 0 && completed_length >= total_length))
};
if terminal_success || next_attempt >= max_attempts {
return HttpTransferExecution {
response: Some(response),
retry_count: saturating_u32_from_usize(retry_attempts.len()),
retry_attempts,
planned_ranges,
checksum_observed,
checksum_complete,
};
}
if success
&& response.partial_content
&& requested_end_exclusive
.is_some_and(|segment_end| completed_length < segment_end)
{
retry_attempts.push(aria2_rust_pro_protocol::RetryAttempt {
attempt: next_attempt,
reason: aria2_rust_pro_protocol::RetryReason::Other,
status: Some(response.status),
backoff_ms: Some(0),
});
continue;
}
if !should_retry_response(&response, &task.retry.policy) {
return HttpTransferExecution {
response: Some(response),
retry_count: saturating_u32_from_usize(retry_attempts.len()),
retry_attempts,
planned_ranges,
checksum_observed,
checksum_complete,
};
}
let retry_reason = match response.status {
300..=399 => aria2_rust_pro_protocol::RetryReason::Http3xx,
400..=499 => aria2_rust_pro_protocol::RetryReason::Http4xx,
500..=599 => aria2_rust_pro_protocol::RetryReason::Http5xx,
_ => aria2_rust_pro_protocol::RetryReason::Other,
};
retry_attempts.push(aria2_rust_pro_protocol::RetryAttempt {
attempt: next_attempt,
reason: retry_reason,
status: Some(response.status),
backoff_ms: None,
});
}
Err(_) => {
if next_attempt >= max_attempts || !task.retry.policy.retry_on_network_error {
return HttpTransferExecution {
response: None,
retry_count: saturating_u32_from_usize(retry_attempts.len()),
retry_attempts,
planned_ranges,
checksum_observed,
checksum_complete,
};
}
retry_attempts.push(aria2_rust_pro_protocol::RetryAttempt {
attempt: next_attempt,
reason: aria2_rust_pro_protocol::RetryReason::NetworkError,
status: None,
backoff_ms: None,
});
}
}
}
HttpTransferExecution {
response: None,
retry_count: saturating_u32_from_usize(retry_attempts.len()),
retry_attempts,
planned_ranges,
checksum_observed,
checksum_complete,
}
}
pub(crate) fn execute_tagged_segment_transfers<D: Downloader + Sync, T: Send>(
downloader: &D,
planned_tasks: Vec<(T, HttpTransferTaskModel)>,
runtime: &RuntimeConfig,
) -> Vec<(T, HttpTransferTaskModel, HttpTransferExecution)> {
let parallelism = effective_segment_transfer_parallelism(runtime, planned_tasks.len());
execute_tagged_segment_transfers_with_parallelism(
downloader,
planned_tasks,
runtime,
parallelism,
)
}
pub(crate) fn execute_tagged_segment_transfers_with_parallelism<D: Downloader + Sync, T: Send>(
downloader: &D,
planned_tasks: Vec<(T, HttpTransferTaskModel)>,
runtime: &RuntimeConfig,
parallelism: usize,
) -> Vec<(T, HttpTransferTaskModel, HttpTransferExecution)> {
if planned_tasks.len() <= 1 || parallelism <= 1 {
return planned_tasks
.into_iter()
.map(|(tag, task)| {
let execution = execute_http_transfer_with_retry(downloader, &task, runtime);
(tag, task, execution)
})
.collect();
}
if planned_tasks.len() <= parallelism.saturating_mul(4) {
return execute_tagged_segment_transfers_static_partitioned(
downloader,
planned_tasks,
runtime,
parallelism,
);
}
let task_count = planned_tasks.len();
let chunk_size = task_count
.div_ceil(parallelism.saturating_mul(4).max(1))
.max(1);
let mut chunk_queue = VecDeque::new();
let mut current_chunk = Vec::with_capacity(chunk_size);
for item in planned_tasks.into_iter().enumerate() {
current_chunk.push(item);
if current_chunk.len() >= chunk_size {
chunk_queue.push_back(std::mem::take(&mut current_chunk));
current_chunk = Vec::with_capacity(chunk_size);
}
}
if !current_chunk.is_empty() {
chunk_queue.push_back(current_chunk);
}
let work_chunks = Arc::new(Mutex::new(chunk_queue));
let mut indexed_results = std::thread::scope(|scope| {
let mut handles = Vec::with_capacity(parallelism);
for _ in 0..parallelism {
let work_chunks = Arc::clone(&work_chunks);
handles.push(scope.spawn(move || {
let mut local_results = Vec::new();
loop {
let Some(chunk) = work_chunks
.lock()
.expect("segment work chunk mutex should not be poisoned")
.pop_front()
else {
break;
};
local_results.reserve(chunk.len());
for (index, (tag, task)) in chunk {
let execution =
execute_http_transfer_with_retry(downloader, &task, runtime);
local_results.push((index, (tag, task, execution)));
}
}
local_results
}));
}
handles
.into_iter()
.flat_map(|handle| {
handle
.join()
.expect("dynamic segment transfer worker should not panic")
})
.collect::<Vec<_>>()
});
debug_assert_eq!(indexed_results.len(), task_count);
indexed_results.sort_by_key(|(index, _)| *index);
indexed_results
.into_iter()
.map(|(_, result)| result)
.collect()
}
fn execute_tagged_segment_transfers_static_partitioned<D: Downloader + Sync, T: Send>(
downloader: &D,
planned_tasks: Vec<(T, HttpTransferTaskModel)>,
runtime: &RuntimeConfig,
parallelism: usize,
) -> Vec<(T, HttpTransferTaskModel, HttpTransferExecution)> {
let task_count = planned_tasks.len();
let indexed_chunks = partition_indexed_work_evenly(
planned_tasks.into_iter().enumerate(),
task_count,
parallelism,
);
std::thread::scope(|scope| {
let mut handles = Vec::with_capacity(indexed_chunks.len());
for chunk in indexed_chunks {
handles.push(scope.spawn(move || {
chunk
.into_iter()
.map(|(index, (tag, task))| {
let execution =
execute_http_transfer_with_retry(downloader, &task, runtime);
(index, (tag, task, execution))
})
.collect::<Vec<_>>()
}));
}
let mut ordered_results = std::iter::repeat_with(|| None)
.take(task_count)
.collect::<Vec<_>>();
for handle in handles {
for (index, result) in handle
.join()
.expect("static segment transfer worker should not panic")
{
*ordered_results
.get_mut(index)
.expect("static segment transfer worker should return an in-bounds index") =
Some(result);
}
}
ordered_results
.into_iter()
.map(|result| result.expect("static segment transfer worker should fill every slot"))
.collect()
})
}
pub(crate) fn execute_segment_transfers<D: Downloader + Sync>(
downloader: &D,
planned_tasks: Vec<HttpTransferTaskModel>,
runtime: &RuntimeConfig,
) -> Vec<(HttpTransferTaskModel, HttpTransferExecution)> {
execute_tagged_segment_transfers(
downloader,
planned_tasks.into_iter().map(|task| ((), task)).collect(),
runtime,
)
.into_iter()
.map(|((), task, execution)| (task, execution))
.collect()
}
pub(crate) fn partition_indexed_work_evenly<T>(
indexed_work: impl IntoIterator<Item = (usize, T)>,
task_count: usize,
parallelism: usize,
) -> Vec<Vec<(usize, T)>> {
if task_count == 0 {
return Vec::new();
}
let worker_count = parallelism.max(1).min(task_count);
let mut chunks = std::iter::repeat_with(Vec::new)
.take(worker_count)
.collect::<Vec<_>>();
for (ordinal, item) in indexed_work.into_iter().enumerate() {
let chunk_index = ordinal
.checked_rem(worker_count)
.expect("worker count is nonzero when partitioning indexed work");
chunks
.get_mut(chunk_index)
.expect("round-robin chunk index should be in bounds")
.push(item);
}
chunks.retain(|chunk| !chunk.is_empty());
chunks
}
pub(crate) fn effective_segment_transfer_parallelism(
runtime: &RuntimeConfig,
planned_tasks: usize,
) -> usize {
if planned_tasks <= 1 {
return planned_tasks;
}
let segment_unit = runtime.min_split_size.max(runtime.piece_length).max(1);
let mut parallelism = planned_tasks;
if let Some(limit) = runtime
.max_download_limit
.map(|bytes_per_second| saturating_usize_from_u64(bytes_per_second.div_ceil(segment_unit)))
{
parallelism = parallelism.min(limit.max(1));
}
parallelism.max(1).min(planned_tasks)
}
@@ -0,0 +1,173 @@
#![doc(hidden)]
use super::*;
fn derive_http_target_path(profile: Option<&ConfigProfile>, uri: &str) -> PathBuf {
let dir = profile
.and_then(|profile| profile_option_value(profile, "dir"))
.map(PathBuf::from);
let file_name = profile
.and_then(|profile| profile_option_value(profile, "out"))
.map(ToOwned::to_owned)
.or_else(|| {
uri.rsplit('/')
.next()
.map(|segment| segment.split(['?', '#']).next().unwrap_or(segment))
.filter(|segment| !segment.is_empty())
.map(str::to_owned)
})
.unwrap_or_else(|| "download.bin".to_owned());
match dir {
Some(dir) => dir.join(file_name),
None => PathBuf::from(file_name),
}
}
pub(crate) fn prepare_http_target_path(
profile: Option<&ConfigProfile>,
uri: &str,
) -> Result<PathBuf, CliError> {
let target_path = derive_http_target_path(profile, uri);
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
CliError::Io(format!("failed to create download directory: {error}"))
})?;
}
Ok(target_path)
}
fn copy_temp_file_contents_to_target(
temp_path: &Path,
target_path: &Path,
offset: u64,
truncate: bool,
) -> io::Result<()> {
let mut source = fs::File::open(temp_path)?;
let mut target = fs::OpenOptions::new()
.create(true)
.truncate(truncate)
.write(true)
.open(target_path)?;
io::Seek::seek(&mut target, io::SeekFrom::Start(offset))?;
io::copy(&mut source, &mut target)?;
Ok(())
}
fn write_temp_file_to_target(temp_path: &Path, target_path: &Path, offset: u64) -> io::Result<()> {
if offset == 0 {
if fs::rename(temp_path, target_path).is_ok() {
return Ok(());
}
return copy_temp_file_contents_to_target(temp_path, target_path, 0, true);
}
copy_temp_file_contents_to_target(temp_path, target_path, offset, false)
}
fn write_bytes_to_target(target_path: &Path, offset: u64, payload: &[u8]) -> io::Result<()> {
let mut target = fs::OpenOptions::new()
.create(true)
.truncate(offset == 0)
.write(true)
.open(target_path)?;
io::Seek::seek(&mut target, io::SeekFrom::Start(offset))?;
target.write_all(payload)?;
Ok(())
}
pub(crate) fn persist_http_response_body_to_target(
target_path: &Path,
task: &HttpTransferTaskModel,
response: &HttpResponseModel,
) -> Result<(), CliError> {
let offset = response
.content_range
.as_ref()
.map(|range| range.start)
.or_else(|| task.request.range.as_ref().map(|range| range.start))
.unwrap_or(0);
match &response.body {
ResponseBody::Empty => {
if offset == 0 && !response.partial_content {
let _ = fs::File::create(target_path).map_err(|error| {
CliError::Io(format!(
"failed to create download target {}: {error}",
target_path.display()
))
})?;
}
}
ResponseBody::Inline(bytes) => {
write_bytes_to_target(target_path, offset, bytes).map_err(|error| {
CliError::Io(format!(
"failed to persist inline response body to {}: {error}",
target_path.display()
))
})?;
}
ResponseBody::Streamed { temp_path, .. } => {
if let Some(temp_path) = temp_path {
let write_result = write_temp_file_to_target(temp_path, target_path, offset)
.map_err(|error| {
CliError::Io(format!(
"failed to persist streamed response body to {}: {error}",
target_path.display()
))
});
let _ = fs::remove_file(temp_path);
write_result?;
}
}
}
Ok(())
}
fn persisted_http_target_satisfies_completion(
profile: Option<&ConfigProfile>,
uri: &str,
task: &HttpTransferTaskModel,
response: &HttpResponseModel,
) -> bool {
let Some(total_length) = response.total_length() else {
return false;
};
let target_path = derive_http_target_path(profile, uri);
let Ok(metadata) = fs::metadata(&target_path) else {
return false;
};
if metadata.len() < total_length {
return false;
}
let Some(checksum_hook) = task.checksum_hook.as_ref().filter(|hook| hook.enabled) else {
return true;
};
fs::read(target_path)
.ok()
.and_then(|bytes| checksum_hook.spec.verify_payload(&bytes))
.unwrap_or(false)
}
pub(crate) fn http_execution_completed_via_checksum(
execution: &HttpTransferExecution,
response: &HttpResponseModel,
profile: Option<&ConfigProfile>,
uri: &str,
task: &HttpTransferTaskModel,
) -> bool {
if !execution.checksum_observed {
return false;
}
let completion = response.completion_model();
let response_checksum_complete = matches!(
completion.state,
HttpCompletionState::Complete | HttpCompletionState::Verified
) && execution.checksum_complete;
response_checksum_complete
|| persisted_http_target_satisfies_completion(profile, uri, task, response)
}
@@ -0,0 +1,167 @@
#![doc(hidden)]
use super::{
Downloader, HttpTransferExecution, HttpTransferTaskModel, RangeSpec, RangeUnit, RuntimeConfig,
execute_http_transfer_with_retry, lossless_u64_from_usize,
};
const SMALL_SEGMENT_PROBE_ALIGNMENT_LIMIT: u64 = 64 * 1_024;
pub(crate) struct InitialHttpExecutionPlan {
pub(crate) task: HttpTransferTaskModel,
pub(crate) execution: HttpTransferExecution,
pub(crate) planned_segments: Vec<HttpTransferTaskModel>,
}
pub(crate) fn planned_segment_span(total_length: u64, runtime: &RuntimeConfig) -> Option<u64> {
if runtime.split <= 1 || total_length == 0 {
return None;
}
let split_budget = lossless_u64_from_usize(runtime.split.max(1));
let planned = total_length.div_ceil(split_budget);
Some(
planned
.max(runtime.min_split_size)
.max(runtime.piece_length)
.min(total_length),
)
}
pub(crate) fn build_initial_http_execution_plan<D: Downloader>(
downloader: &D,
base_task: &HttpTransferTaskModel,
runtime: &RuntimeConfig,
) -> InitialHttpExecutionPlan {
if !should_attempt_initial_segment_probe(base_task) {
return InitialHttpExecutionPlan {
task: base_task.clone(),
execution: execute_http_transfer_with_retry(downloader, base_task, runtime),
planned_segments: Vec::new(),
};
}
let probe_task = build_initial_segment_probe_task(base_task, runtime);
let probe_execution = execute_http_transfer_with_retry(downloader, &probe_task, runtime);
let planned_segments = probe_execution
.response
.as_ref()
.and_then(|response| {
response
.partial_content
.then_some((response.total_length(), response.completed_length()))
})
.and_then(|(total_length, completed_length)| {
total_length.map(|total_length| {
build_balanced_segment_transfer_tasks(
base_task,
runtime,
completed_length.min(total_length),
total_length,
)
})
})
.unwrap_or_default();
InitialHttpExecutionPlan {
task: probe_task,
execution: probe_execution,
planned_segments,
}
}
const fn should_attempt_initial_segment_probe(task: &HttpTransferTaskModel) -> bool {
task.request.range.is_none() && task.max_connections > 1
}
fn build_initial_segment_probe_task(
base_task: &HttpTransferTaskModel,
runtime: &RuntimeConfig,
) -> HttpTransferTaskModel {
let mut probe_task = base_task.clone();
let alignment = runtime.min_split_size.max(runtime.piece_length).max(1);
let probe_span =
if base_task.max_connections >= 4 && alignment <= SMALL_SEGMENT_PROBE_ALIGNMENT_LIMIT {
alignment
.saturating_mul(u64::from(base_task.max_connections))
.saturating_mul(2)
} else if base_task.max_connections >= 4 {
alignment.saturating_mul(2)
} else {
alignment
};
probe_task.request.range = Some(RangeSpec {
start: 0,
end_inclusive: Some(probe_span.saturating_sub(1)),
unit: RangeUnit::Bytes,
});
probe_task.resume_state = Some(aria2_rust_pro_protocol::ResumeState {
requested_offset: 0,
accepted_offset: None,
resumed: false,
});
probe_task
}
fn build_balanced_segment_transfer_tasks(
base_task: &HttpTransferTaskModel,
runtime: &RuntimeConfig,
start_offset: u64,
total_length: u64,
) -> Vec<HttpTransferTaskModel> {
if total_length <= start_offset {
return Vec::new();
}
let desired_segments = usize::from(base_task.max_connections.max(1));
let alignment = runtime.min_split_size.max(runtime.piece_length).max(1);
let remaining = total_length.saturating_sub(start_offset);
let mut segment_count = desired_segments
.min(
usize::try_from(remaining.div_ceil(runtime.min_split_size.max(1)))
.unwrap_or(usize::MAX),
)
.max(1);
if desired_segments >= 4 && segment_count > 1 && remaining <= alignment.saturating_mul(2) {
segment_count = 1;
} else if segment_count > 2 && remaining <= alignment.saturating_mul(3) {
segment_count = 2;
}
let mut cursor = start_offset;
let mut planned_tasks = Vec::with_capacity(segment_count);
for segment_index in 0..segment_count {
if cursor >= total_length {
break;
}
let remaining_segments = segment_count.saturating_sub(segment_index);
let remaining_bytes = total_length.saturating_sub(cursor);
let span = if remaining_segments <= 1 {
remaining_bytes
} else {
let target = remaining_bytes.div_ceil(lossless_u64_from_usize(remaining_segments));
let aligned_target = target.div_ceil(alignment).saturating_mul(alignment);
let min_tail = lossless_u64_from_usize(remaining_segments.saturating_sub(1));
aligned_target
.min(remaining_bytes.saturating_sub(min_tail).max(1))
.max(1)
};
let end_exclusive = cursor.saturating_add(span).min(total_length);
let mut task = base_task.clone();
task.request.range = Some(RangeSpec {
start: cursor,
end_inclusive: Some(end_exclusive.saturating_sub(1)),
unit: RangeUnit::Bytes,
});
task.resume_state = Some(aria2_rust_pro_protocol::ResumeState {
requested_offset: cursor,
accepted_offset: None,
resumed: cursor > 0,
});
planned_tasks.push(task);
cursor = end_exclusive;
}
planned_tasks
}
+248
View File
@@ -0,0 +1,248 @@
//! CLI entrypoints and runtime glue for the `aria2-rust-pro` binary.
//!
//! This crate keeps the user-facing command surface, config projection, and
//! downloader-backed execution bridge in one place so the integration suite can
//! verify the observable behavior without reaching into lower layers directly.
#![forbid(unsafe_code)]
#![expect(
clippy::multiple_crate_versions,
reason = "workspace dependency resolution is shared across crates and not owned by cli alone"
)]
use std::{
env,
ffi::OsString,
fs,
io::{self, Read as _},
path::{Path, PathBuf},
};
use aria2_rust_pro_compat::{
ConfigDirective, ConfigDocument, ConfigLocationKind, ConfigParseError, ConfigProfile,
ConfigScope, ConfigSource, OptionKind, cli_version_text, compat::compat_ledger,
compatibility_help_text, help::help_sections, help_text, help_text_for_query, option_spec,
parse_config_line_strict,
};
use aria2_rust_pro_core::RuntimeConfig;
use aria2_rust_pro_protocol::{
ChecksumHookModel, ChecksumSpec, Downloader, HeaderKind, HttpHeader, HttpSessionModel,
Protocol, ReqwestHttpConnector, downloader::ConnectorBackedDownloader,
};
use aria2_rust_pro_rpc::{InProcessRpcDispatcher, RpcValue};
/// CLI argument parsing and compatibility rendering helpers.
mod args;
/// HTTP transfer task planning, retry, and persistence helpers.
mod http_runtime;
/// Parallel HTTP bootstrap and segment follow-up execution helpers.
mod parallel_http_runtime;
/// CLI/config projection and typed option conversion helpers.
mod projection;
/// RPC-daemon launch assembly and listener bootstrap helpers.
mod rpc_daemon;
/// Registered transfer execution and runtime dispatch helpers.
mod runtime_execution;
/// Foreground runtime orchestration façade for parsed invocations.
mod runtime_host;
/// Runtime input expansion, resolution, and dispatcher registration helpers.
mod runtime_planning;
/// Terminal runtime summary collection helpers.
mod runtime_summary;
/// Transfer registration, Metalink expansion, and bootstrap payload helpers.
mod transfer_resolution;
/// Per-transfer execution, BT runtime driving, and live completion helpers.
mod transfer_runtime;
/// CLI-facing domain types used across the private split modules.
mod types;
pub use self::{
args::{
compatibility_snapshot, parse_args, parse_cli, render_compatibility_help, render_help,
render_version,
},
projection::{
classify_transfer, command_surface, derive_http_session, derive_runtime_config,
load_config_report, parse_protocol, profile_option_map,
},
types::{
BtStatusReport, CliError, CommandSurface, CompatibilitySnapshot, ConfigLoadReport,
Invocation, ParsedArguments, RpcLaunchConfig, RuntimeMode, RuntimeReport, StartupProfile,
TransferSelection,
},
};
use self::{
args::{
expand_transfer_entries, merged_profile, metalink_entry_implied_profile,
parse_checksum_hook_text, rpc_option_object,
},
http_runtime::{
build_http_transfer_task, build_http_transfer_task_with_target,
build_initial_http_execution_plan, build_segment_transfer_tasks,
execute_http_transfer_with_retry, execute_segment_transfers,
execute_tagged_segment_transfers, http_execution_completed_via_checksum,
persist_http_response_body_to_target, prepare_http_target_path,
},
projection::{
apply_proxy_auth_overrides, derive_rpc_listen_host, derive_rpc_secret,
lossless_u64_from_usize, parse_bool_text, parse_bt_status_report, parse_csv_text,
parse_proxy_text, profile_option_value, rpc_bool, rpc_u64, saturating_u16_from_usize,
saturating_u32_from_usize, saturating_usize_from_u64,
},
transfer_resolution::{
DispatcherRegistrationKind, build_ftp_transfer_parts, build_sftp_transfer_parts,
register_resolved_entry_with_dispatcher, resolve_transfer_entry_with_downloader,
},
transfer_runtime::{
dispatcher_status_for_gid, dispatcher_status_summary_for_gid, execute_bt_runtime_for_gid,
execute_transfer_for_uri, http_response_is_terminal_success, rpc_status_text,
},
types::{CliTransferSource, TransferInputEntry},
};
#[cfg(test)]
use self::http_runtime::planned_segment_span;
/// Executes a parsed invocation and returns a structured runtime report.
///
/// # Errors
///
/// Returns config, I/O, or in-process RPC errors encountered while wiring the
/// runtime execution surface.
pub fn execute_runtime(invocation: Invocation) -> Result<RuntimeReport, CliError> {
let connector = ReqwestHttpConnector::new().map_err(|error| {
CliError::Io(format!("failed to initialize reqwest connector: {error}"))
})?;
let downloader = ConnectorBackedDownloader::new(connector.clone(), connector);
execute_runtime_with_context(
invocation,
&StartupProfile::default(),
None,
None,
&downloader,
)
}
/// Executes a runtime invocation with explicit startup and CLI override context.
fn execute_runtime_with_context<D: Downloader + Sync>(
invocation: Invocation,
startup: &StartupProfile,
cli_profile: Option<&ConfigProfile>,
cli_transfer_sources: Option<&[CliTransferSource]>,
downloader: &D,
) -> Result<RuntimeReport, CliError> {
runtime_host::execute_runtime_with_downloader_impl(
invocation,
startup,
cli_profile,
cli_transfer_sources,
downloader,
)
}
/// Executes a parsed invocation against the supplied downloader.
///
/// # Errors
///
/// Returns config, I/O, protocol, or in-process RPC errors encountered while
/// wiring the runtime execution surface.
///
/// # Panics
///
/// Panics if a scoped HTTP worker thread panics while the runtime is executing
/// parallel transfer work.
pub fn execute_runtime_with_downloader<D: Downloader + Sync>(
invocation: Invocation,
downloader: &D,
) -> Result<RuntimeReport, CliError> {
execute_runtime_with_context(
invocation,
&StartupProfile::default(),
None,
None,
downloader,
)
}
/// Shared runtime execution entrypoint used by public and parsed-command flows.
///
/// # Errors
///
/// Returns config, I/O, protocol, or in-process RPC errors encountered while
/// executing the supplied invocation with explicit startup and CLI override
/// context.
/// Executes a parsed invocation.
///
/// # Errors
///
/// Returns an error when a referenced config file cannot be read or when the
/// config parser rejects the file contents.
pub fn execute(invocation: Invocation) -> Result<(), CliError> {
match invocation.clone() {
Invocation::Version => {
println!("{}", render_version());
Ok(())
}
Invocation::Help { query } => {
println!("{}", render_help(query.as_deref()));
Ok(())
}
Invocation::Run { .. } => {
let report = execute_runtime(invocation)?;
println!(
"accepted {} uri(s); tracked {}; control-file v{}",
report.accepted_uri_count,
report.tracked_download_count,
report.control_file_version_major
);
Ok(())
}
}
}
/// Runs the RPC daemon surface implied by a parsed CLI invocation.
/// Parses process arguments and executes the resulting invocation.
///
/// # Errors
///
/// Returns any argument parsing, config reading, or config parsing error
/// produced by [`parse_cli`] or [`execute`].
pub fn run_from_env() -> Result<(), CliError> {
let parsed = parse_cli(env::args_os())?;
match command_surface(&parsed) {
CommandSurface::PrintVersion => execute(Invocation::Version),
CommandSurface::PrintHelp { query } => execute(Invocation::Help { query }),
CommandSurface::ValidateConfig {
config_path,
strict,
} => {
let _ = load_config_report(&config_path, strict)?;
Ok(())
}
CommandSurface::Foreground(invocation) => {
let connector = ReqwestHttpConnector::new().map_err(|error| {
CliError::Io(format!("failed to initialize reqwest connector: {error}"))
})?;
let downloader = ConnectorBackedDownloader::new(connector.clone(), connector);
let report = execute_runtime_with_context(
invocation,
&parsed.profile,
parsed.cli_profile.as_ref(),
Some(&parsed.cli_transfer_sources),
&downloader,
)?;
println!(
"accepted {} uri(s); tracked {}; control-file v{}",
report.accepted_uri_count,
report.tracked_download_count,
report.control_file_version_major
);
Ok(())
}
CommandSurface::RpcDaemon { .. } => rpc_daemon::run_rpc_daemon(parsed),
}
}
#[cfg(test)]
/// Regression coverage for CLI parsing, config merging, runtime wiring, and live transport smokes.
mod tests;
+21
View File
@@ -0,0 +1,21 @@
//! Binary entrypoint for the `aria2-rust-pro` CLI.
#![forbid(unsafe_code)]
#![doc = "Binary entrypoint for the aria2-rust-pro CLI."]
use aria2_rust_pro_cli::run_from_env;
use aria2_rust_pro_compat as _;
use aria2_rust_pro_core as _;
use aria2_rust_pro_protocol as _;
use aria2_rust_pro_rpc as _;
use aria2_rust_pro_storage as _;
#[cfg(test)]
use ssh2 as _;
/// Runs the CLI process and exits with a non-zero status on failure.
fn main() {
if let Err(error) = run_from_env() {
eprintln!("{error}");
std::process::exit(1);
}
}
@@ -0,0 +1,627 @@
#![doc(hidden)]
#![expect(
clippy::redundant_pub_crate,
clippy::indexing_slicing,
reason = "this private parallel HTTP runtime module keeps batch execution and follow-up coordination together, and the index usage is constrained by precomputed entry partitions"
)]
use aria2_rust_pro_compat::ConfigProfile;
use aria2_rust_pro_core::RuntimeConfig;
use aria2_rust_pro_protocol::{Downloader, HttpTransferTaskModel};
use std::{
collections::VecDeque,
env,
path::PathBuf,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::Instant,
};
use super::{
CliError, InProcessRpcDispatcher, build_http_transfer_task_with_target,
build_initial_http_execution_plan, build_segment_transfer_tasks,
execute_tagged_segment_transfers, http_execution_completed_via_checksum,
http_response_is_terminal_success, persist_http_response_body_to_target,
prepare_http_target_path,
};
use crate::http_runtime::{
HttpTransferExecution, effective_segment_transfer_parallelism,
execute_tagged_segment_transfers_with_parallelism, partition_indexed_work_evenly,
};
use crate::runtime_planning::RegisteredRuntimeEntry;
struct PreparedParallelHttpEntry {
index: usize,
target_path: PathBuf,
task: HttpTransferTaskModel,
bootstrap_execution: HttpTransferExecution,
planned_segments: Vec<HttpTransferTaskModel>,
}
type PlannedSegmentBatchTag = usize;
fn execute_shared_runtime_segment_batches<D: Downloader + Sync>(
downloader: &D,
planned_segment_batches: Vec<(PlannedSegmentBatchTag, HttpTransferTaskModel)>,
runtime: &RuntimeConfig,
active_download_count: usize,
) -> Vec<(
PlannedSegmentBatchTag,
HttpTransferTaskModel,
HttpTransferExecution,
)> {
if planned_segment_batches.len() <= 1 {
return execute_tagged_segment_transfers(downloader, planned_segment_batches, runtime);
}
let queue_slot_count = planned_segment_batches
.iter()
.map(|(tag, _)| *tag)
.max()
.map_or(0, |index| index.saturating_add(1));
let planned_segment_count = planned_segment_batches.len();
let mut per_download_queues = std::iter::repeat_with(VecDeque::new)
.take(queue_slot_count)
.collect::<Vec<_>>();
let mut active_queue_count = 0usize;
for (tag, task) in planned_segment_batches {
if let Some(queue) = per_download_queues.get_mut(tag) {
if queue.is_empty() {
active_queue_count = active_queue_count.saturating_add(1);
}
queue.push_back((tag, task));
}
}
let mut completed = Vec::with_capacity(planned_segment_count);
while active_queue_count > 0 {
let mut batch = Vec::with_capacity(active_queue_count);
for queue in per_download_queues
.iter_mut()
.filter(|queue| !queue.is_empty())
{
let per_download_parallelism =
effective_segment_transfer_parallelism(runtime, queue.len());
for _ in 0..per_download_parallelism {
if let Some(item) = queue.pop_front() {
batch.push(item);
} else {
break;
}
}
if queue.is_empty() {
active_queue_count = active_queue_count.saturating_sub(1);
}
}
if batch.is_empty() {
break;
}
let parallelism = effective_shared_runtime_segment_parallelism(
runtime,
active_download_count,
batch.len(),
);
completed.extend(execute_tagged_segment_transfers_with_parallelism(
downloader,
batch,
runtime,
parallelism,
));
}
completed
}
fn prepare_parallel_http_entry<D: Downloader + Sync>(
downloader: &D,
entry: &RegisteredRuntimeEntry,
base_profile: Option<&ConfigProfile>,
index: usize,
) -> Result<PreparedParallelHttpEntry, CliError> {
let target_path = prepare_http_target_path(
entry.resolved.entry_profile.as_ref().or(base_profile),
&entry.resolved.uri,
)?;
let task = build_http_transfer_task_with_target(
entry.gid.clone(),
entry.resolved.uri.clone(),
&entry.resolved.http_session,
&entry.resolved.runtime,
entry.resolved.entry_profile.as_ref().or(base_profile),
Some(target_path.clone()),
);
let plan = build_initial_http_execution_plan(downloader, &task, &entry.resolved.runtime);
Ok(PreparedParallelHttpEntry {
index,
target_path,
task: plan.task,
bootstrap_execution: plan.execution,
planned_segments: plan.planned_segments,
})
}
fn prepare_parallel_http_entries_static_partitioned<D: Downloader + Sync>(
downloader: &D,
entries: &[RegisteredRuntimeEntry],
base_profile: Option<&ConfigProfile>,
parallel_http_indices: &[usize],
bootstrap_parallelism: usize,
) -> Result<Vec<PreparedParallelHttpEntry>, CliError> {
let task_count = parallel_http_indices.len();
let indexed_chunks = partition_indexed_work_evenly(
parallel_http_indices.iter().copied().enumerate(),
task_count,
bootstrap_parallelism,
);
std::thread::scope(|scope| {
let mut handles = Vec::with_capacity(indexed_chunks.len());
for chunk in indexed_chunks {
handles.push(scope.spawn(move || {
chunk
.into_iter()
.map(|(position, index)| {
(
position,
prepare_parallel_http_entry(
downloader,
&entries[index],
base_profile,
index,
),
)
})
.collect::<Vec<_>>()
}));
}
let mut ordered_results = std::iter::repeat_with(|| None)
.take(task_count)
.collect::<Vec<_>>();
for handle in handles {
for (position, prepared) in handle
.join()
.expect("static parallel http prepare worker should not panic")
{
ordered_results[position] = Some(prepared);
}
}
ordered_results
.into_iter()
.map(|prepared| {
prepared.expect("static parallel http prepare worker should fill every slot")
})
.collect()
})
}
#[expect(
clippy::too_many_arguments,
reason = "shared-runtime bookkeeping needs dispatcher state, timing, retry counters, and queued follow-up batches in one place"
)]
fn process_prepared_parallel_http_entry(
dispatcher: &mut InProcessRpcDispatcher,
entries: &[RegisteredRuntimeEntry],
base_profile: Option<&ConfigProfile>,
timing_probe: bool,
prepared: PreparedParallelHttpEntry,
planned_segment_batches: &mut Vec<(usize, HttpTransferTaskModel)>,
segment_target_paths: &mut [Option<PathBuf>],
cumulative_retry_counts: &mut [u32],
max_connections: &mut [u16],
) -> Result<(), CliError> {
let PreparedParallelHttpEntry {
index,
target_path,
task,
bootstrap_execution,
mut planned_segments,
} = prepared;
max_connections[index] = task.max_connections;
if let Some(ref response) = bootstrap_execution.response {
cumulative_retry_counts[index] =
cumulative_retry_counts[index].saturating_add(bootstrap_execution.retry_count);
let entry = &entries[index];
let persist_started = timing_probe.then(Instant::now);
persist_http_response_body_to_target(&target_path, &task, response)?;
let persist_elapsed_ms = persist_started
.as_ref()
.map(|started| started.elapsed().as_millis())
.unwrap_or_default();
let completed_via_checksum = http_execution_completed_via_checksum(
&bootstrap_execution,
response,
entry.resolved.entry_profile.as_ref().or(base_profile),
&entry.resolved.uri,
&task,
);
let record_started = timing_probe.then(Instant::now);
dispatcher
.record_http_transfer_result(
&entry.gid,
response,
task.max_connections,
cumulative_retry_counts[index],
!bootstrap_execution.checksum_observed || completed_via_checksum,
)
.map_err(|error| CliError::Rpc(error.message))?;
let record_elapsed_ms = record_started
.as_ref()
.map(|started| started.elapsed().as_millis())
.unwrap_or_default();
let _uri_marked_complete = http_response_is_terminal_success(response)
&& (!bootstrap_execution.checksum_observed || completed_via_checksum);
let needs_segment_followups = response.partial_content
&& response
.total_length()
.is_some_and(|total| response.completed_length() < total);
if needs_segment_followups {
if planned_segments.is_empty() {
let group = dispatcher
.prepare_http_download(&entry.gid)
.map_err(|error| CliError::Rpc(error.message))?;
planned_segments = build_segment_transfer_tasks(&task, &group);
}
if timing_probe {
eprintln!(
"parallel http timing uri={} bootstrap_persist_ms={} bootstrap_record_ms={} planned_segments={} status={} completed={} total_length={:?}",
entry.resolved.uri,
persist_elapsed_ms,
record_elapsed_ms,
planned_segments.len(),
response.status,
response.completed_length(),
response.total_length(),
);
}
planned_segment_batches.extend(
planned_segments
.into_iter()
.map(|planned_task| (index, planned_task)),
);
segment_target_paths[index] = Some(target_path);
} else if timing_probe {
eprintln!(
"parallel http timing uri={} bootstrap_persist_ms={} bootstrap_record_ms={} planned_segments=0 status={} completed={} total_length={:?}",
entry.resolved.uri,
persist_elapsed_ms,
record_elapsed_ms,
response.status,
response.completed_length(),
response.total_length(),
);
}
}
Ok(())
}
#[expect(
clippy::too_many_lines,
reason = "parallel HTTP execution intentionally keeps bootstrap, retry, and segment scheduling in one stateful control flow"
)]
pub(super) fn execute_parallel_http_entries<D: Downloader + Sync>(
dispatcher: &mut InProcessRpcDispatcher,
downloader: &D,
entries: &[RegisteredRuntimeEntry],
base_profile: Option<&ConfigProfile>,
derived_runtime: &RuntimeConfig,
parallel_http_indices: &[usize],
) -> Result<(), CliError> {
let timing_probe = env::var_os("ARIA2_RUST_PRO_HTTP_TIMING").is_some();
let bootstrap_parallelism =
effective_http_bootstrap_parallelism(derived_runtime, parallel_http_indices.len());
let mut planned_segment_batches = Vec::new();
let mut segment_target_paths = std::iter::repeat_with(|| None)
.take(entries.len())
.collect::<Vec<_>>();
let mut cumulative_retry_counts = vec![0_u32; entries.len()];
let mut max_connections = vec![0_u16; entries.len()];
if parallel_http_indices.len() <= 1 || bootstrap_parallelism <= 1 {
for index in parallel_http_indices {
let prepared =
prepare_parallel_http_entry(downloader, &entries[*index], base_profile, *index)?;
process_prepared_parallel_http_entry(
dispatcher,
entries,
base_profile,
timing_probe,
prepared,
&mut planned_segment_batches,
&mut segment_target_paths,
&mut cumulative_retry_counts,
&mut max_connections,
)?;
}
} else if parallel_http_indices.len() <= bootstrap_parallelism.saturating_mul(4) {
for prepared in prepare_parallel_http_entries_static_partitioned(
downloader,
entries,
base_profile,
parallel_http_indices,
bootstrap_parallelism,
)? {
process_prepared_parallel_http_entry(
dispatcher,
entries,
base_profile,
timing_probe,
prepared,
&mut planned_segment_batches,
&mut segment_target_paths,
&mut cumulative_retry_counts,
&mut max_connections,
)?;
}
} else {
let work_indices = Arc::new(parallel_http_indices.to_vec());
let next_index = AtomicUsize::new(0);
let prepared_results = std::thread::scope(|scope| {
let mut handles = Vec::with_capacity(bootstrap_parallelism);
for _ in 0..bootstrap_parallelism {
let work_indices = Arc::clone(&work_indices);
let next_index = &next_index;
handles.push(scope.spawn(move || {
let mut local_results = Vec::new();
loop {
let position = next_index.fetch_add(1, Ordering::Relaxed);
if position >= work_indices.len() {
break;
}
let index = work_indices[position];
let prepared = prepare_parallel_http_entry(
downloader,
&entries[index],
base_profile,
index,
);
local_results.push((position, prepared));
}
local_results
}));
}
handles
.into_iter()
.flat_map(|handle| {
handle
.join()
.expect("parallel http prepare worker should not panic")
})
.collect::<Vec<_>>()
});
let mut prepared_results = prepared_results;
prepared_results.sort_by_key(|(position, _)| *position);
for (_, prepared) in prepared_results {
process_prepared_parallel_http_entry(
dispatcher,
entries,
base_profile,
timing_probe,
prepared?,
&mut planned_segment_batches,
&mut segment_target_paths,
&mut cumulative_retry_counts,
&mut max_connections,
)?;
}
}
let mut pending_segment_records = std::iter::repeat_with(|| None)
.take(entries.len())
.collect::<Vec<_>>();
for (index, planned_task, execution) in execute_shared_runtime_segment_batches(
downloader,
planned_segment_batches,
derived_runtime,
parallel_http_indices.len(),
) {
if let Some(ref response) = execution.response {
cumulative_retry_counts[index] =
cumulative_retry_counts[index].saturating_add(execution.retry_count);
let target_path = segment_target_paths[index]
.as_ref()
.expect("segment follow-up target path should be recorded");
let persist_started = timing_probe.then(Instant::now);
persist_http_response_body_to_target(target_path, &planned_task, response)?;
let persist_elapsed_ms = persist_started
.as_ref()
.map(|started| started.elapsed().as_millis())
.unwrap_or_default();
if timing_probe {
let entry = &entries[index];
eprintln!(
"parallel http segment timing uri={} persist_ms={} status={} completed={} total_length={:?}",
entry.resolved.uri,
persist_elapsed_ms,
response.status,
response.completed_length(),
response.total_length(),
);
}
pending_segment_records[index] = Some((planned_task, execution));
}
}
for (index, pending_record) in pending_segment_records.into_iter().enumerate() {
let Some((planned_task, execution)) = pending_record else {
continue;
};
let Some(ref response) = execution.response else {
continue;
};
let entry = &entries[index];
let completed_via_checksum = http_execution_completed_via_checksum(
&execution,
response,
entry.resolved.entry_profile.as_ref().or(base_profile),
&entry.resolved.uri,
&planned_task,
);
let record_started = timing_probe.then(Instant::now);
dispatcher
.record_http_transfer_result(
&entry.gid,
response,
max_connections[index],
cumulative_retry_counts[index],
!execution.checksum_observed || completed_via_checksum,
)
.map_err(|error| CliError::Rpc(error.message))?;
let record_elapsed_ms = record_started
.as_ref()
.map(|started| started.elapsed().as_millis())
.unwrap_or_default();
if timing_probe {
eprintln!(
"parallel http segment record timing uri={} record_ms={} status={} completed={} total_length={:?}",
entry.resolved.uri,
record_elapsed_ms,
response.status,
response.completed_length(),
response.total_length(),
);
}
}
Ok(())
}
fn effective_http_bootstrap_parallelism(runtime: &RuntimeConfig, entry_count: usize) -> usize {
if entry_count <= 1 {
return entry_count;
}
entry_count.min(runtime.max_downloads.max(1)).max(1)
}
fn effective_shared_runtime_segment_parallelism(
runtime: &RuntimeConfig,
entry_count: usize,
batch_len: usize,
) -> usize {
if batch_len <= 1 {
return batch_len;
}
let active_downloads = entry_count.min(runtime.max_downloads.max(1)).max(1);
let connection_budget =
active_downloads.saturating_mul(runtime.effective_max_connections_per_server().max(1));
batch_len
.min(connection_budget.max(runtime.worker_threads.max(1)))
.max(1)
}
#[cfg(test)]
mod tests {
use super::{
effective_shared_runtime_segment_parallelism, execute_shared_runtime_segment_batches,
};
use aria2_rust_pro_core::RuntimeConfig;
use aria2_rust_pro_protocol::{
FixtureHttpDownloader, HttpBody, HttpMethod, HttpRequestHeaders, HttpRequestModel,
HttpResponseHeaders, HttpTransferTaskModel, HttpVersion, ResponseBody, RetryPolicy,
RetryStrategy,
};
use std::collections::HashMap;
fn 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,
}
}
fn task(url: &str) -> HttpTransferTaskModel {
HttpTransferTaskModel {
task_id: url.to_owned(),
request: HttpRequestModel {
method: HttpMethod::Get,
url: url.to_owned(),
version: HttpVersion::Http11,
headers: HttpRequestHeaders {
headers: Vec::new(),
},
query: HashMap::new(),
range: None,
body: HttpBody::Empty,
retry: retry_strategy(),
auth: None,
proxy: None,
response_sink: None,
},
response_headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Empty,
resume_state: None,
retry_attempts: Vec::new(),
checksum_hook: None,
max_connections: 1,
retry: retry_strategy(),
}
}
#[test]
fn shared_runtime_segment_batches_keep_sparse_entry_indices() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://127.0.0.1/sparse-two", b"two");
downloader.register("http://127.0.0.1/sparse-five", b"five");
let completed = execute_shared_runtime_segment_batches(
&downloader,
vec![
(2, task("http://127.0.0.1/sparse-two")),
(5, task("http://127.0.0.1/sparse-five")),
],
&RuntimeConfig::default(),
2,
);
assert_eq!(completed.len(), 2);
let mut indices = completed
.into_iter()
.map(|(index, _, _)| index)
.collect::<Vec<_>>();
indices.sort_unstable();
assert_eq!(indices, vec![2, 5]);
}
#[test]
fn shared_runtime_segment_parallelism_uses_admitted_download_count() {
let runtime = RuntimeConfig {
worker_threads: 4,
max_active_downloads: 5,
max_downloads: 16,
max_connections_per_server: 4,
max_connection_per_server: 4,
..RuntimeConfig::default()
};
assert_eq!(
effective_shared_runtime_segment_parallelism(&runtime, 6, 24),
24
);
}
}
+708
View File
@@ -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);
}
}
+126
View File
@@ -0,0 +1,126 @@
#![doc(hidden)]
#![expect(
clippy::needless_pass_by_value,
clippy::redundant_pub_crate,
reason = "this private daemon-launch module keeps ownership explicit across listener bootstrap helpers"
)]
use std::{
net::{IpAddr, Ipv4Addr, TcpListener},
sync::{Arc, Mutex},
};
use aria2_rust_pro_core::RuntimeConfig;
use aria2_rust_pro_rpc::{
InProcessRpcDispatcher, JsonRpcRequest, RpcMeta, RpcMethod, RpcServerConfig, RpcValue,
serve_rpc_listener,
};
use super::{
CliError, ParsedArguments, TransferInputEntry, derive_rpc_listen_host, derive_rpc_secret,
derive_runtime_config, expand_transfer_entries, load_config_report, merged_profile,
rpc_option_object,
};
use crate::Invocation;
#[derive(Debug)]
struct RpcDaemonLaunch {
runtime: RuntimeConfig,
listen_ip: IpAddr,
secret: Option<String>,
input_entries: Vec<TransferInputEntry>,
}
/// Runs the RPC daemon surface implied by a parsed CLI invocation.
pub(super) fn run_rpc_daemon(parsed: ParsedArguments) -> Result<(), CliError> {
let (config_path, inputs) = match &parsed.invocation {
Invocation::Run { config_path, uris } => (config_path.clone(), uris.clone()),
Invocation::Version | Invocation::Help { .. } => {
return Err(CliError::Io(
"rpc daemon launch requires a run invocation".to_owned(),
));
}
};
let launch = build_rpc_daemon_launch(&parsed, config_path, inputs)?;
let listener = bind_rpc_listener(launch.listen_ip, launch.runtime.rpc_port)?;
let mut dispatcher = InProcessRpcDispatcher::with_runtime(launch.runtime);
seed_dispatcher_with_inputs(&mut dispatcher, launch.input_entries);
let server_config = build_rpc_server_config(&listener, launch.secret)?;
println!(
"rpc daemon listening on {}:{}",
launch.listen_ip,
server_config.listen_addr.port()
);
serve_rpc_listener(listener, server_config, Arc::new(Mutex::new(dispatcher)))
.map_err(|error| CliError::Io(format!("rpc daemon serve failed: {error}")))
}
fn build_rpc_daemon_launch(
parsed: &ParsedArguments,
config_path: Option<std::path::PathBuf>,
inputs: Vec<String>,
) -> Result<RpcDaemonLaunch, CliError> {
let config_report = config_path
.as_ref()
.map(|path| load_config_report(path, true))
.transpose()?;
let file_profile = config_report.as_ref().map(|report| &report.profile);
let effective_profile = merged_profile(file_profile, parsed.cli_profile.as_ref());
let profile = effective_profile.as_ref();
let runtime = derive_runtime_config(profile, &parsed.profile);
let listen_host = derive_rpc_listen_host(profile, &parsed.profile);
let secret = derive_rpc_secret(profile, &parsed.profile);
let listen_ip = listen_host
.parse::<IpAddr>()
.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
let input_entries =
expand_transfer_entries(&inputs, profile, Some(&parsed.cli_transfer_sources))?;
Ok(RpcDaemonLaunch {
runtime,
listen_ip,
secret,
input_entries,
})
}
fn bind_rpc_listener(listen_ip: IpAddr, listen_port: u16) -> Result<TcpListener, CliError> {
TcpListener::bind((listen_ip, listen_port))
.map_err(|error| CliError::Io(format!("rpc daemon bind failed: {error}")))
}
fn build_rpc_server_config(
listener: &TcpListener,
secret: Option<String>,
) -> Result<RpcServerConfig, CliError> {
Ok(RpcServerConfig {
listen_addr: listener
.local_addr()
.map_err(|error| CliError::Io(format!("rpc daemon local addr failed: {error}")))?,
secret_token: secret,
..RpcServerConfig::default()
})
}
fn seed_dispatcher_with_inputs(
dispatcher: &mut InProcessRpcDispatcher,
input_entries: Vec<TransferInputEntry>,
) {
for entry in input_entries {
let mut params = vec![RpcValue::Array(
entry.uris.into_iter().map(RpcValue::String).collect(),
)];
if let Some(options) = rpc_option_object(entry.profile.as_ref()) {
params.push(options);
}
let _ = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params,
meta: RpcMeta::default(),
});
}
}
@@ -0,0 +1,88 @@
#![doc(hidden)]
#![expect(
clippy::redundant_pub_crate,
reason = "this private runtime-execution module exposes parent-only helpers across the split CLI runtime facade"
)]
use aria2_rust_pro_compat::ConfigProfile;
use aria2_rust_pro_core::RuntimeConfig;
use aria2_rust_pro_protocol::{Downloader, Protocol};
use super::{
CliError, DispatcherRegistrationKind, InProcessRpcDispatcher, TransferSelection,
classify_transfer, execute_bt_runtime_for_gid, execute_transfer_for_uri, parse_protocol,
};
use crate::{
parallel_http_runtime::execute_parallel_http_entries, runtime_planning::RegisteredRuntimeEntry,
};
pub(super) use crate::runtime_summary::collect_runtime_execution_summary;
pub(super) fn execute_registered_transfers<D: Downloader + Sync>(
dispatcher: &mut InProcessRpcDispatcher,
downloader: &D,
entries: &[RegisteredRuntimeEntry],
base_profile: Option<&ConfigProfile>,
derived_runtime: &RuntimeConfig,
) -> Result<(), CliError> {
let (mut serial_indices, parallel_http_indices) = partition_runtime_entries(entries);
if parallel_http_indices.len() > 1 {
execute_parallel_http_entries(
dispatcher,
downloader,
entries,
base_profile,
derived_runtime,
&parallel_http_indices,
)?;
} else {
serial_indices.extend(parallel_http_indices);
}
for index in serial_indices {
let entry = entries
.get(index)
.expect("serial runtime entry index must come from partition_runtime_entries");
match classify_transfer(&entry.resolved.uri) {
TransferSelection::Magnet | TransferSelection::Torrent => {
execute_bt_runtime_for_gid(dispatcher, &entry.gid)?;
}
_ => match entry.registration_kind {
DispatcherRegistrationKind::Uri => {
execute_transfer_for_uri(
dispatcher,
downloader,
&entry.resolved.uri,
&entry.gid,
entry.resolved.entry_profile.as_ref().or(base_profile),
&entry.resolved.http_session,
&entry.resolved.runtime,
)?;
}
DispatcherRegistrationKind::Torrent => {
execute_bt_runtime_for_gid(dispatcher, &entry.gid)?;
}
},
}
}
Ok(())
}
fn partition_runtime_entries(entries: &[RegisteredRuntimeEntry]) -> (Vec<usize>, Vec<usize>) {
let mut serial_indices = Vec::new();
let mut parallel_http_indices = Vec::new();
for (index, entry) in entries.iter().enumerate() {
if entry.registration_kind == DispatcherRegistrationKind::Uri
&& classify_transfer(&entry.resolved.uri) == TransferSelection::Uri
&& matches!(
parse_protocol(&entry.resolved.uri),
Some(Protocol::Http | Protocol::Https)
)
{
parallel_http_indices.push(index);
} else {
serial_indices.push(index);
}
}
(serial_indices, parallel_http_indices)
}
@@ -0,0 +1,113 @@
#![doc(hidden)]
#![expect(
clippy::redundant_pub_crate,
reason = "this private runtime host module is an internal orchestration façade for the CLI crate split"
)]
use std::path::PathBuf;
use aria2_rust_pro_compat::ConfigProfile;
use aria2_rust_pro_core::RuntimeConfig;
use aria2_rust_pro_protocol::Downloader;
use aria2_rust_pro_storage::ControlFileVersion;
use super::{
CliError, CliTransferSource, Invocation, RuntimeReport, StartupProfile, derive_http_session,
};
use crate::{
runtime_execution::{collect_runtime_execution_summary, execute_registered_transfers},
runtime_planning::{build_runtime_input_context, register_runtime_entries},
};
pub(super) fn execute_runtime_with_downloader_impl<D: Downloader + Sync>(
invocation: Invocation,
startup: &StartupProfile,
cli_profile: Option<&ConfigProfile>,
cli_transfer_sources: Option<&[CliTransferSource]>,
downloader: &D,
) -> Result<RuntimeReport, CliError> {
match invocation {
Invocation::Version | Invocation::Help { .. } => Ok(empty_runtime_report()),
Invocation::Run { config_path, uris } => execute_run_invocation(
config_path,
uris,
startup,
cli_profile,
cli_transfer_sources,
downloader,
),
}
}
fn empty_runtime_report() -> RuntimeReport {
RuntimeReport {
accepted_uri_count: 0,
tracked_download_count: 0,
completed_download_count: 0,
first_gid: None,
first_status: None,
first_total_length: None,
first_completed_length: None,
first_connections: None,
recognized_schemes: Vec::new(),
transfer_kinds: Vec::new(),
config_report: None,
derived_runtime: RuntimeConfig::default(),
http_session: derive_http_session(None, &StartupProfile::default()),
control_file_version_major: ControlFileVersion::CURRENT.major(),
first_bt_status: None,
}
}
fn execute_run_invocation<D: Downloader + Sync>(
config_path: Option<PathBuf>,
uris: Vec<String>,
startup: &StartupProfile,
cli_profile: Option<&ConfigProfile>,
cli_transfer_sources: Option<&[CliTransferSource]>,
downloader: &D,
) -> Result<RuntimeReport, CliError> {
let context = build_runtime_input_context(
config_path,
uris,
startup,
cli_profile,
cli_transfer_sources,
downloader,
)?;
let base_profile = context.effective_profile.as_ref();
let mut dispatcher =
super::InProcessRpcDispatcher::with_runtime(context.derived_runtime.clone());
let registered_entries = register_runtime_entries(
&mut dispatcher,
downloader,
context.resolved_entries,
base_profile,
)?;
execute_registered_transfers(
&mut dispatcher,
downloader,
&registered_entries,
base_profile,
&context.derived_runtime,
)?;
let summary = collect_runtime_execution_summary(&mut dispatcher, &registered_entries)?;
Ok(RuntimeReport {
accepted_uri_count: registered_entries.len(),
tracked_download_count: summary.tracked_download_count,
completed_download_count: summary.completed_download_count,
first_gid: summary.first_gid,
first_status: summary.first_status,
first_total_length: summary.first_total_length,
first_completed_length: summary.first_completed_length,
first_connections: summary.first_connections,
recognized_schemes: context.recognized_schemes,
transfer_kinds: context.transfer_kinds,
config_report: context.config_report,
derived_runtime: context.derived_runtime,
http_session: context.http_session,
control_file_version_major: ControlFileVersion::CURRENT.major(),
first_bt_status: summary.first_bt_status,
})
}
@@ -0,0 +1,222 @@
#![doc(hidden)]
#![expect(
clippy::needless_pass_by_value,
clippy::redundant_pub_crate,
reason = "this private runtime-planning module shares parent-only planning structs and helpers across the split CLI runtime"
)]
use std::path::PathBuf;
use aria2_rust_pro_compat::{ConfigParseError, ConfigProfile};
use aria2_rust_pro_core::RuntimeConfig;
use aria2_rust_pro_protocol::{Downloader, HttpSessionModel, Protocol};
use super::{
CliError, CliTransferSource, ConfigLoadReport, DispatcherRegistrationKind,
InProcessRpcDispatcher, StartupProfile, TransferInputEntry, TransferSelection,
classify_transfer, derive_http_session, derive_runtime_config, expand_transfer_entries,
load_config_report, merged_profile, parse_protocol, register_resolved_entry_with_dispatcher,
resolve_transfer_entry_with_downloader,
};
#[derive(Clone, Debug)]
pub(super) struct RuntimeInputContext {
pub(super) config_report: Option<ConfigLoadReport>,
pub(super) effective_profile: Option<ConfigProfile>,
pub(super) recognized_schemes: Vec<String>,
pub(super) transfer_kinds: Vec<TransferSelection>,
pub(super) derived_runtime: RuntimeConfig,
pub(super) http_session: HttpSessionModel,
pub(super) resolved_entries: Vec<ResolvedRuntimeEntry>,
}
#[derive(Clone, Debug)]
pub(super) struct ResolvedRuntimeEntry {
pub(super) entry: TransferInputEntry,
pub(super) entry_profile: Option<ConfigProfile>,
pub(super) runtime: RuntimeConfig,
pub(super) http_session: HttpSessionModel,
pub(super) uri: String,
}
#[derive(Clone, Debug)]
pub(super) struct RegisteredRuntimeEntry {
pub(super) resolved: ResolvedRuntimeEntry,
pub(super) gid: String,
pub(super) registration_kind: DispatcherRegistrationKind,
}
#[derive(Clone, Debug)]
struct DerivedEntryContext {
runtime: RuntimeConfig,
http_session: HttpSessionModel,
}
pub(super) fn build_runtime_input_context<D: Downloader + Sync>(
config_path: Option<PathBuf>,
uris: Vec<String>,
startup: &StartupProfile,
cli_profile: Option<&ConfigProfile>,
cli_transfer_sources: Option<&[CliTransferSource]>,
downloader: &D,
) -> Result<RuntimeInputContext, CliError> {
let config_report = config_path
.as_ref()
.map(|path| load_config_report(path, true))
.transpose()?;
let file_profile = config_report.as_ref().map(|report| &report.profile);
let effective_profile = merged_profile(file_profile, cli_profile);
let profile = effective_profile.as_ref();
let input_entries = expand_transfer_entries(&uris, profile, cli_transfer_sources)?;
let first_entry_profile = input_entries.first().and_then(|entry| {
let implied = merged_profile(entry.implied_profile.as_ref(), profile);
merged_profile(implied.as_ref(), entry.profile.as_ref())
});
let report_profile = first_entry_profile.as_ref().or(profile);
let derived_runtime = derive_runtime_config(report_profile, startup);
let http_session = derive_http_session(report_profile, startup);
let recognized_schemes = input_entries
.iter()
.filter_map(|entry| entry.uris.first())
.filter_map(|uri| parse_protocol(uri).map(Protocol::as_str))
.map(str::to_owned)
.collect::<Vec<_>>();
let transfer_kinds = input_entries
.iter()
.filter_map(|entry| entry.uris.first())
.map(String::as_str)
.map(classify_transfer)
.collect::<Vec<_>>();
let resolved_entries = resolve_runtime_entries(&input_entries, startup, profile, downloader)?;
Ok(RuntimeInputContext {
config_report,
effective_profile,
recognized_schemes,
transfer_kinds,
derived_runtime,
http_session,
resolved_entries,
})
}
pub(super) fn register_runtime_entries<D: Downloader + Sync>(
dispatcher: &mut InProcessRpcDispatcher,
downloader: &D,
entries: Vec<ResolvedRuntimeEntry>,
base_profile: Option<&ConfigProfile>,
) -> Result<Vec<RegisteredRuntimeEntry>, CliError> {
let mut registered_entries = Vec::with_capacity(entries.len());
for resolved in entries {
let (gid, registration_kind) = register_resolved_entry_with_dispatcher(
dispatcher,
downloader,
&resolved.entry,
&resolved.uri,
resolved.entry_profile.as_ref().or(base_profile),
&resolved.http_session,
&resolved.runtime,
)?;
registered_entries.push(RegisteredRuntimeEntry {
resolved,
gid,
registration_kind,
});
}
Ok(registered_entries)
}
fn resolve_runtime_entries<D: Downloader + Sync>(
input_entries: &[TransferInputEntry],
startup: &StartupProfile,
profile: Option<&ConfigProfile>,
downloader: &D,
) -> Result<Vec<ResolvedRuntimeEntry>, CliError> {
let mut resolved_entries = Vec::new();
let mut derived_context_cache = Vec::new();
for entry in input_entries {
let direct_passthrough = entry
.uris
.first()
.is_some_and(|uri| classify_transfer(uri) != TransferSelection::Metalink);
let pre_resolve_profile = merged_profile(
merged_profile(entry.implied_profile.as_ref(), profile).as_ref(),
entry.profile.as_ref(),
);
let pre_resolve_context = derived_entry_context(
&mut derived_context_cache,
pre_resolve_profile.as_ref(),
startup,
);
let expanded_entries = resolve_transfer_entry_with_downloader(
entry,
downloader,
&pre_resolve_context.http_session,
&pre_resolve_context.runtime,
)?;
if direct_passthrough && expanded_entries.len() == 1 {
let resolved_uri = entry.uris.first().cloned().ok_or_else(|| {
CliError::Config(ConfigParseError::InvalidDirective(
"input-file entry missing URI".to_owned(),
))
})?;
let expanded_entry = expanded_entries
.into_iter()
.next()
.expect("direct entry fast path should preserve one resolved entry");
resolved_entries.push(ResolvedRuntimeEntry {
entry: expanded_entry,
entry_profile: pre_resolve_profile,
runtime: pre_resolve_context.runtime,
http_session: pre_resolve_context.http_session,
uri: resolved_uri,
});
continue;
}
for expanded_entry in expanded_entries {
let entry_profile = merged_profile(
merged_profile(expanded_entry.implied_profile.as_ref(), profile).as_ref(),
expanded_entry.profile.as_ref(),
);
let entry_context =
derived_entry_context(&mut derived_context_cache, entry_profile.as_ref(), startup);
let resolved_uri = expanded_entry.uris.first().cloned().ok_or_else(|| {
CliError::Config(ConfigParseError::InvalidDirective(
"input-file entry missing URI".to_owned(),
))
})?;
resolved_entries.push(ResolvedRuntimeEntry {
entry: expanded_entry,
entry_profile,
runtime: entry_context.runtime,
http_session: entry_context.http_session,
uri: resolved_uri,
});
}
}
Ok(resolved_entries)
}
fn derived_entry_context(
cache: &mut Vec<(Option<ConfigProfile>, DerivedEntryContext)>,
profile: Option<&ConfigProfile>,
startup: &StartupProfile,
) -> DerivedEntryContext {
if let Some((_, cached)) = cache
.iter()
.find(|(cached_profile, _)| cached_profile.as_ref() == profile)
{
return cached.clone();
}
let derived = DerivedEntryContext {
runtime: derive_runtime_config(profile, startup),
http_session: derive_http_session(profile, startup),
};
cache.push((profile.cloned(), derived.clone()));
derived
}
@@ -0,0 +1,54 @@
#![doc(hidden)]
#![expect(
clippy::redundant_pub_crate,
reason = "this private runtime summary helper only feeds the CLI runtime host and does not define public-facing API contracts"
)]
use super::{
BtStatusReport, CliError, InProcessRpcDispatcher, dispatcher_status_for_gid,
dispatcher_status_summary_for_gid, parse_bt_status_report, rpc_status_text,
};
use crate::runtime_planning::RegisteredRuntimeEntry;
#[derive(Clone, Debug, Default)]
pub(super) struct RuntimeExecutionSummary {
pub(super) first_gid: Option<String>,
pub(super) tracked_download_count: usize,
pub(super) completed_download_count: usize,
pub(super) first_status: Option<String>,
pub(super) first_total_length: Option<u64>,
pub(super) first_completed_length: Option<u64>,
pub(super) first_connections: Option<u32>,
pub(super) first_bt_status: Option<BtStatusReport>,
}
pub(super) fn collect_runtime_execution_summary(
dispatcher: &mut InProcessRpcDispatcher,
entries: &[RegisteredRuntimeEntry],
) -> Result<RuntimeExecutionSummary, CliError> {
let mut summary = RuntimeExecutionSummary {
first_gid: entries.first().map(|entry| entry.gid.clone()),
tracked_download_count: dispatcher.tracked_download_count(),
..RuntimeExecutionSummary::default()
};
for (index, entry) in entries.iter().enumerate() {
let status = dispatcher_status_summary_for_gid(dispatcher, &entry.gid)?;
let status_value = rpc_status_text(status.status);
if status.status.as_rpc_status() == "complete" {
summary.completed_download_count = summary.completed_download_count.saturating_add(1);
}
if index == 0 {
summary.first_total_length = Some(status.total_length);
summary.first_completed_length = Some(status.completed_length);
summary.first_connections = Some(status.connections);
summary.first_status = Some(status_value);
if status.is_bt {
let full_status = dispatcher_status_for_gid(dispatcher, &entry.gid)?;
summary.first_bt_status = parse_bt_status_report(&full_status);
}
}
}
Ok(summary)
}
+930
View File
@@ -0,0 +1,930 @@
use std::{
collections::VecDeque,
ffi::OsString,
fs,
io::{BufRead, BufReader, Read, Write},
net::{TcpListener, TcpStream},
process::{Child, Command, ExitStatus, Stdio},
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
},
thread,
time::{Duration, Instant},
};
use aria2_rust_pro_core::RuntimeConfig;
use aria2_rust_pro_protocol::{
Downloader, FixtureHttpDownloader, FtpConfigModel, FtpRequestModel, FtpResponseModel,
HttpResponseHeaders, HttpResponseModel, HttpVersion, Protocol, ReqwestHttpConnector,
ResponseBody, SftpConfigModel, SftpRequestModel, SftpResponseModel,
transport::{TransportError, TransportErrorKind},
};
use aria2_rust_pro_rpc::{InProcessRpcDispatcher, JsonRpcRequest, RpcMethod, RpcValue};
use super::http_runtime::partition_indexed_work_evenly;
use super::{
CliError, CliTransferSource, CommandSurface, Invocation, RuntimeMode, StartupProfile,
TransferSelection, build_ftp_transfer_parts, build_http_transfer_task, classify_transfer,
command_surface, derive_http_session, derive_runtime_config, execute,
execute_http_transfer_with_retry, execute_runtime, execute_runtime_with_context,
execute_runtime_with_downloader, execute_segment_transfers, load_config_report, merged_profile,
parse_args, parse_cli, parse_protocol, planned_segment_span, profile_option_map, render_help,
render_version,
};
fn percent_encode_uri_component(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
if matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~') {
encoded.push(char::from(byte));
} else {
encoded.push('%');
std::fmt::Write::write_fmt(&mut encoded, format_args!("{byte:02X}"))
.expect("writing percent-encoded byte into string must succeed");
}
}
encoded
}
fn start_live_bt_tracker_fixture() -> (String, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("local listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let handle = thread::spawn(move || {
for _ in 0..2 {
let (mut stream, _) = listener.accept().expect("tracker client should connect");
let mut request = [0_u8; 2048];
let read = stream.read(&mut request).expect("request should read");
let request_slice = request
.get(..read)
.expect("read length must stay within request buffer");
let request_text = String::from_utf8_lossy(request_slice);
let (payload, path) = if request_text.starts_with("GET /announce?") {
(
b"d8:intervali600e10:tracker id12:cli-live-0015:peers6:\x7f\x00\x00\x01\x1a\xe1e"
.to_vec(),
"/announce",
)
} else {
(
b"d8:completei4e10:downloadedi9e10:incompletei2ee".to_vec(),
"/scrape",
)
};
assert!(request_text.contains(path));
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\n\r\n",
payload.len()
);
stream
.write_all(response.as_bytes())
.expect("headers should write");
stream.write_all(&payload).expect("payload should write");
}
});
(format!("http://{addr}/announce"), handle)
}
fn build_single_file_torrent_bytes(
announce_url: &str,
file_name: &str,
total_length: u64,
piece_length: u64,
) -> Vec<u8> {
fn bencode_bytes(value: &[u8]) -> Vec<u8> {
let mut encoded = format!("{}:", value.len()).into_bytes();
encoded.extend_from_slice(value);
encoded
}
fn bencode_int(value: u64) -> Vec<u8> {
format!("i{value}e").into_bytes()
}
let mut torrent = Vec::new();
torrent.extend_from_slice(b"d8:announce");
torrent.extend_from_slice(&bencode_bytes(announce_url.as_bytes()));
torrent.extend_from_slice(b"4:infod6:length");
torrent.extend_from_slice(&bencode_int(total_length));
torrent.extend_from_slice(b"4:name");
torrent.extend_from_slice(&bencode_bytes(file_name.as_bytes()));
torrent.extend_from_slice(b"12:piece length");
torrent.extend_from_slice(&bencode_int(piece_length));
torrent.extend_from_slice(b"6:pieces20:");
torrent.extend_from_slice(&[0_u8; 20]);
torrent.extend_from_slice(b"ee");
torrent
}
#[test]
fn execute_runtime_with_local_metalink_fixture_expands_multiple_files_and_uses_implied_output_names()
{
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://example.com/alpha.bin", b"abc");
downloader.register("http://example.com/beta.bin", b"hello");
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-metalink-multifile-test");
let _ = fs::create_dir_all(&temp_dir);
let download_dir = temp_dir.join("downloads");
let metalink_path = temp_dir.join("fixture.meta4");
let config_path = temp_dir.join("aria2.conf");
fs::write(
&metalink_path,
r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0">
<file name="alpha.bin">
<hash type="md5">900150983cd24fb0d6963f7d28e17f72</hash>
<url priority="1">http://example.com/alpha.bin</url>
</file>
<file name="beta.bin">
<url priority="1">http://example.com/beta.bin</url>
</file>
</metalink>"#,
)
.expect("metalink file should write");
fs::write(&config_path, format!("dir={}\n", download_dir.display()))
.expect("config should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path),
uris: vec![metalink_path.to_string_lossy().into_owned()],
},
&downloader,
)
.expect("runtime should execute with expanded metalink fixture");
assert_eq!(report.accepted_uri_count, 2);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(3));
assert_eq!(report.first_completed_length, Some(3));
assert_eq!(report.transfer_kinds, vec![TransferSelection::Metalink]);
assert_eq!(report.completed_download_count, 2);
assert_eq!(
fs::read(download_dir.join("alpha.bin")).expect("alpha output should exist"),
b"abc"
);
assert_eq!(
fs::read(download_dir.join("beta.bin")).expect("beta output should exist"),
b"hello"
);
let _ = fs::remove_dir_all(temp_dir);
}
#[derive(Clone, Debug)]
struct SequencedHttpDownloader {
responses: Arc<Mutex<Vec<Result<HttpResponseModel, TransportError>>>>,
requests: Arc<Mutex<Vec<aria2_rust_pro_protocol::HttpTransferTaskModel>>>,
}
struct LocalFtpTestServer {
control_port: u16,
join: Option<thread::JoinHandle<()>>,
}
impl LocalFtpTestServer {
fn spawn(payload: Vec<u8>) -> Self {
let control_listener =
TcpListener::bind("127.0.0.1:0").expect("control listener should bind");
let control_port = control_listener
.local_addr()
.expect("control addr should exist")
.port();
let data_listener = TcpListener::bind("127.0.0.1:0").expect("data listener should bind");
let data_addr = data_listener.local_addr().expect("data addr should exist");
let join = thread::spawn(move || {
let (mut control_stream, _) = control_listener
.accept()
.expect("control connection should arrive");
control_stream
.write_all(b"220 local ftp ready\r\n")
.expect("welcome should write");
let mut control_reader =
BufReader::new(control_stream.try_clone().expect("clone should work"));
loop {
let mut line = String::new();
let read = control_reader
.read_line(&mut line)
.expect("control line should read");
if read == 0 {
break;
}
if line.starts_with("USER ") {
control_stream
.write_all(b"331 password required\r\n")
.expect("USER response should write");
} else if line.starts_with("PASS ") {
control_stream
.write_all(b"230 login ok\r\n")
.expect("PASS response should write");
} else if line.starts_with("TYPE ") {
control_stream
.write_all(b"200 type ok\r\n")
.expect("TYPE response should write");
} else if line.starts_with("PASV") {
let port_hi = data_addr.port().div_euclid(256);
let port_lo = data_addr.port().rem_euclid(256);
let response =
format!("227 Entering Passive Mode (127,0,0,1,{port_hi},{port_lo})\r\n");
control_stream
.write_all(response.as_bytes())
.expect("PASV response should write");
} else if line.starts_with("RETR ") {
control_stream
.write_all(b"150 opening data\r\n")
.expect("RETR prelim response should write");
let (mut data_stream, _) = data_listener
.accept()
.expect("data connection should arrive");
data_stream
.write_all(&payload)
.expect("payload should write");
drop(data_stream);
control_stream
.write_all(b"226 transfer complete\r\n")
.expect("RETR completion should write");
} else if line.starts_with("QUIT") {
control_stream
.write_all(b"221 bye\r\n")
.expect("QUIT response should write");
break;
} else {
control_stream
.write_all(b"500 unsupported\r\n")
.expect("fallback response should write");
}
}
});
Self {
control_port,
join: Some(join),
}
}
fn control_port(&self) -> u16 {
self.control_port
}
fn join(mut self) {
if let Some(join) = self.join.take() {
join.join().expect("ftp server thread should join");
}
}
}
struct LiveFtpSmokeDownloader;
struct LiveSftpSmokeDownloader;
#[derive(Debug)]
struct LocalSftpDockerServer {
container_name: String,
port: u16,
payload_len: usize,
}
impl LocalSftpDockerServer {
const DOCKER_EXEC_TIMEOUT: Duration = Duration::from_secs(5);
const DOCKER_RM_TIMEOUT: Duration = Duration::from_secs(5);
const DOCKER_RUN_TIMEOUT: Duration = Duration::from_secs(30);
fn spawn() -> Option<Self> {
let docker = Command::new("docker")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.ok()?;
if !docker.success() {
return None;
}
let port = Self::pick_port();
let container_name = format!("aria2-rust-pro-sftp-smoke-{port}");
let payload = "hello-from-live-sftp\n";
let payload_len = payload.len();
let port_binding = format!("{port}:22");
let run = Self::docker_status_with_timeout(
&[
"run",
"--rm",
"-d",
"--name",
&container_name,
"-p",
&port_binding,
"atmoz/sftp:debian",
"foo:pass:1001::upload",
],
Self::DOCKER_RUN_TIMEOUT,
)?;
if !run.success() {
return None;
}
for _ in 0..20 {
if Self::docker_status_with_timeout(
&[
"exec",
&container_name,
"sh",
"-lc",
"echo 'hello-from-live-sftp' > /home/foo/upload/hello.txt",
],
Self::DOCKER_EXEC_TIMEOUT,
)
.is_some_and(|status| status.success())
{
if Self::wait_until_ready(port) {
return Some(Self {
container_name,
port,
payload_len,
});
}
break;
}
thread::sleep(Duration::from_millis(250));
}
let _ = Self::docker_status_with_timeout(
&["rm", "-f", &container_name],
Self::DOCKER_RM_TIMEOUT,
);
None
}
fn pick_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.expect("test should pick local port")
.local_addr()
.expect("local addr should exist")
.port()
}
fn host_port(&self) -> u16 {
self.port
}
fn payload_len(&self) -> usize {
self.payload_len
}
fn wait_until_ready(port: u16) -> bool {
for _ in 0..40 {
if let Ok(stream) = TcpStream::connect(("127.0.0.1", port)) {
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(2)));
if let Ok(mut session) = ssh2::Session::new() {
session.set_tcp_stream(stream);
if session.handshake().is_ok()
&& session.userauth_password("foo", "pass").is_ok()
{
return true;
}
}
}
thread::sleep(Duration::from_millis(250));
}
false
}
fn docker_status_with_timeout(args: &[&str], timeout: Duration) -> Option<ExitStatus> {
let mut child = Command::new("docker")
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()?;
Self::wait_child_with_timeout(&mut child, timeout)
}
fn wait_child_with_timeout(child: &mut Child, timeout: Duration) -> Option<ExitStatus> {
let started_at = Instant::now();
loop {
if let Some(status) = child.try_wait().ok()? {
return Some(status);
}
if started_at.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
return None;
}
thread::sleep(Duration::from_millis(100));
}
}
}
impl Drop for LocalSftpDockerServer {
fn drop(&mut self) {
let _ = Self::docker_status_with_timeout(
&["rm", "-f", &self.container_name],
Self::DOCKER_RM_TIMEOUT,
);
}
}
impl LiveFtpSmokeDownloader {
#[expect(
clippy::result_large_err,
reason = "test-only live FTP smoke helpers bubble full transport context for assertions"
)]
fn read_response_line(reader: &mut BufReader<TcpStream>) -> Result<String, TransportError> {
let mut line = String::new();
reader
.read_line(&mut line)
.map_err(|error| TransportError {
kind: TransportErrorKind::Io,
message: format!("failed to read ftp response: {error}"),
source: Some(error.to_string()),
context: None,
})?;
Ok(line)
}
#[expect(
clippy::result_large_err,
reason = "test-only live FTP smoke helpers bubble full transport context for assertions"
)]
fn expect_code(
reader: &mut BufReader<TcpStream>,
expected: u16,
) -> Result<String, TransportError> {
let line = Self::read_response_line(reader)?;
let code = line
.get(0..3)
.and_then(|digits| digits.parse::<u16>().ok())
.ok_or_else(|| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("invalid ftp response line: {line:?}"),
source: None,
context: None,
})?;
if code != expected {
return Err(TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("expected ftp code {expected}, got {code}: {line}"),
source: None,
context: None,
});
}
Ok(line)
}
#[expect(
clippy::result_large_err,
reason = "test-only live FTP smoke helpers bubble full transport context for assertions"
)]
fn write_command(stream: &mut TcpStream, command: &str) -> Result<(), TransportError> {
stream
.write_all(command.as_bytes())
.map_err(|error| TransportError {
kind: TransportErrorKind::Io,
message: format!("failed to write ftp command {command:?}: {error}"),
source: Some(error.to_string()),
context: None,
})
}
#[expect(
clippy::result_large_err,
reason = "test-only live FTP smoke helpers bubble full transport context for assertions"
)]
fn parse_pasv_addr(line: &str) -> Result<(String, u16), TransportError> {
let (_, after_open) = line.split_once('(').ok_or_else(|| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("missing PASV tuple in response: {line}"),
source: None,
context: None,
})?;
let (tuple_text, _) = after_open.split_once(')').ok_or_else(|| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("unterminated PASV tuple in response: {line}"),
source: None,
context: None,
})?;
let parts = tuple_text
.split(',')
.map(str::trim)
.map(str::parse::<u16>)
.collect::<Result<Vec<_>, _>>()
.map_err(|error| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("invalid PASV tuple in response: {line}: {error}"),
source: Some(error.to_string()),
context: None,
})?;
let [a, b, c, d, hi, lo]: [u16; 6] =
parts.try_into().map_err(|parts: Vec<u16>| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("expected 6 PASV tuple parts, got {}: {line}", parts.len()),
source: None,
context: None,
})?;
let host = format!("{a}.{b}.{c}.{d}");
let port = hi
.checked_mul(256)
.and_then(|value| value.checked_add(lo))
.ok_or_else(|| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("invalid PASV port tuple in response: {line}"),
source: None,
context: None,
})?;
Ok((host, port))
}
}
impl Downloader for LiveFtpSmokeDownloader {
fn start_http_transfer(
&self,
_task: &aria2_rust_pro_protocol::HttpTransferTaskModel,
) -> Result<HttpResponseModel, TransportError> {
Err(TransportError {
kind: TransportErrorKind::UnsupportedScheme,
message: "http unused in live ftp smoke".to_owned(),
source: None,
context: None,
})
}
fn start_ftp_transfer(
&self,
config: &FtpConfigModel,
request: &FtpRequestModel,
) -> Result<FtpResponseModel, TransportError> {
let mut control_stream =
TcpStream::connect((config.host.as_str(), config.port)).map_err(|error| {
TransportError {
kind: TransportErrorKind::NotConnected,
message: format!("failed to connect to live ftp smoke server: {error}"),
source: Some(error.to_string()),
context: None,
}
})?;
control_stream
.set_read_timeout(Some(Duration::from_secs(5)))
.expect("read timeout should set");
control_stream
.set_write_timeout(Some(Duration::from_secs(5)))
.expect("write timeout should set");
let mut control_reader =
BufReader::new(control_stream.try_clone().expect("clone should work"));
let _ = Self::expect_code(&mut control_reader, 220)?;
Self::write_command(
&mut control_stream,
&format!(
"USER {}\r\n",
config.username.as_deref().unwrap_or("anonymous")
),
)?;
let _ = Self::expect_code(&mut control_reader, 331)?;
Self::write_command(
&mut control_stream,
&format!("PASS {}\r\n", config.password.as_deref().unwrap_or("")),
)?;
let _ = Self::expect_code(&mut control_reader, 230)?;
Self::write_command(&mut control_stream, "TYPE I\r\n")?;
let _ = Self::expect_code(&mut control_reader, 200)?;
Self::write_command(&mut control_stream, "PASV\r\n")?;
let pasv = Self::expect_code(&mut control_reader, 227)?;
let (data_host, data_port) = Self::parse_pasv_addr(&pasv)?;
let mut data_stream =
TcpStream::connect((data_host.as_str(), data_port)).map_err(|error| {
TransportError {
kind: TransportErrorKind::NotConnected,
message: format!("failed to connect ftp data socket: {error}"),
source: Some(error.to_string()),
context: None,
}
})?;
let retr_path = request.path.as_deref().unwrap_or("/file.bin");
Self::write_command(&mut control_stream, &format!("RETR {retr_path}\r\n"))?;
let _ = Self::expect_code(&mut control_reader, 150)?;
let mut payload = Vec::new();
data_stream
.read_to_end(&mut payload)
.map_err(|error| TransportError {
kind: TransportErrorKind::Io,
message: format!("failed to read ftp data payload: {error}"),
source: Some(error.to_string()),
context: None,
})?;
let completion = Self::expect_code(&mut control_reader, 226)?;
let _ = Self::write_command(&mut control_stream, "QUIT\r\n");
Ok(FtpResponseModel {
code: 226,
message: completion.trim().to_owned(),
data: Some(payload),
path: request.path.clone(),
transferable: true,
})
}
fn start_sftp_transfer(
&self,
_config: &SftpConfigModel,
_request: &SftpRequestModel,
) -> Result<SftpResponseModel, TransportError> {
Err(TransportError {
kind: TransportErrorKind::UnsupportedScheme,
message: "sftp unused in live ftp smoke".to_owned(),
source: None,
context: None,
})
}
}
impl Downloader for LiveSftpSmokeDownloader {
fn start_http_transfer(
&self,
_task: &aria2_rust_pro_protocol::HttpTransferTaskModel,
) -> Result<HttpResponseModel, TransportError> {
Err(TransportError {
kind: TransportErrorKind::UnsupportedScheme,
message: "http unused in live sftp smoke".to_owned(),
source: None,
context: None,
})
}
fn start_ftp_transfer(
&self,
_config: &FtpConfigModel,
_request: &FtpRequestModel,
) -> Result<FtpResponseModel, TransportError> {
Err(TransportError {
kind: TransportErrorKind::UnsupportedScheme,
message: "ftp unused in live sftp smoke".to_owned(),
source: None,
context: None,
})
}
fn start_sftp_transfer(
&self,
config: &SftpConfigModel,
request: &SftpRequestModel,
) -> Result<SftpResponseModel, TransportError> {
let tcp = TcpStream::connect((config.host.as_str(), config.port)).map_err(|error| {
TransportError {
kind: TransportErrorKind::NotConnected,
message: format!("failed to connect live sftp smoke server: {error}"),
source: Some(error.to_string()),
context: None,
}
})?;
tcp.set_read_timeout(Some(Duration::from_secs(8))).ok();
tcp.set_write_timeout(Some(Duration::from_secs(8))).ok();
let mut session = ssh2::Session::new().map_err(|error| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("failed to build ssh2 session: {error}"),
source: Some(error.to_string()),
context: None,
})?;
session.set_tcp_stream(tcp);
session.handshake().map_err(|error| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("ssh handshake failed: {error}"),
source: Some(error.to_string()),
context: None,
})?;
let user = config.username.as_deref().unwrap_or("foo");
let pass = config.password.as_deref().unwrap_or("pass");
session
.userauth_password(user, pass)
.map_err(|error| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("ssh auth failed: {error}"),
source: Some(error.to_string()),
context: None,
})?;
let sftp = session.sftp().map_err(|error| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("failed to start sftp subsystem: {error}"),
source: Some(error.to_string()),
context: None,
})?;
let path = request.path.as_deref().unwrap_or("/upload/hello.txt");
let mut remote_file = sftp.open(path).map_err(|error| TransportError {
kind: TransportErrorKind::Io,
message: format!("failed to open remote path {path}: {error}"),
source: Some(error.to_string()),
context: None,
})?;
let mut payload = Vec::new();
remote_file
.read_to_end(&mut payload)
.map_err(|error| TransportError {
kind: TransportErrorKind::Io,
message: format!("failed to read remote payload: {error}"),
source: Some(error.to_string()),
context: None,
})?;
Ok(SftpResponseModel {
ok: true,
message: "sftp read ok".to_owned(),
payload: Some(payload),
path: request.path.clone(),
transferable: true,
})
}
}
impl SequencedHttpDownloader {
fn new(responses: Vec<Result<HttpResponseModel, TransportError>>) -> Self {
Self {
responses: Arc::new(Mutex::new(responses)),
requests: Arc::new(Mutex::new(Vec::new())),
}
}
fn recorded_requests(&self) -> Vec<aria2_rust_pro_protocol::HttpTransferTaskModel> {
self.requests.lock().expect("lock should work").clone()
}
}
impl Downloader for SequencedHttpDownloader {
fn start_http_transfer(
&self,
task: &aria2_rust_pro_protocol::HttpTransferTaskModel,
) -> Result<HttpResponseModel, TransportError> {
self.requests
.lock()
.expect("lock should work")
.push(task.clone());
let mut guard = self.responses.lock().expect("lock should work");
if guard.is_empty() {
return Err(TransportError {
kind: TransportErrorKind::Io,
message: "no scripted HTTP response remaining".to_owned(),
source: None,
context: None,
});
}
guard.remove(0)
}
fn start_ftp_transfer(
&self,
_config: &FtpConfigModel,
_request: &FtpRequestModel,
) -> Result<FtpResponseModel, TransportError> {
Err(TransportError {
kind: TransportErrorKind::UnsupportedScheme,
message: "unused in test".to_owned(),
source: None,
context: None,
})
}
fn start_sftp_transfer(
&self,
_config: &SftpConfigModel,
_request: &SftpRequestModel,
) -> Result<SftpResponseModel, TransportError> {
Err(TransportError {
kind: TransportErrorKind::UnsupportedScheme,
message: "unused in test".to_owned(),
source: None,
context: None,
})
}
}
#[derive(Clone, Debug)]
struct ConcurrentProbeDownloader {
bootstrap: HttpResponseModel,
bootstrap_delay: Duration,
ranged_responses: Arc<Mutex<VecDeque<(u64, HttpResponseModel)>>>,
requests: Arc<Mutex<Vec<aria2_rust_pro_protocol::HttpTransferTaskModel>>>,
active_calls: Arc<AtomicUsize>,
max_concurrent_calls: Arc<AtomicUsize>,
ranged_delay: Duration,
}
impl ConcurrentProbeDownloader {
fn new(
bootstrap: HttpResponseModel,
ranged_responses: Vec<(u64, HttpResponseModel)>,
ranged_delay: Duration,
) -> Self {
Self {
bootstrap,
bootstrap_delay: Duration::ZERO,
ranged_responses: Arc::new(Mutex::new(VecDeque::from(ranged_responses))),
requests: Arc::new(Mutex::new(Vec::new())),
active_calls: Arc::new(AtomicUsize::new(0)),
max_concurrent_calls: Arc::new(AtomicUsize::new(0)),
ranged_delay,
}
}
fn with_bootstrap_delay(
bootstrap: HttpResponseModel,
bootstrap_delay: Duration,
ranged_responses: Vec<(u64, HttpResponseModel)>,
ranged_delay: Duration,
) -> Self {
Self {
bootstrap,
bootstrap_delay,
ranged_responses: Arc::new(Mutex::new(VecDeque::from(ranged_responses))),
requests: Arc::new(Mutex::new(Vec::new())),
active_calls: Arc::new(AtomicUsize::new(0)),
max_concurrent_calls: Arc::new(AtomicUsize::new(0)),
ranged_delay,
}
}
fn recorded_requests(&self) -> Vec<aria2_rust_pro_protocol::HttpTransferTaskModel> {
self.requests.lock().expect("lock should work").clone()
}
fn max_concurrent_calls(&self) -> usize {
self.max_concurrent_calls.load(Ordering::SeqCst)
}
fn note_active_call(&self) {
let current = self
.active_calls
.fetch_add(1, Ordering::SeqCst)
.saturating_add(1);
let _ =
self.max_concurrent_calls
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |seen| {
(current > seen).then_some(current)
});
}
fn finish_active_call(&self) {
self.active_calls.fetch_sub(1, Ordering::SeqCst);
}
}
impl Downloader for ConcurrentProbeDownloader {
fn start_http_transfer(
&self,
task: &aria2_rust_pro_protocol::HttpTransferTaskModel,
) -> Result<HttpResponseModel, TransportError> {
self.requests
.lock()
.expect("lock should work")
.push(task.clone());
self.note_active_call();
let response = task.request.range.as_ref().map_or_else(
|| {
if !self.bootstrap_delay.is_zero() {
thread::sleep(self.bootstrap_delay);
}
Ok(self.bootstrap.clone())
},
|range| {
thread::sleep(self.ranged_delay);
let mut queued = self.ranged_responses.lock().expect("lock should work");
let index = queued
.iter()
.position(|(start, _)| *start == range.start)
.expect("matching ranged response should exist");
Ok(queued
.remove(index)
.expect("queued ranged response should exist")
.1)
},
);
self.finish_active_call();
response
}
fn start_ftp_transfer(
&self,
_config: &FtpConfigModel,
_request: &FtpRequestModel,
) -> Result<FtpResponseModel, TransportError> {
Err(TransportError {
kind: TransportErrorKind::UnsupportedScheme,
message: "probe downloader does not implement ftp".to_owned(),
source: None,
context: None,
})
}
fn start_sftp_transfer(
&self,
_config: &SftpConfigModel,
_request: &SftpRequestModel,
) -> Result<SftpResponseModel, TransportError> {
Err(TransportError {
kind: TransportErrorKind::UnsupportedScheme,
message: "probe downloader does not implement sftp".to_owned(),
source: None,
context: None,
})
}
}
mod command_surface;
mod integration_surface;
mod runtime_execution;
@@ -0,0 +1,478 @@
use super::*;
#[test]
fn parses_version_switch() {
let invocation = parse_args(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--version"),
])
.expect("version should parse");
assert_eq!(invocation, Invocation::Version);
}
#[test]
fn parses_help_switch() {
let invocation = parse_args(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--help"),
])
.expect("help should parse");
assert_eq!(invocation, Invocation::Help { query: None });
}
#[test]
fn parses_help_filter_switch() {
let invocation = parse_args(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--help=#http"),
])
.expect("filtered help should parse");
assert_eq!(
invocation,
Invocation::Help {
query: Some("#http".to_owned())
}
);
}
#[test]
fn parses_config_path_and_uris() {
let invocation = parse_args(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--conf-path"),
OsString::from("aria2.conf"),
OsString::from("https://example.com/file"),
])
.expect("config args should parse");
match invocation {
Invocation::Run { config_path, uris } => {
assert_eq!(
config_path.as_deref(),
Some(std::path::Path::new("aria2.conf"))
);
assert_eq!(uris, vec!["https://example.com/file"]);
}
other => panic!("unexpected invocation: {other:?}"),
}
}
#[test]
fn renderers_include_the_product_name() {
assert!(render_help(None).contains("Usage: aria2c [OPTIONS]"));
assert!(render_help(Some("#http")).contains("Printing options tagged with \"#http\"."));
assert!(render_version().contains("aria2-rust-pro"));
}
#[test]
fn execute_reads_configuration_files() {
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-test");
fs::create_dir_all(&temp_dir).expect("temp dir should be creatable");
let config_path = temp_dir.join("aria2.conf");
fs::write(&config_path, "max-connection-per-server=4\n").expect("config should be writable");
let invocation = Invocation::Run {
config_path: Some(config_path),
uris: Vec::new(),
};
execute(invocation).expect("config should execute");
}
#[test]
fn rejects_unknown_options() {
let error = parse_args(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--unknown-option"),
])
.expect_err("unknown options should fail");
assert!(matches!(error, CliError::UnknownOption(_)));
}
#[test]
fn execute_runtime_tracks_uris_through_rpc_and_core() {
let report = execute_runtime(Invocation::Run {
config_path: None,
uris: vec![
"https://example.org/file.iso".to_owned(),
"http://example.org/file-2.iso".to_owned(),
],
})
.expect("runtime should execute");
assert_eq!(report.accepted_uri_count, 2);
assert_eq!(report.tracked_download_count, 2);
assert_eq!(report.first_status.as_deref(), Some("error"));
assert_eq!(report.recognized_schemes, vec!["https", "http"]);
assert_eq!(
report.transfer_kinds,
vec![TransferSelection::Uri, TransferSelection::Uri]
);
}
#[test]
fn execute_runtime_with_fixture_http_marks_download_complete() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://example.com/file.bin", b"fixture-body");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec!["http://example.com/file.bin".to_owned()],
},
&downloader,
)
.expect("runtime should execute with fixture downloader");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.tracked_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(12));
assert_eq!(report.first_completed_length, Some(12));
assert_eq!(report.first_connections, Some(1));
assert_eq!(report.recognized_schemes, vec!["http"]);
assert_eq!(report.completed_download_count, 1);
}
#[test]
fn execute_runtime_runs_multiple_http_uris_concurrently_in_one_runtime() {
let downloader = ConcurrentProbeDownloader::with_bootstrap_delay(
HttpResponseModel {
status: 200,
reason: "OK".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
}],
},
body: ResponseBody::Inline(b"test".to_vec()),
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
},
Duration::from_millis(80),
Vec::new(),
Duration::ZERO,
);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec![
"http://example.com/parallel-a.bin".to_owned(),
"http://example.com/parallel-b.bin".to_owned(),
],
},
&downloader,
)
.expect("runtime should execute multiple http uris in one runtime");
assert_eq!(report.accepted_uri_count, 2);
assert_eq!(report.tracked_download_count, 2);
assert_eq!(report.completed_download_count, 2);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert!(
downloader.max_concurrent_calls() >= 2,
"multi-uri same-runtime execution should overlap bootstrap HTTP transfers"
);
assert_eq!(downloader.recorded_requests().len(), 2);
}
#[test]
fn execute_runtime_overlaps_registered_parallel_http_bootstrap_fanout() {
let downloader = ConcurrentProbeDownloader::with_bootstrap_delay(
HttpResponseModel {
status: 200,
reason: "OK".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
}],
},
body: ResponseBody::Inline(b"test".to_vec()),
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
},
Duration::from_millis(80),
Vec::new(),
Duration::ZERO,
);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec![
"http://example.com/capped-a.bin".to_owned(),
"http://example.com/capped-b.bin".to_owned(),
"http://example.com/capped-c.bin".to_owned(),
"http://example.com/capped-d.bin".to_owned(),
],
},
&downloader,
)
.expect("runtime should execute capped parallel http bootstrap in one runtime");
assert_eq!(report.accepted_uri_count, 4);
assert_eq!(report.tracked_download_count, 4);
assert_eq!(report.completed_download_count, 4);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(
downloader.max_concurrent_calls(),
4,
"bootstrap http fanout should use the registered same-runtime HTTP work set"
);
assert_eq!(downloader.recorded_requests().len(), 4);
}
#[test]
fn static_http_work_partition_uses_available_parallelism_evenly() {
let chunks = partition_indexed_work_evenly((0..6).map(|index| (index, index)), 6, 5);
assert_eq!(chunks.len(), 5);
assert_eq!(
chunks.iter().map(Vec::len).max(),
Some(2),
"six tasks over five workers should not collapse to three two-item workers"
);
}
#[test]
fn execute_runtime_with_fixture_ftp_marks_download_complete() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register_ftp(
"ftp://example.com:21/file.bin",
FtpResponseModel {
code: 226,
message: "transfer complete".to_owned(),
data: Some(b"ftp-payload".to_vec()),
path: Some("/file.bin".to_owned()),
transferable: true,
},
);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec!["ftp://example.com/file.bin".to_owned()],
},
&downloader,
)
.expect("runtime should execute with ftp fixture downloader");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(11));
assert_eq!(report.first_completed_length, Some(11));
assert_eq!(report.recognized_schemes, vec!["ftp"]);
assert_eq!(report.completed_download_count, 1);
}
#[test]
fn execute_runtime_with_live_ftp_server_marks_download_complete() {
let payload = b"live-ftp-payload".to_vec();
let ftp_server = LocalFtpTestServer::spawn(payload.clone());
let downloader = LiveFtpSmokeDownloader;
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec![format!(
"ftp://user:pass@127.0.0.1:{}/file.bin",
ftp_server.control_port()
)],
},
&downloader,
)
.expect("runtime should execute with live ftp server");
let payload_len = u64::try_from(payload.len()).expect("payload length should fit in u64");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(payload_len));
assert_eq!(report.first_completed_length, Some(payload_len));
assert_eq!(report.recognized_schemes, vec!["ftp"]);
assert_eq!(report.completed_download_count, 1);
ftp_server.join();
}
#[test]
fn execute_runtime_with_fixture_sftp_marks_download_complete() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register_sftp(
"sftp://example.com:22/file.bin",
SftpResponseModel {
ok: true,
message: "read ok".to_owned(),
payload: Some(b"sftp-payload".to_vec()),
path: Some("/file.bin".to_owned()),
transferable: true,
},
);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec!["sftp://example.com/file.bin".to_owned()],
},
&downloader,
)
.expect("runtime should execute with sftp fixture downloader");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(12));
assert_eq!(report.first_completed_length, Some(12));
assert_eq!(report.recognized_schemes, vec!["sftp"]);
assert_eq!(report.completed_download_count, 1);
}
#[test]
fn execute_runtime_with_live_sftp_server_marks_download_complete() {
let Some(server) = LocalSftpDockerServer::spawn() else {
eprintln!("skipping live sftp smoke: docker unavailable");
return;
};
let downloader = LiveSftpSmokeDownloader;
let expected_len = server.payload_len();
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec![format!(
"sftp://foo:pass@127.0.0.1:{}/upload/hello.txt",
server.host_port()
)],
},
&downloader,
)
.expect("runtime should execute with live sftp server");
let expected_len = u64::try_from(expected_len).expect("payload length should fit in u64");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(expected_len));
assert_eq!(report.first_completed_length, Some(expected_len));
assert_eq!(report.recognized_schemes, vec!["sftp"]);
assert_eq!(report.completed_download_count, 1);
}
#[test]
fn execute_runtime_with_local_metalink_fixture_http_marks_download_complete() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://example.com/file.bin", b"metalink-body");
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-metalink-runtime-test");
let _ = fs::create_dir_all(&temp_dir);
let metalink_path = temp_dir.join("fixture.meta4");
fs::write(
&metalink_path,
r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0">
<file name="file.bin">
<size>13</size>
<url priority="1">http://example.com/file.bin</url>
</file>
</metalink>"#,
)
.expect("metalink file should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec![metalink_path.to_string_lossy().into_owned()],
},
&downloader,
)
.expect("runtime should execute with local metalink fixture");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(13));
assert_eq!(report.first_completed_length, Some(13));
assert_eq!(report.transfer_kinds, vec![TransferSelection::Metalink]);
assert_eq!(report.completed_download_count, 1);
let _ = fs::remove_file(metalink_path);
}
#[test]
fn execute_runtime_with_local_metalink_fixture_prefers_protocol_candidate_resource() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://example.com/preferred.bin", b"preferred-body");
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-metalink-preferred-test");
let _ = fs::create_dir_all(&temp_dir);
let metalink_path = temp_dir.join("fixture.meta4");
fs::write(
&metalink_path,
r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0">
<file name="ignored.bin">
<url priority="1"></url>
</file>
<file name="picked.bin">
<size>14</size>
<url priority="2">http://example.com/first.bin</url>
<url priority="1">http://example.com/preferred.bin</url>
</file>
</metalink>"#,
)
.expect("metalink file should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec![metalink_path.to_string_lossy().into_owned()],
},
&downloader,
)
.expect("runtime should execute with preferred metalink resource");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(14));
assert_eq!(report.first_completed_length, Some(14));
assert_eq!(report.transfer_kinds, vec![TransferSelection::Metalink]);
assert_eq!(report.completed_download_count, 1);
let _ = fs::remove_file(metalink_path);
}
#[test]
fn execute_runtime_with_remote_metalink_fixture_fetches_document_then_downloads_resource() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register(
"http://example.com/doc.meta4",
r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0">
<file name="remote.bin">
<size>11</size>
<url priority="1">http://example.com/remote.bin</url>
</file>
</metalink>"#,
);
downloader.register("http://example.com/remote.bin", b"remote-body");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec!["http://example.com/doc.meta4".to_owned()],
},
&downloader,
)
.expect("runtime should execute with remote metalink fixture");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(11));
assert_eq!(report.first_completed_length, Some(11));
assert_eq!(report.transfer_kinds, vec![TransferSelection::Metalink]);
assert_eq!(report.recognized_schemes, vec!["http"]);
assert_eq!(report.completed_download_count, 1);
}
@@ -0,0 +1,9 @@
pub(super) use super::*;
pub(super) use crate::{args, derive_rpc_listen_host};
mod bt_runtime_and_tracker;
mod cli_overrides_and_proxy;
mod config_and_protocol_surface;
mod http_runtime_and_checksum;
mod input_file_and_source_order;
mod rpc_pressure_and_command_surface;
@@ -0,0 +1,105 @@
use super::*;
#[test]
fn execute_runtime_reports_bt_status_snapshot_for_magnet_inputs() {
let report = execute_runtime(Invocation::Run {
config_path: None,
uris: vec![String::from(
"magnet:?xt=urn:btih:00112233445566778899aabbccddeeff00112233&dn=bt-dht.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce&tr=udp%3A%2F%2Ftracker.example.org%3A6969&x.pe=198.51.100.9%3A51413",
)],
})
.expect("runtime should execute for a bt magnet");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.tracked_download_count, 1);
assert_eq!(report.transfer_kinds, vec![TransferSelection::Magnet]);
assert_eq!(report.recognized_schemes, vec![String::from("magnet")]);
let bt = report
.first_bt_status
.as_ref()
.expect("bt status snapshot should exist");
assert_eq!(bt.is_bt, Some(true));
assert_eq!(bt.metadata_only, Some(true));
assert_eq!(bt.share_time, Some(0));
assert_eq!(bt.share_ratio.as_deref(), Some("0.000"));
assert_eq!(bt.share_ratio_progress.as_deref(), Some("0.000"));
assert_eq!(bt.share_ratio_remaining.as_deref(), Some("0.000"));
assert_eq!(bt.num_seeders, Some(0));
assert!(
bt.announce_list_tier_count.unwrap_or_default() >= 1,
"magnet inputs should retain announce tiers in the cli-visible bt snapshot"
);
assert!(
matches!(bt.magnet_uri.as_deref(), Some(uri) if uri.starts_with("magnet:?xt=urn:btih:")),
"runtime report should preserve the canonical magnet uri"
);
}
#[test]
fn execute_runtime_with_magnet_executes_live_tracker_announce_when_available() {
let (tracker_url, handle) = start_live_bt_tracker_fixture();
let magnet_uri = format!(
"magnet:?xt=urn:btih:00112233445566778899aabbccddeeff00112233&dn=live-tracker.iso&tr={}",
percent_encode_uri_component(&tracker_url)
);
let report = execute_runtime(Invocation::Run {
config_path: None,
uris: vec![magnet_uri],
})
.expect("runtime should execute live tracker-backed magnet orchestration");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.tracked_download_count, 1);
assert_eq!(report.first_connections, Some(1));
let bt = report
.first_bt_status
.as_ref()
.expect("bt status snapshot should exist for tracker-backed magnet");
assert_eq!(bt.is_bt, Some(true));
assert_eq!(bt.metadata_only, Some(true));
assert_eq!(bt.num_seeders, Some(4));
assert!(
bt.announce_list_tier_count.unwrap_or_default() >= 1,
"tracker-backed magnet should retain announce tiers"
);
handle.join().expect("tracker server thread should join");
}
#[test]
fn execute_runtime_with_remote_torrent_url_fetches_payload_and_executes_live_tracker_announce() {
let (tracker_url, handle) = start_live_bt_tracker_fixture();
let torrent_bytes =
build_single_file_torrent_bytes(&tracker_url, "live-tracker.iso", 16_384, 16_384);
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://example.com/live-tracker.torrent", &torrent_bytes);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec!["http://example.com/live-tracker.torrent".to_owned()],
},
&downloader,
)
.expect("runtime should fetch torrent payload then execute live tracker announce");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.tracked_download_count, 1);
assert_eq!(report.transfer_kinds, vec![TransferSelection::Torrent]);
assert_eq!(report.first_connections, Some(1));
let bt = report
.first_bt_status
.as_ref()
.expect("bt status snapshot should exist for remote torrent input");
assert_eq!(bt.is_bt, Some(true));
assert_eq!(bt.metadata_only, Some(false));
assert_eq!(bt.num_seeders, Some(4));
assert!(
bt.announce_list_tier_count.unwrap_or_default() >= 1,
"remote torrent input should register announce tiers through addTorrent"
);
handle.join().expect("tracker server thread should join");
}
@@ -0,0 +1,334 @@
use super::*;
#[test]
fn parse_cli_tracks_rpc_profile() {
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--enable-rpc"),
OsString::from("--rpc-listen-all"),
OsString::from("--rpc-listen-port=16800"),
OsString::from("--rpc-secret=token"),
OsString::from("magnet:?xt=urn:btih:abc"),
])
.expect("cli should parse");
assert!(parsed.profile.rpc.enabled);
assert_eq!(parsed.profile.rpc.listen_host, "0.0.0.0");
assert_eq!(parsed.profile.rpc.listen_port, 16_800);
assert_eq!(parsed.profile.rpc.secret.as_deref(), Some("token"));
}
#[test]
fn parse_cli_routes_short_continue_into_cli_overrides() {
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("-c"),
OsString::from("-R"),
OsString::from("--dir"),
OsString::from("downloads"),
OsString::from("http://example.com/file.bin"),
])
.expect("cli should parse");
match parsed.invocation {
Invocation::Run { config_path, uris } => {
assert_eq!(config_path, None);
assert_eq!(uris, vec!["http://example.com/file.bin"]);
}
other => panic!("unexpected invocation: {other:?}"),
}
let cli_profile = parsed
.cli_profile
.as_ref()
.expect("cli overrides should produce a transient profile");
let options = profile_option_map(cli_profile);
assert_eq!(options.get("continue").map(String::as_str), Some("true"));
assert_eq!(options.get("remote-time").map(String::as_str), Some("true"));
assert_eq!(options.get("dir").map(String::as_str), Some("downloads"));
}
#[test]
fn parse_cli_canonicalizes_long_compat_aliases_and_live_options() {
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--http-want-digest=false"),
OsString::from("--check-certificate"),
OsString::from("false"),
OsString::from("--retry-on-403"),
OsString::from("--all-proxy"),
OsString::from("http://127.0.0.1:8080"),
OsString::from("--max-overall-download-limit=12M"),
OsString::from("http://example.com/file.bin"),
])
.expect("cli should parse compat aliases and live options");
match parsed.invocation {
Invocation::Run { config_path, uris } => {
assert_eq!(config_path, None);
assert_eq!(uris, vec!["http://example.com/file.bin"]);
}
other => panic!("unexpected invocation: {other:?}"),
}
let cli_profile = parsed
.cli_profile
.as_ref()
.expect("cli overrides should produce a transient profile");
let options = profile_option_map(cli_profile);
assert_eq!(
options.get("no-want-digest-header").map(String::as_str),
Some("false")
);
assert_eq!(
options.get("check-certificate").map(String::as_str),
Some("false")
);
assert_eq!(
options.get("retry-on-403").map(String::as_str),
Some("true")
);
assert_eq!(
options.get("all-proxy").map(String::as_str),
Some("http://127.0.0.1:8080")
);
assert_eq!(
options
.get("max-overall-download-limit")
.map(String::as_str),
Some("12M")
);
}
#[test]
fn execute_runtime_with_parsed_cli_overrides_wins_over_config_file() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://example.com/override.bin", b"override-body");
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-inline-override-test");
let _ = fs::create_dir_all(&temp_dir);
let config_target_dir = temp_dir.join("config-downloads");
let cli_target_dir = temp_dir.join("cli-downloads");
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
[
format!("dir={}", config_target_dir.display()),
"out=config.bin".to_owned(),
"split=2".to_owned(),
"max-connection-per-server=4".to_owned(),
"all-proxy=http://user:pass@127.0.0.1:9000".to_owned(),
"check-certificate=true".to_owned(),
"save-session=config.session".to_owned(),
]
.join("\n"),
)
.expect("config should write");
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--conf-path"),
config_path.as_os_str().to_os_string(),
OsString::from("--dir"),
cli_target_dir.as_os_str().to_os_string(),
OsString::from("--out"),
OsString::from("cli.bin"),
OsString::from("--split"),
OsString::from("5"),
OsString::from("--max-connection-per-server"),
OsString::from("7"),
OsString::from("--all-proxy"),
OsString::from("http://user:pass@127.0.0.1:8080"),
OsString::from("--check-certificate"),
OsString::from("false"),
OsString::from("--save-session"),
OsString::from("cli.session"),
OsString::from("http://example.com/override.bin"),
])
.expect("cli should parse");
let report = execute_runtime_with_context(
parsed.invocation,
&parsed.profile,
parsed.cli_profile.as_ref(),
Some(&parsed.cli_transfer_sources),
&downloader,
)
.expect("runtime should honor parsed cli overrides");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_connections, Some(5));
assert_eq!(report.derived_runtime.split, 5);
assert_eq!(report.derived_runtime.max_connections_per_server, 7);
assert_eq!(
report.derived_runtime.session_path.as_deref(),
Some(std::path::Path::new("cli.session"))
);
assert_eq!(
report.http_session.proxy.as_ref().map(|proxy| proxy.port),
Some(8080)
);
assert_eq!(
report.http_session.tls.as_ref().map(|tls| tls.verify_peer),
Some(false)
);
assert_eq!(
fs::read(cli_target_dir.join("cli.bin")).expect("cli target should persist payload"),
b"override-body"
);
assert!(
!config_target_dir.join("config.bin").exists(),
"config target should not win over CLI output overrides"
);
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn derive_http_session_layers_protocol_specific_proxy_auth_over_selected_endpoint() {
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-proxy-auth-layering");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
[
"http-proxy=http://127.0.0.1:8080".to_owned(),
"all-proxy-user=global-user".to_owned(),
"all-proxy-passwd=global-pass".to_owned(),
"http-proxy-user=http-user".to_owned(),
"http-proxy-passwd=http-pass".to_owned(),
String::new(),
]
.join("\n"),
)
.expect("config should be writable");
let report = load_config_report(&config_path, true).expect("config should load");
let session = derive_http_session(Some(&report.profile), &StartupProfile::default());
let proxy = session.proxy.expect("http proxy should be derived");
assert_eq!(proxy.host, "127.0.0.1");
assert_eq!(proxy.port, 8080);
assert_eq!(proxy.username.as_deref(), Some("http-user"));
assert_eq!(proxy.password.as_deref(), Some("http-pass"));
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn derive_http_session_falls_back_to_all_proxy_auth_when_scheme_specific_auth_is_missing() {
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-proxy-auth-fallback");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
[
"https-proxy=http://127.0.0.1:8443".to_owned(),
"all-proxy-user=global-user".to_owned(),
"all-proxy-passwd=global-pass".to_owned(),
String::new(),
]
.join("\n"),
)
.expect("config should be writable");
let report = load_config_report(&config_path, true).expect("config should load");
let session = derive_http_session(Some(&report.profile), &StartupProfile::default());
let proxy = session.proxy.expect("https proxy should be derived");
assert_eq!(proxy.host, "127.0.0.1");
assert_eq!(proxy.port, 8443);
assert_eq!(proxy.username.as_deref(), Some("global-user"));
assert_eq!(proxy.password.as_deref(), Some("global-pass"));
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn build_ftp_transfer_parts_prefers_ftp_proxy_and_honors_ftp_pasv_setting() {
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-ftp-proxy-derivation");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
[
"all-proxy=http://127.0.0.1:9000".to_owned(),
"all-proxy-user=global-user".to_owned(),
"all-proxy-passwd=global-pass".to_owned(),
"ftp-proxy=http://127.0.0.1:2121".to_owned(),
"ftp-proxy-user=ftp-user".to_owned(),
"ftp-proxy-passwd=ftp-pass".to_owned(),
"ftp-pasv=false".to_owned(),
String::new(),
]
.join("\n"),
)
.expect("config should be writable");
let report = load_config_report(&config_path, true).expect("config should load");
let profile = Some(&report.profile);
let session = derive_http_session(profile, &StartupProfile::default());
let (config, request) =
build_ftp_transfer_parts("FTP://download.example.org/file.bin", profile, &session)
.expect("ftp transfer parts should derive");
assert_eq!(config.host, "download.example.org");
assert_eq!(config.port, 21);
assert_eq!(config.mode, aria2_rust_pro_protocol::FtpMode::Active);
let proxy = config.proxy.expect("ftp proxy should be derived");
assert_eq!(proxy.host, "127.0.0.1");
assert_eq!(proxy.port, 2121);
assert_eq!(proxy.username.as_deref(), Some("ftp-user"));
assert_eq!(proxy.password.as_deref(), Some("ftp-pass"));
assert_eq!(request.path.as_deref(), Some("/file.bin"));
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn cli_overrides_win_for_ftp_proxy_auth_and_pasv_mode() {
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-ftp-override-precedence");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
[
"ftp-proxy=http://127.0.0.1:2121".to_owned(),
"ftp-proxy-user=config-user".to_owned(),
"ftp-proxy-passwd=config-pass".to_owned(),
"ftp-pasv=true".to_owned(),
String::new(),
]
.join("\n"),
)
.expect("config should be writable");
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--conf-path"),
config_path.as_os_str().to_os_string(),
OsString::from("--ftp-proxy-user"),
OsString::from("cli-user"),
OsString::from("--ftp-proxy-passwd"),
OsString::from("cli-pass"),
OsString::from("--ftp-pasv"),
OsString::from("false"),
OsString::from("ftp://download.example.org/file.bin"),
])
.expect("cli should parse");
let report = load_config_report(&config_path, true).expect("config should load");
let effective_profile = merged_profile(Some(&report.profile), parsed.cli_profile.as_ref());
let profile = effective_profile.as_ref();
let session = derive_http_session(profile, &parsed.profile);
let (config, _) =
build_ftp_transfer_parts("ftp://download.example.org/file.bin", profile, &session)
.expect("ftp transfer parts should derive");
assert_eq!(config.mode, aria2_rust_pro_protocol::FtpMode::Active);
let proxy = config.proxy.expect("ftp proxy should be derived");
assert_eq!(proxy.username.as_deref(), Some("cli-user"));
assert_eq!(proxy.password.as_deref(), Some("cli-pass"));
let _ = fs::remove_dir_all(temp_dir);
}
@@ -0,0 +1,141 @@
use super::*;
#[test]
fn load_config_report_derives_runtime_and_http_session_semantics() {
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-config-semantics");
fs::create_dir_all(&temp_dir).expect("temp dir should be creatable");
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
[
"rpc-listen-all=true",
"rpc-listen-port=16801",
"listen-port=16901",
"dht-listen-port=16902",
"split=6",
"max-concurrent-downloads=8",
"max-connection-per-server=32",
"max-overall-download-limit=12M",
"max-download-limit=3M",
"max-overall-upload-limit=4M",
"max-upload-limit=2M",
"disk-cache=32M",
"min-split-size=4M",
"piece-length=2M",
"save-session=session.dat",
"save-session-interval=45",
"disable-ipv6=false",
"user-agent=aria2-rust-pro-test",
"header=Accept: */*,X-Test: yes",
"all-proxy=http://user:pass@127.0.0.1:8080",
"no-proxy=localhost,127.0.0.1",
"check-certificate=false",
"retry-wait=5",
"max-tries=7",
"retry-on-403=true",
"",
]
.join("\n"),
)
.expect("config should be writable");
let report = load_config_report(&config_path, true).expect("report should load");
let startup = StartupProfile::default();
let runtime = derive_runtime_config(Some(&report.profile), &startup);
let rpc_listen_host = derive_rpc_listen_host(Some(&report.profile), &startup);
let session = derive_http_session(Some(&report.profile), &startup);
assert_eq!(rpc_listen_host, "0.0.0.0");
assert_eq!(runtime.rpc_port, 16_801);
assert_eq!(runtime.listen_port, 16_902);
assert_eq!(runtime.split, 6);
assert_eq!(runtime.max_active_downloads, 8);
assert_eq!(runtime.max_connections_per_server, 32);
assert_eq!(runtime.max_overall_download_limit, Some(12 * 1024 * 1024));
assert_eq!(runtime.max_download_limit, Some(3 * 1024 * 1024));
assert_eq!(runtime.max_overall_upload_limit, Some(4 * 1024 * 1024));
assert_eq!(runtime.max_upload_limit, Some(2 * 1024 * 1024));
assert_eq!(runtime.disk_cache_bytes, 32 * 1024 * 1024);
assert_eq!(runtime.min_split_size, 4 * 1024 * 1024);
assert_eq!(runtime.piece_length, 2 * 1024 * 1024);
assert_eq!(runtime.save_session_interval_secs, 45);
assert!(runtime.enable_ipv6);
assert_eq!(
runtime.session_path.as_deref(),
Some(std::path::Path::new("session.dat"))
);
assert_eq!(session.user_agent.as_deref(), Some("aria2-rust-pro-test"));
assert_eq!(session.default_headers.len(), 2);
assert_eq!(session.proxy.as_ref().map(|proxy| proxy.port), Some(8080));
assert_eq!(
session
.proxy
.as_ref()
.map(|proxy| proxy.bypass_hosts.clone()),
Some(vec!["localhost".to_owned(), "127.0.0.1".to_owned()])
);
assert_eq!(session.tls.as_ref().map(|tls| tls.verify_peer), Some(false));
assert_eq!(session.retry.policy.max_attempts, 7);
assert_eq!(session.retry.policy.initial_backoff_ms, 5000);
assert!(session.retry.policy.retry_on_4xx);
}
#[test]
fn classify_transfer_and_protocol_cover_bt_and_magnet_surface() {
let cases = vec![
(
"magnet:?xt=urn:btih:00112233445566778899aabbccddeeff00112233",
TransferSelection::Magnet,
Some(Protocol::Magnet),
),
(
"https://cdn.example.org/image.torrent",
TransferSelection::Torrent,
Some(Protocol::Https),
),
(
"http://cdn.example.org/doc.meta4",
TransferSelection::Metalink,
Some(Protocol::Http),
),
(
"magnet:?xt=urn:btih:00112233445566778899aabbccddeeff00112233&tr=udp%3A%2F%2Ftracker.example.org%3A6969&x.pe=198.51.100.9%3A51413",
TransferSelection::Magnet,
Some(Protocol::Magnet),
),
(
"sftp://mirror.example.org/archive.iso",
TransferSelection::Uri,
Some(Protocol::Sftp),
),
(
"HTTPS://cdn.example.org/MIXED.TORRENT",
TransferSelection::Torrent,
Some(Protocol::Https),
),
(
"MAGNET:?xt=urn:btih:00112233445566778899aabbccddeeff00112233",
TransferSelection::Magnet,
Some(Protocol::Magnet),
),
(
"udp://tracker.example.org:6969",
TransferSelection::Uri,
None,
),
];
for (input, expected_kind, expected_protocol) in cases {
assert_eq!(
classify_transfer(input),
expected_kind,
"transfer kind mismatch"
);
assert_eq!(
parse_protocol(input),
expected_protocol,
"protocol parse mismatch"
);
}
}
@@ -0,0 +1,281 @@
use super::*;
#[test]
fn execute_runtime_parallel_live_http_respects_base_profile_checksum_hook() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let handle = thread::spawn(move || {
for _ in 0..2 {
let (mut stream, _) = listener.accept().expect("client should connect");
let mut request = [0_u8; 2048];
let _ = stream.read(&mut request);
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nabc")
.expect("response should write");
}
});
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-parallel-checksum-base-profile");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
format!(
"dir={}\nsplit=1\nchecksum=sha-1=0000000000000000000000000000000000000000\n",
temp_dir.display()
),
)
.expect("config should write");
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let downloader = aria2_rust_pro_protocol::downloader::ConnectorBackedDownloader::new(
connector.clone(),
connector,
);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path),
uris: vec![
format!("http://{addr}/parallel-a.bin"),
format!("http://{addr}/parallel-b.bin"),
],
},
&downloader,
)
.expect("runtime should execute parallel live transfers");
assert_eq!(report.accepted_uri_count, 2);
assert_eq!(report.tracked_download_count, 2);
assert_eq!(report.completed_download_count, 0);
assert_eq!(report.first_status.as_deref(), Some("active"));
handle.join().expect("server thread should join");
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn execute_http_transfer_with_retry_uses_streamed_observed_truth_for_terminal_checksum() {
let downloader = FixtureHttpDownloader::new();
downloader.register_streamed_ok_with_checksum(
"http://example.com/retry-streamed.bin",
b"abc",
"md5",
"900150983cd24fb0d6963f7d28e17f72",
);
let runtime = RuntimeConfig::default();
let session = derive_http_session(None, &StartupProfile::default());
let task = build_http_transfer_task(
"gid-streamed".to_owned(),
"http://example.com/retry-streamed.bin".to_owned(),
&session,
&runtime,
None,
);
let execution = execute_http_transfer_with_retry(&downloader, &task, &runtime);
let response = execution.response.expect("response should exist");
assert_eq!(response.status, 200);
assert!(execution.checksum_observed);
assert!(execution.checksum_complete);
assert_eq!(response.completed_length(), 3);
assert_eq!(response.total_length(), Some(3));
}
#[test]
fn execute_runtime_requires_terminal_success_for_streamed_checksum_completion() {
let downloader = SequencedHttpDownloader::new(vec![Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Streamed {
expected_len: Some(10),
observed_len: Some(5),
observed_digest: Some("900150983cd24fb0d6963f7d28e17f72".to_owned()),
temp_path: None,
},
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 0,
end_inclusive: 4,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: Some(aria2_rust_pro_protocol::ChecksumSpec {
algorithm: "md5".to_owned(),
expected_hex: "900150983cd24fb0d6963f7d28e17f72".to_owned(),
actual_hex: None,
}),
redirected_from: None,
})]);
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-streamed-partial-single-test");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(&config_path, "split=1\n").expect("config should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path.clone()),
uris: vec!["http://example.com/streamed-partial.bin".to_owned()],
},
&downloader,
)
.expect("runtime should execute");
assert_eq!(report.first_status.as_deref(), Some("active"));
assert_eq!(report.first_total_length, Some(10));
assert_eq!(report.first_completed_length, Some(5));
assert_eq!(report.completed_download_count, 0);
let _ = fs::remove_file(config_path);
}
#[test]
fn build_http_transfer_task_caps_connections_by_split_budget() {
let session = derive_http_session(None, &StartupProfile::default());
let runtime = RuntimeConfig {
split: 3,
max_connections_per_server: 8,
..RuntimeConfig::default()
};
let task = build_http_transfer_task(
"gid".to_owned(),
"http://example.com/file.bin".to_owned(),
&session,
&runtime,
None,
);
assert_eq!(task.max_connections, 3);
}
#[test]
fn build_http_transfer_task_derives_checksum_hook_from_profile() {
let session = derive_http_session(None, &StartupProfile::default());
let runtime = RuntimeConfig::default();
let profile = args::config_profile_from_directives(
"checksum-hook",
aria2_rust_pro_compat::ConfigSource::RuntimeOverride,
vec![aria2_rust_pro_compat::ConfigDirective {
name: "checksum".to_owned(),
value: Some("sha-256=abcdef".to_owned()),
}],
)
.expect("profile should exist");
let task = build_http_transfer_task(
"gid-checksum".to_owned(),
"http://example.com/file.bin".to_owned(),
&session,
&runtime,
Some(&profile),
);
assert_eq!(
task.checksum_hook.as_ref().map(|hook| (
hook.spec.algorithm.as_str(),
hook.spec.expected_hex.as_str()
)),
Some(("sha-256", "abcdef"))
);
}
#[test]
fn planned_segment_span_respects_split_and_size_floors() {
let mut runtime = RuntimeConfig {
split: 3,
min_split_size: 4,
piece_length: 4,
..RuntimeConfig::default()
};
assert_eq!(planned_segment_span(10, &runtime), Some(4));
runtime.min_split_size = 8;
runtime.piece_length = 4;
assert_eq!(planned_segment_span(18, &runtime), Some(8));
runtime.split = 1;
assert_eq!(planned_segment_span(18, &runtime), None);
}
#[test]
fn execute_runtime_reports_connection_budget_from_split_and_server_cap() {
let downloader = SequencedHttpDownloader::new(vec![Ok(HttpResponseModel {
status: 200,
reason: "OK".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
}],
},
body: ResponseBody::Inline(b"done".to_vec()),
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
})]);
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-split-test");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(&config_path, "split=3\nmax-connection-per-server=8\n").expect("config should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path.clone()),
uris: vec!["http://example.com/split.bin".to_owned()],
},
&downloader,
)
.expect("runtime should execute with split budget");
assert_eq!(report.first_connections, Some(3));
let recorded = downloader.recorded_requests();
let [request] = recorded.as_slice() else {
panic!("expected exactly one recorded request");
};
assert_eq!(request.max_connections, 3);
let _ = fs::remove_file(config_path);
}
#[test]
fn execute_runtime_persists_http_payload_to_configured_dir() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://example.com/payload.bin", b"fixture-file-body");
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-file-persist-test");
let _ = fs::create_dir_all(&temp_dir);
let target_dir = temp_dir.join("downloads");
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
format!("dir={}\nout=payload.bin\nsplit=1\n", target_dir.display()),
)
.expect("config should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path),
uris: vec!["http://example.com/payload.bin".to_owned()],
},
&downloader,
)
.expect("runtime should execute");
assert_eq!(report.completed_download_count, 1);
assert_eq!(
fs::read(target_dir.join("payload.bin")).expect("payload should persist"),
b"fixture-file-body"
);
let _ = fs::remove_dir_all(temp_dir);
}
@@ -0,0 +1,225 @@
use super::*;
#[test]
fn execute_runtime_with_input_file_cli_option_loads_multiple_downloads() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://example.com/input-a.bin", b"aaaa");
downloader.register("http://example.com/input-b.bin", b"bbbb");
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-input-file-test");
let _ = fs::create_dir_all(&temp_dir);
let input_path = temp_dir.join("downloads.txt");
fs::write(
&input_path,
"http://example.com/input-a.bin\nhttp://example.com/input-b.bin\n",
)
.expect("input-file should write");
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--input-file"),
input_path.as_os_str().to_os_string(),
])
.expect("cli should parse input-file");
let report = execute_runtime_with_context(
parsed.invocation,
&parsed.profile,
parsed.cli_profile.as_ref(),
Some(&parsed.cli_transfer_sources),
&downloader,
)
.expect("runtime should expand input-file downloads");
assert_eq!(report.accepted_uri_count, 2);
assert_eq!(report.tracked_download_count, 2);
assert_eq!(report.completed_download_count, 2);
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn execute_runtime_with_input_file_groups_tab_separated_mirrors_as_one_entity() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://example.com/mirror-a.bin", b"mirror");
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-input-file-mirror-test");
let _ = fs::create_dir_all(&temp_dir);
let input_path = temp_dir.join("downloads.txt");
fs::write(
&input_path,
"http://example.com/mirror-a.bin\thttp://mirror.example.com/mirror-a.bin\n",
)
.expect("input-file should write");
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--input-file"),
input_path.as_os_str().to_os_string(),
])
.expect("cli should parse input-file");
let report = execute_runtime_with_context(
parsed.invocation,
&parsed.profile,
parsed.cli_profile.as_ref(),
Some(&parsed.cli_transfer_sources),
&downloader,
)
.expect("runtime should keep mirror rows as one logical entity");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.tracked_download_count, 1);
assert_eq!(report.completed_download_count, 1);
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn execute_runtime_with_input_file_entry_options_override_global_output_path() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://example.com/from-input.bin", b"from-input");
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-input-file-entry-option-test");
let _ = fs::create_dir_all(&temp_dir);
let config_dir = temp_dir.join("config-output");
let entry_dir = temp_dir.join("entry-output");
let config_path = temp_dir.join("aria2.conf");
let input_path = temp_dir.join("downloads.txt");
fs::write(
&config_path,
[
format!("dir={}", config_dir.display()),
"out=config.bin".to_owned(),
]
.join("\n"),
)
.expect("config should write");
fs::write(
&input_path,
[
"http://example.com/from-input.bin".to_owned(),
format!(" dir={}", entry_dir.display()),
" out=entry.bin".to_owned(),
]
.join("\n"),
)
.expect("input-file should write");
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--conf-path"),
config_path.as_os_str().to_os_string(),
OsString::from("--input-file"),
input_path.as_os_str().to_os_string(),
])
.expect("cli should parse input-file with config");
let report = execute_runtime_with_context(
parsed.invocation,
&parsed.profile,
parsed.cli_profile.as_ref(),
Some(&parsed.cli_transfer_sources),
&downloader,
)
.expect("runtime should apply per-entry input-file overrides");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.completed_download_count, 1);
assert_eq!(
fs::read(entry_dir.join("entry.bin")).expect("entry override target should exist"),
b"from-input"
);
assert!(
!config_dir.join("config.bin").exists(),
"global config output should not win over entry-specific input-file overrides"
);
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn parse_cli_preserves_mixed_uri_and_input_file_source_order() {
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("https://example.com/a.bin"),
OsString::from("--input-file"),
OsString::from("first.txt"),
OsString::from("http://example.com/c.bin"),
OsString::from("-i=second.txt"),
])
.expect("cli should parse mixed uri and input-file sources");
assert_eq!(
parsed.cli_transfer_sources,
vec![
CliTransferSource::Uri("https://example.com/a.bin".to_owned()),
CliTransferSource::InputFile("first.txt".to_owned()),
CliTransferSource::Uri("http://example.com/c.bin".to_owned()),
CliTransferSource::InputFile("second.txt".to_owned()),
]
);
}
#[test]
fn execute_runtime_preserves_cli_source_order_across_repeated_input_files() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("https://example.com/a.bin", b"a");
downloader.register_ftp(
"ftp://example.com:21/b.bin",
FtpResponseModel {
code: 226,
message: "transfer complete".to_owned(),
data: Some(b"b".to_vec()),
path: Some("/b.bin".to_owned()),
transferable: true,
},
);
downloader.register("http://example.com/c.bin", b"c");
downloader.register_sftp(
"sftp://example.com:22/d.txt",
SftpResponseModel {
ok: true,
message: "read ok".to_owned(),
payload: Some(b"d".to_vec()),
path: Some("/d.txt".to_owned()),
transferable: true,
},
);
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-input-file-order-test");
let _ = fs::create_dir_all(&temp_dir);
let first_input = temp_dir.join("first.txt");
let second_input = temp_dir.join("second.txt");
fs::write(&first_input, "ftp://example.com/b.bin\n").expect("first input-file should write");
fs::write(&second_input, "sftp://example.com/d.txt\n").expect("second input-file should write");
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("https://example.com/a.bin"),
OsString::from("--input-file"),
first_input.as_os_str().to_os_string(),
OsString::from("http://example.com/c.bin"),
OsString::from(format!("-i={}", second_input.display())),
])
.expect("cli should parse ordered transfer sources");
let report = execute_runtime_with_context(
parsed.invocation,
&parsed.profile,
parsed.cli_profile.as_ref(),
Some(&parsed.cli_transfer_sources),
&downloader,
)
.expect("runtime should preserve ordered transfer-source expansion");
assert_eq!(report.accepted_uri_count, 4);
assert_eq!(report.tracked_download_count, 4);
assert_eq!(report.completed_download_count, 4);
assert_eq!(
report.recognized_schemes,
vec!["https", "ftp", "http", "sftp"]
);
let _ = fs::remove_dir_all(temp_dir);
}
@@ -0,0 +1,222 @@
use super::*;
#[test]
fn synthetic_bt_like_pressure_keeps_rpc_tell_status_responsive() {
let runtime = RuntimeConfig {
allow_jsonrpc: true,
allow_xmlrpc: true,
..RuntimeConfig::default()
};
let mut dispatcher = InProcessRpcDispatcher::with_runtime(runtime);
// Keep this smoke lightweight: enough concurrency-like pressure to catch
// dispatcher starvation without depending on real BT sessions.
let synthetic_bt_inputs = (0..128)
.map(|index| {
format!(
"magnet:?xt=urn:btih:{:040x}&dn=synthetic-{index}&tr=http://127.0.0.1:6969/announce",
index + 1
)
})
.collect::<Vec<_>>();
let mut gids = Vec::with_capacity(synthetic_bt_inputs.len());
for magnet_uri in &synthetic_bt_inputs {
let add_response = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet_uri.clone())],
meta: aria2_rust_pro_rpc::RpcMeta::default(),
});
match add_response.result {
Some(RpcValue::String(gid)) => gids.push(gid),
_ => panic!("expected gid from addUri, got: {add_response:?}"),
}
}
assert_eq!(
dispatcher.tracked_download_count(),
synthetic_bt_inputs.len()
);
let probe_gid = gids
.first()
.cloned()
.expect("synthetic bt probe should register at least one gid");
let mut ok_rounds = 0_usize;
let probe_rounds = 256_usize;
let deadline = Instant::now() + Duration::from_secs(2);
for _ in 0..probe_rounds {
let status_response = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(probe_gid.clone())],
meta: aria2_rust_pro_rpc::RpcMeta::default(),
});
if matches!(status_response.result, Some(RpcValue::Object(_))) {
ok_rounds += 1;
} else {
panic!("tellStatus should stay responsive: {status_response:?}");
}
assert!(
Instant::now() < deadline,
"synthetic RPC probe exceeded responsiveness budget"
);
}
assert_eq!(ok_rounds, probe_rounds);
}
#[test]
fn command_surface_uses_validate_mode_for_dry_run() {
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--dry-run"),
OsString::from("--conf-path=aria2.conf"),
])
.expect("cli should parse");
match command_surface(&parsed) {
CommandSurface::ValidateConfig {
config_path,
strict,
} => {
assert_eq!(config_path, std::path::PathBuf::from("aria2.conf"));
assert!(strict);
}
other => panic!("unexpected command surface: {other:?}"),
}
}
#[test]
fn parse_cli_bt_batch_inputs_keep_order_and_rpc_daemon_surface() {
let bt_inputs = vec![
"magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=alpha&tr=http://tracker.example.org/a",
"https://tracker.example.org/files/tracker-a.torrent",
"magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&dn=beta&tr=http://tracker.example.org/b",
"http://cdn.example.org/tracker-b.torrent",
];
let mut args = vec![
OsString::from("aria2-rust-pro"),
OsString::from("--enable-rpc"),
OsString::from("--rpc-only"),
OsString::from("--rpc-listen-port=16999"),
OsString::from("--conf-path=bt-runtime.conf"),
];
args.extend(bt_inputs.iter().map(OsString::from));
let parsed = parse_cli(args).expect("bt batch args should parse");
let surface = command_surface(&parsed);
match surface {
CommandSurface::RpcDaemon {
config_path,
inputs,
} => {
assert_eq!(
config_path,
Some(std::path::PathBuf::from("bt-runtime.conf"))
);
assert_eq!(inputs, bt_inputs);
}
other => panic!("expected rpc-daemon command surface, got: {other:?}"),
}
assert_eq!(parsed.profile.mode, RuntimeMode::RpcOnly);
assert_eq!(parsed.profile.rpc.listen_port, 16_999);
}
#[test]
fn synthetic_bt_mixed_status_probe_keeps_runtime_fields_shape_stable() {
let runtime = RuntimeConfig {
allow_jsonrpc: true,
allow_xmlrpc: true,
..RuntimeConfig::default()
};
let mut dispatcher = InProcessRpcDispatcher::with_runtime(runtime);
let mut gids = Vec::new();
for index in 0..48 {
let uri = if index % 3 == 0 {
format!("https://assets.example.org/bt-{index}.torrent")
} else {
format!(
"magnet:?xt=urn:btih:{:040x}&dn=mix-{index}&tr=http://127.0.0.1:6969/announce&tr=udp://127.0.0.1:6969",
index + 100
)
};
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(uri)],
meta: aria2_rust_pro_rpc::RpcMeta::default(),
});
match add.result {
Some(RpcValue::String(gid)) => gids.push(gid),
other => panic!("unexpected addUri result: {other:?}"),
}
}
let mut bt_object_count = 0usize;
for gid in &gids {
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: aria2_rust_pro_rpc::RpcMeta::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert!(
payload.contains_key("status"),
"status key should remain visible under mixed bt load"
);
if payload.get("isBt") == Some(&RpcValue::Bool(true)) {
bt_object_count += 1;
match payload.get("seeder") {
Some(RpcValue::Bool(false)) => {}
Some(RpcValue::String(value)) if value == "false" => {}
other => panic!(
"bt payload should expose seeder as a visible scalar, got: {other:?}"
),
}
assert!(
payload.contains_key("numSeeders"),
"bt payload should expose numSeeders"
);
assert!(
payload.contains_key("shareRatio"),
"bt payload should expose shareRatio"
);
assert!(
payload.contains_key("shareRatioProgress"),
"bt payload should expose shareRatioProgress"
);
assert!(
payload.contains_key("shareRatioRemaining"),
"bt payload should expose shareRatioRemaining"
);
assert!(
payload.contains_key("shareTime"),
"bt payload should expose shareTime"
);
assert!(
payload.contains_key("metadataOnly"),
"bt payload should expose metadataOnly"
);
assert!(
payload.contains_key("announceList"),
"bt payload should expose announceList"
);
}
}
other => panic!("unexpected tellStatus result: {other:?}"),
}
}
assert_eq!(dispatcher.tracked_download_count(), 48);
assert!(
bt_object_count >= 32,
"magnet-heavy mix should be mostly BT"
);
}
@@ -0,0 +1,7 @@
pub(super) use super::*;
mod checksum_completion;
mod live_http_connector;
mod retry_and_partial;
mod segment_parallelism;
mod segment_planning;
@@ -0,0 +1,63 @@
use super::*;
#[test]
fn execute_runtime_marks_completion_when_checksum_seen_on_terminal_success() {
let downloader = SequencedHttpDownloader::new(vec![Ok(HttpResponseModel {
status: 200,
reason: "OK".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "3".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
}],
},
body: ResponseBody::Inline(b"abc".to_vec()),
content_range: None,
partial_content: false,
checksum: Some(aria2_rust_pro_protocol::ChecksumSpec {
algorithm: "sha-1".to_owned(),
expected_hex: "a9993e364706816aba3e25717850c26c9cd0d89d".to_owned(),
actual_hex: None,
}),
redirected_from: None,
})]);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec!["http://example.com/checksum.bin".to_owned()],
},
&downloader,
)
.expect("runtime should treat checksum terminal response as complete");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_completed_length, Some(3));
}
#[test]
fn execute_runtime_uses_streamed_observed_truth_for_checksum_completion() {
let downloader = FixtureHttpDownloader::new();
downloader.register_streamed_ok_with_checksum(
"http://example.com/streamed-checksum.bin",
b"abc",
"md5",
"900150983cd24fb0d6963f7d28e17f72",
);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec!["http://example.com/streamed-checksum.bin".to_owned()],
},
&downloader,
)
.expect("runtime should treat streamed observed checksum as complete");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_completed_length, Some(3));
}
@@ -0,0 +1,315 @@
use super::*;
#[test]
fn execute_runtime_with_live_http_connector_completes_local_response() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("client should connect");
let mut request = [0_u8; 2048];
let _ = stream.read(&mut request);
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabc")
.expect("response should write");
});
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let downloader = aria2_rust_pro_protocol::downloader::ConnectorBackedDownloader::new(
connector.clone(),
connector,
);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec![format!("http://{addr}/live")],
},
&downloader,
)
.expect("runtime should complete via live connector");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_completed_length, Some(3));
handle.join().expect("server thread should join");
}
#[test]
fn execute_runtime_with_live_http_connector_persists_streamed_payload_to_configured_dir() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("client should connect");
let mut request = [0_u8; 2048];
let _ = stream.read(&mut request);
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 7\r\n\r\npersist")
.expect("response should write");
});
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-live-persist-test");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let target_dir = temp_dir.join("downloads");
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
format!("dir={}\nout=payload.bin\nsplit=1\n", target_dir.display()),
)
.expect("config should write");
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let downloader = aria2_rust_pro_protocol::downloader::ConnectorBackedDownloader::new(
connector.clone(),
connector,
);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path),
uris: vec![format!("http://{addr}/payload.bin")],
},
&downloader,
)
.expect("runtime should persist the streamed live response");
assert_eq!(report.completed_download_count, 1);
assert_eq!(
fs::read(target_dir.join("payload.bin")).expect("payload should persist"),
b"persist"
);
handle.join().expect("server thread should join");
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn execute_runtime_with_live_http_connector_avoids_second_scale_delay_for_large_single_file() {
let payload = vec![b'a'; 8 * 1024 * 1024];
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("client should connect");
let mut request = [0_u8; 2048];
let _ = stream.read(&mut request);
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
payload.len()
);
stream
.write_all(headers.as_bytes())
.expect("response headers should write");
stream
.write_all(&payload)
.expect("response body should write");
});
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let downloader = aria2_rust_pro_protocol::downloader::ConnectorBackedDownloader::new(
connector.clone(),
connector,
);
let started = Instant::now();
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec![format!("http://{addr}/large-live.bin")],
},
&downloader,
)
.expect("runtime should complete large live connector response");
let elapsed = started.elapsed();
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_completed_length, Some(8 * 1024 * 1024));
assert!(
elapsed < Duration::from_millis(900),
"large single-file live connector execution should stay below a second-scale delay: {elapsed:?}"
);
handle.join().expect("server thread should join");
}
#[test]
fn execute_runtime_with_live_http_connector_completes_multiple_local_responses_in_one_runtime() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let handle = thread::spawn(move || {
let mut workers = Vec::new();
for _ in 0..2 {
let (mut stream, _) = listener.accept().expect("client should connect");
workers.push(thread::spawn(move || {
let mut request = [0_u8; 2048];
let _ = stream.read(&mut request);
thread::sleep(Duration::from_millis(80));
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabc")
.expect("response should write");
}));
}
for worker in workers {
worker.join().expect("http response worker should join");
}
});
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let downloader = aria2_rust_pro_protocol::downloader::ConnectorBackedDownloader::new(
connector.clone(),
connector,
);
let started = Instant::now();
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec![
format!("http://{addr}/live-a"),
format!("http://{addr}/live-b"),
],
},
&downloader,
)
.expect("runtime should complete multiple live connector responses");
let elapsed = started.elapsed();
assert_eq!(report.accepted_uri_count, 2);
assert_eq!(report.tracked_download_count, 2);
assert_eq!(report.completed_download_count, 2);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert!(
elapsed < Duration::from_millis(600),
"same-runtime live connector execution should overlap request latency: {elapsed:?}"
);
handle.join().expect("server thread should join");
}
#[expect(
clippy::too_many_lines,
reason = "live retry/resume fixture is clearer as one test"
)]
#[test]
fn execute_runtime_with_live_http_connector_retries_resumes_and_completes_checksum() {
fn read_http_request(stream: &mut TcpStream) -> String {
let mut buf = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk).expect("socket should read");
if read == 0 {
break;
}
let payload = chunk
.get(..read)
.expect("read count should stay within the temporary buffer");
buf.extend_from_slice(payload);
if buf.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
String::from_utf8_lossy(&buf).to_lowercase()
}
fn requested_range(request: &str) -> Option<(usize, Option<usize>)> {
let range_line = request
.lines()
.find(|line| line.trim_start().starts_with("range: bytes="))?;
let raw = range_line
.trim_start()
.strip_prefix("range: bytes=")?
.trim();
let (start, end) = raw.split_once('-')?;
Some((
start.parse().ok()?,
(!end.is_empty()).then(|| end.parse().ok()).flatten(),
))
}
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let handle = thread::spawn(move || {
let (mut retry, _) = listener.accept().expect("retry client should connect");
let retry_request = read_http_request(&mut retry);
assert!(retry_request.contains("get /resume-checksum.bin http/1.1"));
assert!(!retry_request.contains("range:"));
retry
.write_all(
b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.expect("retry response should write");
let (mut bootstrap, _) = listener.accept().expect("bootstrap client should connect");
let bootstrap_request = read_http_request(&mut bootstrap);
assert!(bootstrap_request.contains("get /resume-checksum.bin http/1.1"));
assert!(!bootstrap_request.contains("range:"));
bootstrap
.write_all(
b"HTTP/1.1 206 Partial Content\r\nContent-Length: 4\r\nContent-Range: bytes 0-3/10\r\nConnection: close\r\n\r\n1234",
)
.expect("bootstrap response should write");
let (mut resumed, _) = listener.accept().expect("resume client should connect");
let resumed_request = read_http_request(&mut resumed);
let payload = b"1234567890";
let (start, end) =
requested_range(&resumed_request).expect("resume request should include range");
let end = end.unwrap_or_else(|| payload.len().saturating_sub(1));
assert_eq!(start, 4);
assert_eq!(end, 9);
let body = payload
.get(start..=end)
.expect("resume request should stay within the scripted payload");
let response = format!(
"HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {start}-{end}/{}\r\nConnection: close\r\n\r\n",
body.len(),
payload.len()
);
resumed
.write_all(response.as_bytes())
.expect("resume response headers should write");
resumed
.write_all(body)
.expect("resume response body should write");
});
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-live-resume-checksum-test");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
format!(
"dir={}\nout=resume-checksum.bin\nsplit=1\nmax-tries=3\nchecksum=sha-1=01b307acba4f54f55aafc33bb06bbbf6ca803e9a\n",
temp_dir.display()
),
)
.expect("config should write");
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let downloader = aria2_rust_pro_protocol::downloader::ConnectorBackedDownloader::new(
connector.clone(),
connector,
);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path),
uris: vec![format!("http://{addr}/resume-checksum.bin")],
},
&downloader,
)
.expect("runtime should complete retried ranged live transfer with checksum");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(10));
assert_eq!(report.first_completed_length, Some(10));
assert_eq!(
fs::read(temp_dir.join("resume-checksum.bin")).expect("target should read"),
b"1234567890"
);
handle.join().expect("server thread should join");
let _ = fs::remove_dir_all(temp_dir);
}
@@ -0,0 +1,276 @@
use super::*;
#[test]
fn execute_runtime_retries_http_failure_then_completes() {
let downloader = SequencedHttpDownloader::new(vec![
Ok(HttpResponseModel {
status: 503,
reason: "Service Unavailable".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Empty,
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
}),
Ok(HttpResponseModel {
status: 200,
reason: "OK".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "8".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
}],
},
body: ResponseBody::Inline(b"aaaaaaaa".to_vec()),
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
}),
]);
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-retry-test");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(&config_path, "retry-wait=1\nmax-tries=2\n").expect("config should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path.clone()),
uris: vec!["http://example.com/retry.bin".to_owned()],
},
&downloader,
)
.expect("runtime should recover via retry");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_completed_length, Some(8));
assert_eq!(report.first_connections, Some(1));
let _ = fs::remove_file(config_path);
}
#[test]
fn execute_runtime_accepts_partial_content_as_complete_progress() {
let downloader = SequencedHttpDownloader::new(vec![Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "5".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 0-4/10".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"12345".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 0,
end_inclusive: 4,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
})]);
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-partial-single-test");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(&config_path, "split=1\n").expect("config should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path.clone()),
uris: vec!["http://example.com/partial.bin".to_owned()],
},
&downloader,
)
.expect("runtime should accept 206 transfer");
assert_eq!(report.completed_download_count, 0);
assert_eq!(report.first_status.as_deref(), Some("active"));
assert_eq!(report.first_total_length, Some(10));
assert_eq!(report.first_completed_length, Some(5));
assert_eq!(report.first_connections, Some(1));
let _ = fs::remove_file(config_path);
}
#[expect(
clippy::too_many_lines,
reason = "partial-range regression fixture keeps the response sequence and assertions together"
)]
#[test]
fn execute_runtime_advances_multi_step_partial_ranges_until_complete() {
let downloader = SequencedHttpDownloader::new(vec![
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 0-3/10".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"1234".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 0,
end_inclusive: 3,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "3".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 4-6/10".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"567".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 4,
end_inclusive: 6,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "2".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 8-9/10".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"90".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 8,
end_inclusive: 9,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
]);
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-multipart-test");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
"max-tries=3\nsplit=3\nmin-split-size=4\npiece-length=4\n",
)
.expect("config should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path.clone()),
uris: vec!["http://example.com/multipart.bin".to_owned()],
},
&downloader,
)
.expect("runtime should advance through multiple partial segments");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(10));
assert_eq!(report.first_completed_length, Some(10));
assert_eq!(report.first_connections, Some(1));
let recorded = downloader.recorded_requests();
let [bootstrap, first_followup, second_followup] = recorded.as_slice() else {
panic!("expected exactly three recorded requests");
};
assert_eq!(bootstrap.request.range, None);
let mut followup_ranges = [first_followup, second_followup]
.iter()
.map(|task| task.request.range)
.collect::<Vec<_>>();
followup_ranges.sort_by_key(|range| range.as_ref().map(|range| range.start));
assert_eq!(
followup_ranges,
vec![
Some(aria2_rust_pro_protocol::RangeSpec {
start: 4,
end_inclusive: Some(9),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
}),
Some(aria2_rust_pro_protocol::RangeSpec {
start: 7,
end_inclusive: Some(9),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
}),
]
);
assert_eq!(bootstrap.retry_attempts.len(), 0);
assert_eq!(first_followup.retry_attempts.len(), 0);
assert_eq!(second_followup.retry_attempts.len(), 1);
assert!(
first_followup
.retry_attempts
.iter()
.all(|attempt| attempt.status == Some(206))
);
assert!(
second_followup
.retry_attempts
.iter()
.all(|attempt| attempt.status == Some(206))
);
let _ = fs::remove_file(config_path);
}
@@ -0,0 +1,582 @@
use super::*;
#[expect(
clippy::too_many_lines,
reason = "concurrency regression fixture keeps queued responses and observed range assertions together"
)]
#[test]
fn execute_segment_transfers_runs_planned_segments_concurrently() {
let downloader = ConcurrentProbeDownloader::new(
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 0-3/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"1234".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 0,
end_inclusive: 3,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
vec![
(
4,
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 4-7/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"5678".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 4,
end_inclusive: 7,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
),
(
8,
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "2".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 8-11/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"90ab".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 8,
end_inclusive: 11,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
),
(
12,
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 12-15/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"cdef".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 12,
end_inclusive: 15,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
),
],
Duration::from_millis(80),
);
let runtime = RuntimeConfig {
split: 4,
max_connections_per_server: 4,
max_connection_per_server: 4,
min_split_size: 4,
piece_length: 4,
..RuntimeConfig::default()
};
let session = derive_http_session(None, &StartupProfile::default());
let base_task = build_http_transfer_task(
"gid-concurrent".to_owned(),
"http://example.com/concurrent-segments.bin".to_owned(),
&session,
&runtime,
None,
);
let planned_tasks = vec![(4_u64, 7_u64), (8, 11), (12, 15)]
.into_iter()
.map(|(start, end_inclusive)| {
let mut task = base_task.clone();
task.request.range = Some(aria2_rust_pro_protocol::RangeSpec {
start,
end_inclusive: Some(end_inclusive),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
});
task.resume_state = Some(aria2_rust_pro_protocol::ResumeState {
requested_offset: start,
accepted_offset: None,
resumed: true,
});
task
})
.collect::<Vec<_>>();
let executions = execute_segment_transfers(&downloader, planned_tasks, &runtime);
assert_eq!(executions.len(), 3);
assert!(
downloader.max_concurrent_calls() >= 2,
"follow-up segment transfers should overlap in flight"
);
let recorded = downloader.recorded_requests();
assert_eq!(recorded.len(), 3);
let mut followup_ranges = recorded
.iter()
.map(|task| task.request.range)
.collect::<Vec<_>>();
followup_ranges.sort_by_key(|range| range.as_ref().map(|range| range.start));
assert_eq!(
followup_ranges,
vec![
Some(aria2_rust_pro_protocol::RangeSpec {
start: 4,
end_inclusive: Some(7),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
}),
Some(aria2_rust_pro_protocol::RangeSpec {
start: 8,
end_inclusive: Some(11),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
}),
Some(aria2_rust_pro_protocol::RangeSpec {
start: 12,
end_inclusive: Some(15),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
}),
]
);
}
#[expect(
clippy::too_many_lines,
reason = "throttling regression fixture keeps the speed-cap setup and concurrency assertions together"
)]
#[test]
fn execute_segment_transfers_throttles_parallelism_when_speed_cap_is_tight() {
let downloader = ConcurrentProbeDownloader::new(
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 0-3/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"1234".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 0,
end_inclusive: 3,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
vec![
(
4,
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 4-7/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"5678".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 4,
end_inclusive: 7,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
),
(
8,
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 8-11/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"90ab".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 8,
end_inclusive: 11,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
),
(
12,
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 12-15/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"cdef".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 12,
end_inclusive: 15,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
),
],
Duration::from_millis(80),
);
let runtime = RuntimeConfig {
split: 4,
max_connections_per_server: 4,
max_connection_per_server: 4,
min_split_size: 4,
piece_length: 4,
max_overall_download_limit: Some(4),
..RuntimeConfig::default()
};
let session = derive_http_session(None, &StartupProfile::default());
let base_task = build_http_transfer_task(
"gid-throttled".to_owned(),
"http://example.com/throttled-segments.bin".to_owned(),
&session,
&runtime,
None,
);
let planned_tasks = vec![(4_u64, 7_u64), (8, 11), (12, 15)]
.into_iter()
.map(|(start, end_inclusive)| {
let mut task = base_task.clone();
task.request.range = Some(aria2_rust_pro_protocol::RangeSpec {
start,
end_inclusive: Some(end_inclusive),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
});
task.resume_state = Some(aria2_rust_pro_protocol::ResumeState {
requested_offset: start,
accepted_offset: None,
resumed: true,
});
task
})
.collect::<Vec<_>>();
let executions = execute_segment_transfers(&downloader, planned_tasks, &runtime);
assert_eq!(executions.len(), 3);
assert_eq!(
downloader.max_concurrent_calls(),
3,
"global download caps should not collapse one download's follow-up segments into serial transfers"
);
}
#[expect(
clippy::too_many_lines,
reason = "per-download speed-cap regression keeps mirrored segment fixtures beside the serialism assertion"
)]
#[test]
fn execute_segment_transfers_respect_per_download_speed_cap() {
let downloader = ConcurrentProbeDownloader::new(
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 0-3/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"1234".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 0,
end_inclusive: 3,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
vec![
(
4,
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 4-7/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"5678".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 4,
end_inclusive: 7,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
),
(
8,
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 8-11/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"90ab".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 8,
end_inclusive: 11,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
),
(
12,
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 12-15/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"cdef".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 12,
end_inclusive: 15,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
),
],
Duration::from_millis(80),
);
let runtime = RuntimeConfig {
split: 4,
max_connections_per_server: 4,
max_connection_per_server: 4,
min_split_size: 4,
piece_length: 4,
max_download_limit: Some(4),
..RuntimeConfig::default()
};
let session = derive_http_session(None, &StartupProfile::default());
let base_task = build_http_transfer_task(
"gid-throttled".to_owned(),
"http://example.com/throttled-segments.bin".to_owned(),
&session,
&runtime,
None,
);
let planned_tasks = vec![(4_u64, 7_u64), (8, 11), (12, 15)]
.into_iter()
.map(|(start, end_inclusive)| {
let mut task = base_task.clone();
task.request.range = Some(aria2_rust_pro_protocol::RangeSpec {
start,
end_inclusive: Some(end_inclusive),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
});
task.resume_state = Some(aria2_rust_pro_protocol::ResumeState {
requested_offset: start,
accepted_offset: None,
resumed: true,
});
task
})
.collect::<Vec<_>>();
let executions = execute_segment_transfers(&downloader, planned_tasks, &runtime);
assert_eq!(executions.len(), 3);
assert_eq!(
downloader.max_concurrent_calls(),
1,
"per-download speed caps should still force serial follow-up segments for one download"
);
}
@@ -0,0 +1,420 @@
use super::*;
#[expect(
clippy::too_many_lines,
reason = "segment-plan regression fixture keeps response bodies, ranges, and request assertions adjacent"
)]
#[test]
fn execute_runtime_uses_segment_plan_after_bootstrap_partial_response() {
let downloader = SequencedHttpDownloader::new(vec![
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 0-3/10".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"1234".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 0,
end_inclusive: 3,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 4-7/10".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"5678".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 4,
end_inclusive: 7,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "2".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 8-9/10".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"90".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 8,
end_inclusive: 9,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
]);
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-explicit-segments-test");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
"max-tries=3\nsplit=3\nmin-split-size=4\npiece-length=4\n",
)
.expect("config should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path.clone()),
uris: vec!["http://example.com/segment-plan.bin".to_owned()],
},
&downloader,
)
.expect("runtime should execute via explicit segments");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_completed_length, Some(10));
let recorded = downloader.recorded_requests();
let [bootstrap, first_followup, second_followup] = recorded.as_slice() else {
panic!("expected exactly three recorded requests");
};
assert_eq!(bootstrap.request.range, None);
let mut followup_ranges = [first_followup, second_followup]
.iter()
.map(|task| task.request.range)
.collect::<Vec<_>>();
followup_ranges.sort_by_key(|range| range.as_ref().map(|range| range.start));
assert_eq!(
followup_ranges,
vec![
Some(aria2_rust_pro_protocol::RangeSpec {
start: 4,
end_inclusive: Some(9),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
}),
Some(aria2_rust_pro_protocol::RangeSpec {
start: 8,
end_inclusive: Some(9),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
}),
]
);
let _ = fs::remove_file(config_path);
}
#[expect(
clippy::too_many_lines,
reason = "initial range-probe regression keeps bootstrap and follow-up range fixtures adjacent"
)]
#[test]
fn execute_runtime_uses_initial_range_probe_when_split_budget_allows_parallel_segments() {
let downloader = SequencedHttpDownloader::new(vec![
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 0-3/10".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"1234".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 0,
end_inclusive: 3,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 4-7/10".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"5678".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 4,
end_inclusive: 7,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "2".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 8-9/10".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"90".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 8,
end_inclusive: 9,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
]);
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-segment-probe-test");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
"split=3\nmax-connection-per-server=3\nmin-split-size=4\npiece-length=4\n",
)
.expect("config should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path.clone()),
uris: vec!["http://example.com/probe-plan.bin".to_owned()],
},
&downloader,
)
.expect("runtime should execute via initial range probe");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_completed_length, Some(10));
let recorded = downloader.recorded_requests();
let [probe, first_followup, second_followup] = recorded.as_slice() else {
panic!("expected exactly three recorded requests");
};
assert_eq!(
probe.request.range,
Some(aria2_rust_pro_protocol::RangeSpec {
start: 0,
end_inclusive: Some(3),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
})
);
let mut followup_ranges = [first_followup, second_followup]
.iter()
.map(|task| task.request.range)
.collect::<Vec<_>>();
followup_ranges.sort_by_key(|range| range.as_ref().map(|range| range.start));
assert_eq!(
followup_ranges,
vec![
Some(aria2_rust_pro_protocol::RangeSpec {
start: 4,
end_inclusive: Some(7),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
}),
Some(aria2_rust_pro_protocol::RangeSpec {
start: 8,
end_inclusive: Some(9),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
}),
]
);
let _ = fs::remove_file(config_path);
}
#[expect(
clippy::too_many_lines,
reason = "tiny-tail regression keeps the coalesced probe fixture next to the expected request ranges"
)]
#[test]
fn execute_runtime_coalesces_tiny_followup_tail_after_initial_probe() {
let downloader = SequencedHttpDownloader::new(vec![
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "32".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 0-31/36".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"1234567890abcdefghijklmnopqrstuv".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 0,
end_inclusive: 31,
total_size: Some(36),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 32-35/36".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"ghij".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 32,
end_inclusive: 35,
total_size: Some(36),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
]);
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-tiny-tail-coalesce-test");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
"split=4\nmax-connection-per-server=4\nmin-split-size=4\npiece-length=4\n",
)
.expect("config should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path.clone()),
uris: vec!["http://example.com/coalesced-tail.bin".to_owned()],
},
&downloader,
)
.expect("runtime should coalesce tiny follow-up tail after probe");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_completed_length, Some(36));
let recorded = downloader.recorded_requests();
let [probe, first_followup] = recorded.as_slice() else {
panic!("expected exactly two recorded requests");
};
assert_eq!(
probe.request.range,
Some(aria2_rust_pro_protocol::RangeSpec {
start: 0,
end_inclusive: Some(31),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
})
);
assert_eq!(
first_followup.request.range,
Some(aria2_rust_pro_protocol::RangeSpec {
start: 32,
end_inclusive: Some(35),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
})
);
let _ = fs::remove_file(config_path);
}
@@ -0,0 +1,505 @@
#![doc(hidden)]
#![expect(
clippy::redundant_pub_crate,
reason = "this private transfer-resolution module shares parent-only helpers across the split CLI facade"
)]
use std::{fs, path::Path};
use aria2_rust_pro_compat::{ConfigParseError, ConfigProfile};
use aria2_rust_pro_core::RuntimeConfig;
use aria2_rust_pro_protocol::{
Downloader, FtpCommandModel, FtpConfigModel, FtpMode, FtpRequestModel, HttpResponseModel,
HttpSessionModel, MetalinkParserModel, Protocol, ResponseBody, SftpCommandModel,
SftpConfigModel, SftpRequestModel,
};
use aria2_rust_pro_rpc::{InProcessRpcDispatcher, JsonRpcRequest, RpcMeta, RpcMethod, RpcValue};
use super::{
CliError, TransferInputEntry, apply_proxy_auth_overrides, build_http_transfer_task,
classify_transfer, execute_http_transfer_with_retry, merged_profile,
metalink_entry_implied_profile, parse_bool_text, parse_csv_text, parse_protocol,
parse_proxy_text, profile_option_value, rpc_option_object,
};
/// Parsed authority and path components for FTP- and SFTP-style URIs.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct ParsedRemoteUri {
/// URI scheme such as `ftp` or `sftp`.
pub(super) scheme: String,
/// Remote hostname.
pub(super) host: String,
/// Remote service port.
pub(super) port: u16,
/// Optional username from URI user info.
pub(super) username: Option<String>,
/// Optional password from URI user info.
pub(super) password: Option<String>,
/// Canonical path component beginning with `/`.
pub(super) path: String,
}
/// Parses an FTP- or SFTP-style URI into its normalized components.
pub(super) fn parse_remote_uri(uri: &str) -> Option<ParsedRemoteUri> {
let (scheme, remainder) = uri.split_once("://")?;
let (authority, path_part) = match remainder.split_once('/') {
Some((authority, path)) => (authority, format!("/{path}")),
None => (remainder, "/".to_owned()),
};
let (user_info, host_port) = match authority.rsplit_once('@') {
Some((user_info, host_port)) => (Some(user_info), host_port),
None => (None, authority),
};
let (host, port) = match host_port.rsplit_once(':') {
Some((host, port_text)) => (host, port_text.parse().ok()?),
None if scheme.eq_ignore_ascii_case("ftp") => (host_port, 21),
None if scheme.eq_ignore_ascii_case("sftp") => (host_port, 22),
None => return None,
};
let (username, password) = match user_info.and_then(|value| value.split_once(':')) {
Some((username, password)) => (Some(username.to_owned()), Some(password.to_owned())),
None => (user_info.map(str::to_owned), None),
};
Some(ParsedRemoteUri {
scheme: scheme.to_owned(),
host: host.to_owned(),
port,
username,
password,
path: path_part,
})
}
/// Builds protocol-layer FTP config and request models for a URI.
pub(super) fn build_ftp_transfer_parts(
uri: &str,
profile: Option<&ConfigProfile>,
http_session: &HttpSessionModel,
) -> Option<(FtpConfigModel, FtpRequestModel)> {
let parsed = parse_remote_uri(uri)?;
if !parsed.scheme.eq_ignore_ascii_case("ftp") {
return None;
}
let ftp_passive = profile
.and_then(|profile| profile_option_value(profile, "ftp-pasv"))
.and_then(parse_bool_text)
.unwrap_or(true);
let ftp_mode = if ftp_passive {
FtpMode::Passive
} else {
FtpMode::Active
};
let ftp_proxy = profile.and_then(|profile| {
let bypass_hosts =
profile_option_value(profile, "no-proxy").map_or_else(Vec::new, parse_csv_text);
if let Some(proxy_text) = profile_option_value(profile, "ftp-proxy") {
let mut proxy = parse_proxy_text(proxy_text, bypass_hosts)?;
apply_proxy_auth_overrides(&mut proxy, profile, "ftp-proxy-user", "ftp-proxy-passwd");
Some(proxy)
} else if let Some(proxy_text) = profile_option_value(profile, "all-proxy") {
let mut proxy = parse_proxy_text(proxy_text, bypass_hosts)?;
apply_proxy_auth_overrides(&mut proxy, profile, "all-proxy-user", "all-proxy-passwd");
Some(proxy)
} else {
None
}
});
Some((
FtpConfigModel {
host: parsed.host,
port: parsed.port,
username: parsed.username.or_else(|| {
profile
.and_then(|profile| profile_option_value(profile, "ftp-user"))
.map(ToOwned::to_owned)
}),
password: parsed.password.or_else(|| {
profile
.and_then(|profile| profile_option_value(profile, "ftp-passwd"))
.map(ToOwned::to_owned)
}),
secure: false,
mode: ftp_mode,
initial_cwd: None,
proxy: ftp_proxy,
tls: None,
retry: http_session.retry,
},
FtpRequestModel {
command: FtpCommandModel::Retr(parsed.path.clone()),
path: Some(parsed.path),
headers: Vec::new(),
},
))
}
/// Builds protocol-layer SFTP config and request models for a URI.
pub(super) fn build_sftp_transfer_parts(
uri: &str,
http_session: &HttpSessionModel,
) -> Option<(SftpConfigModel, SftpRequestModel)> {
let parsed = parse_remote_uri(uri)?;
if !parsed.scheme.eq_ignore_ascii_case("sftp") {
return None;
}
Some((
SftpConfigModel {
host: parsed.host,
port: parsed.port,
username: parsed.username,
password: parsed.password,
private_key_path: None,
known_hosts_path: None,
strict_host_key_checking: true,
proxy: http_session.proxy.clone(),
tls: None,
retry: http_session.retry,
},
SftpRequestModel {
command: SftpCommandModel::Read {
path: parsed.path.clone(),
offset: 0,
length: u64::MAX,
},
path: Some(parsed.path),
headers: Vec::new(),
},
))
}
/// Loads a Metalink file from disk and returns executable transfer entries.
pub(super) fn parse_metalink_transfer_entries(
path: &Path,
) -> Result<Vec<TransferInputEntry>, CliError> {
let text = fs::read_to_string(path).map_err(|error| CliError::Io(error.to_string()))?;
parse_metalink_transfer_entries_from_text(&text)
}
/// Parses Metalink XML text and returns executable transfer entries.
pub(super) fn parse_metalink_transfer_entries_from_text(
text: &str,
) -> Result<Vec<TransferInputEntry>, CliError> {
let result = MetalinkParserModel::new(true, false).parse(text);
let document = result.document.ok_or_else(|| {
CliError::Io(
result
.parser
.last_error
.unwrap_or_else(|| "metalink parse failed".to_owned()),
)
})?;
let entries = aria2_rust_pro_protocol::metalink_download_plan(&document)
.into_iter()
.map(|entry| TransferInputEntry {
uris: entry.uris,
implied_profile: metalink_entry_implied_profile(
&entry.file_name,
entry.checksum.as_ref(),
),
profile: None,
})
.collect::<Vec<_>>();
if entries.is_empty() {
return Err(CliError::Io(
"metalink document contains no usable resource url".to_owned(),
));
}
Ok(entries)
}
/// Reads a streamed or inline HTTP response body into owned bytes.
pub(super) fn http_response_body_bytes(
response: &HttpResponseModel,
label: &str,
) -> Result<Vec<u8>, CliError> {
match &response.body {
ResponseBody::Empty => Ok(Vec::new()),
ResponseBody::Inline(bytes) => Ok(bytes.clone()),
ResponseBody::Streamed { temp_path, .. } => {
let Some(temp_path) = temp_path else {
return Err(CliError::Io(format!(
"{label} response missing streamed temp file"
)));
};
let bytes = fs::read(temp_path).map_err(|error| CliError::Io(error.to_string()))?;
let _ = fs::remove_file(temp_path);
Ok(bytes)
}
}
}
/// Decodes an HTTP response body into text for Metalink parsing.
pub(super) fn metalink_response_text(response: &HttpResponseModel) -> Result<String, CliError> {
let bytes = http_response_body_bytes(response, "metalink document")?;
String::from_utf8(bytes)
.map_err(|error| CliError::Io(format!("metalink document is not valid utf-8: {error}")))
}
/// Resolves one logical transfer entity, dereferencing Metalink inputs when needed.
pub(super) fn resolve_transfer_entry_with_downloader<D: Downloader + Sync>(
entry: &TransferInputEntry,
downloader: &D,
session: &HttpSessionModel,
runtime: &RuntimeConfig,
) -> Result<Vec<TransferInputEntry>, CliError> {
let Some(primary_uri) = entry.uris.first() else {
return Err(CliError::Config(ConfigParseError::InvalidDirective(
"input-file entry missing URI".to_owned(),
)));
};
let shared_profile = entry.profile.clone();
let with_shared_profile = |entries: Vec<TransferInputEntry>| {
entries
.into_iter()
.map(|resolved| TransferInputEntry {
uris: resolved.uris,
implied_profile: resolved.implied_profile,
profile: shared_profile.clone(),
})
.collect::<Vec<_>>()
};
if classify_transfer(primary_uri) != super::TransferSelection::Metalink {
return Ok(vec![entry.clone()]);
}
match parse_protocol(primary_uri) {
Some(Protocol::Http | Protocol::Https) => {
let task = build_http_transfer_task(
"metalink-bootstrap".to_owned(),
primary_uri.clone(),
session,
runtime,
None,
);
let execution = execute_http_transfer_with_retry(downloader, &task, runtime);
let Some(response) = execution.response else {
return Err(CliError::Io(format!(
"failed to fetch metalink document: {primary_uri}"
)));
};
if !(200..=299).contains(&response.status) {
return Err(CliError::Io(format!(
"failed to fetch metalink document {primary_uri}: HTTP {}",
response.status
)));
}
let text = metalink_response_text(&response)?;
parse_metalink_transfer_entries_from_text(&text).map(with_shared_profile)
}
_ => parse_metalink_transfer_entries(Path::new(primary_uri)).map(with_shared_profile),
}
}
/// Loads torrent payload bytes from a local path or supported remote transport.
pub(super) fn load_torrent_payload_bytes<D: Downloader + Sync>(
downloader: &D,
uri: &str,
profile: Option<&ConfigProfile>,
http_session: &HttpSessionModel,
derived_runtime: &RuntimeConfig,
) -> Result<Vec<u8>, CliError> {
match parse_protocol(uri) {
Some(Protocol::Http | Protocol::Https) => {
let task = build_http_transfer_task(
"torrent-bootstrap".to_owned(),
uri.to_owned(),
http_session,
derived_runtime,
profile,
);
let execution = execute_http_transfer_with_retry(downloader, &task, derived_runtime);
let Some(response) = execution.response else {
return Err(CliError::Io(format!(
"failed to fetch torrent metadata: {uri}"
)));
};
if !(200..=299).contains(&response.status) {
return Err(CliError::Io(format!(
"failed to fetch torrent metadata {uri}: HTTP {}",
response.status
)));
}
http_response_body_bytes(&response, "torrent metadata")
}
Some(Protocol::Ftp) => {
let Some((config, request)) = build_ftp_transfer_parts(uri, profile, http_session)
else {
return Err(CliError::Io(format!("failed to parse ftp uri: {uri}")));
};
let response = downloader
.start_ftp_transfer(&config, &request)
.map_err(|error| CliError::Io(error.to_string()))?;
response.data.ok_or_else(|| {
CliError::Io(format!(
"torrent metadata transfer returned no ftp payload: {uri}"
))
})
}
Some(Protocol::Sftp) => {
let Some((config, request)) = build_sftp_transfer_parts(uri, http_session) else {
return Err(CliError::Io(format!("failed to parse sftp uri: {uri}")));
};
let response = downloader
.start_sftp_transfer(&config, &request)
.map_err(|error| CliError::Io(error.to_string()))?;
response.payload.ok_or_else(|| {
CliError::Io(format!(
"torrent metadata transfer returned no sftp payload: {uri}"
))
})
}
_ => fs::read(uri)
.map_err(|error| CliError::Io(format!("failed to read torrent file {uri}: {error}"))),
}
}
/// Encodes bytes as standard base64 without pulling extra crate ownership into the CLI.
pub(super) fn encode_base64(bytes: &[u8]) -> String {
let mut encoded = String::with_capacity(bytes.len().div_ceil(3).saturating_mul(4));
for chunk in bytes.chunks(3) {
match chunk {
[b0, b1, b2] => {
let combined = (u32::from(*b0) << 16) | (u32::from(*b1) << 8) | u32::from(*b2);
encoded.push(base64_alphabet_char((combined >> 18) & 0x3f));
encoded.push(base64_alphabet_char((combined >> 12) & 0x3f));
encoded.push(base64_alphabet_char((combined >> 6) & 0x3f));
encoded.push(base64_alphabet_char(combined & 0x3f));
}
[b0, b1] => {
let combined = (u32::from(*b0) << 16) | (u32::from(*b1) << 8);
encoded.push(base64_alphabet_char((combined >> 18) & 0x3f));
encoded.push(base64_alphabet_char((combined >> 12) & 0x3f));
encoded.push(base64_alphabet_char((combined >> 6) & 0x3f));
encoded.push('=');
}
[b0] => {
let combined = u32::from(*b0) << 16;
encoded.push(base64_alphabet_char((combined >> 18) & 0x3f));
encoded.push(base64_alphabet_char((combined >> 12) & 0x3f));
encoded.push('=');
encoded.push('=');
}
[] => {}
_ => unreachable!("chunks(3) never yields slices longer than 3"),
}
}
encoded
}
/// Maps one 6-bit base64 alphabet index into its ASCII output character.
fn base64_alphabet_char(index: u32) -> char {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let normalized = u8::try_from(index).expect("base64 alphabet indices fit into u8");
ALPHABET
.get(usize::from(normalized))
.copied()
.map(char::from)
.expect("base64 alphabet index must remain within range")
}
/// Internal registration surface used when seeding dispatcher state.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum DispatcherRegistrationKind {
/// Register the entry through `aria2.addUri`.
Uri,
/// Register the entry through `aria2.addTorrent`.
Torrent,
}
/// Builds the dispatcher registration path for a resolved entry.
pub(super) fn register_resolved_entry_with_dispatcher<D: Downloader + Sync>(
dispatcher: &mut InProcessRpcDispatcher,
downloader: &D,
entry: &TransferInputEntry,
resolved_uri: &str,
profile: Option<&ConfigProfile>,
http_session: &HttpSessionModel,
derived_runtime: &RuntimeConfig,
) -> Result<(String, DispatcherRegistrationKind), CliError> {
let rpc_profile = merged_profile(entry.implied_profile.as_ref(), entry.profile.as_ref());
let registration_kind = match classify_transfer(resolved_uri) {
super::TransferSelection::Torrent => DispatcherRegistrationKind::Torrent,
_ => DispatcherRegistrationKind::Uri,
};
let gid = match registration_kind {
DispatcherRegistrationKind::Uri => {
let uris = entry
.uris
.iter()
.enumerate()
.map(|(index, uri)| {
if index == 0 {
resolved_uri.to_owned()
} else {
uri.clone()
}
})
.collect::<Vec<_>>();
let options = rpc_profile
.as_ref()
.map_or_else(Vec::new, profile_string_options);
dispatcher
.add_uri_direct_string_options(uris, options)
.map_err(|error| CliError::Rpc(error.message))?
}
DispatcherRegistrationKind::Torrent => {
let payload = load_torrent_payload_bytes(
downloader,
resolved_uri,
profile,
http_session,
derived_runtime,
)?;
let mut params = vec![RpcValue::String(encode_base64(&payload))];
if let Some(options) = rpc_option_object(rpc_profile.as_ref()) {
params.push(RpcValue::Array(Vec::new()));
params.push(options);
}
dispatcher
.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddTorrent.as_str().to_owned(),
params,
meta: RpcMeta::default(),
})
.result
.and_then(|value| match value {
RpcValue::String(gid) => Some(gid),
_ => None,
})
.ok_or_else(|| {
CliError::Rpc("dispatcher registration did not return a gid".to_owned())
})?
}
};
Ok((gid, registration_kind))
}
/// Projects profile directives into the string option shape consumed by direct URI registration.
pub(super) fn profile_string_options(profile: &ConfigProfile) -> Vec<(String, String)> {
profile
.document
.directives
.iter()
.filter_map(|directive| {
directive
.value
.as_ref()
.map(|value| (directive.name.clone(), value.clone()))
})
.collect()
}
@@ -0,0 +1,391 @@
#![doc(hidden)]
#![expect(
clippy::redundant_pub_crate,
reason = "this private transfer-runtime module shares parent-only helpers across the split CLI facade"
)]
use std::{env, time::Instant};
use aria2_rust_pro_compat::ConfigProfile;
use aria2_rust_pro_core::{DownloadStatus, RuntimeConfig};
use aria2_rust_pro_protocol::{
Downloader, HttpResponseModel, HttpSessionModel, Protocol, ReqwestTrackerTransport,
StdDhtTransport, StdTcpPeerWireTransportConnector,
};
use aria2_rust_pro_rpc::{
InProcessRpcDispatcher, JsonRpcRequest, RpcMeta, RpcMethod, RpcStatusSummary, RpcValue,
};
use super::{
CliError, build_ftp_transfer_parts, build_http_transfer_task_with_target,
build_initial_http_execution_plan, build_segment_transfer_tasks, build_sftp_transfer_parts,
http_execution_completed_via_checksum, lossless_u64_from_usize, parse_protocol,
persist_http_response_body_to_target, prepare_http_target_path, rpc_bool, rpc_u64,
};
/// Minimal BT execution plan that keeps current tracker support and future hooks aligned.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub(super) struct BtRuntimeExecutionPlan {
/// Whether the runtime exposes BitTorrent-specific state for the download.
pub(super) is_bt: bool,
/// Whether an HTTP(S) tracker is immediately runnable via the live reqwest transport.
pub(super) has_live_http_tracker: bool,
/// Whether the request still lacks metadata, which blocks later peer-wire execution.
pub(super) metadata_only: bool,
}
/// Maximum BT coordinator iterations attempted for one foreground execution pass.
const MAX_BT_RUNTIME_ROUNDS: usize = 32;
/// Maximum consecutive idle coordinator rounds tolerated before returning.
const MAX_BT_IDLE_ROUNDS: usize = 6;
/// Reads one `aria2.tellStatus` object from the dispatcher.
pub(super) fn dispatcher_status_for_gid(
dispatcher: &mut InProcessRpcDispatcher,
gid: &str,
) -> Result<std::collections::BTreeMap<String, RpcValue>, CliError> {
let response = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid.to_owned())],
meta: RpcMeta::default(),
});
let Some(RpcValue::Object(status)) = response.result else {
let message = response.error.map_or_else(
|| "tellStatus did not return a status".to_owned(),
|error| error.message,
);
return Err(CliError::Rpc(message));
};
Ok(status)
}
/// Reads the lightweight internal status summary for one tracked download.
pub(super) fn dispatcher_status_summary_for_gid(
dispatcher: &InProcessRpcDispatcher,
gid: &str,
) -> Result<RpcStatusSummary, CliError> {
dispatcher
.status_summary_for_gid(gid)
.map_err(|error| CliError::Rpc(error.message))
}
/// Converts a core download status into the canonical aria2 RPC status text.
pub(super) fn rpc_status_text(status: DownloadStatus) -> String {
status.as_rpc_status().to_owned()
}
/// Returns whether one HTTP response represents an observed terminal success.
pub(super) fn http_response_is_terminal_success(response: &HttpResponseModel) -> bool {
(200..=299).contains(&response.status)
&& response
.total_length()
.is_none_or(|total_length| response.completed_length() >= total_length)
}
/// Derives the currently runnable BT phases from a tellStatus payload.
pub(super) fn bt_runtime_execution_plan(
status: &std::collections::BTreeMap<String, RpcValue>,
) -> BtRuntimeExecutionPlan {
let has_live_http_tracker = status
.get("announceList")
.and_then(|value| match value {
RpcValue::Array(tiers) => Some(tiers.iter().any(|tier| match tier {
RpcValue::Array(trackers) => trackers.iter().any(|tracker| match tracker {
RpcValue::String(uri) => {
matches!(parse_protocol(uri), Some(Protocol::Http | Protocol::Https))
}
_ => false,
}),
_ => false,
})),
_ => None,
})
.unwrap_or(false);
BtRuntimeExecutionPlan {
is_bt: rpc_bool(status.get("isBt")).unwrap_or(false),
has_live_http_tracker,
metadata_only: rpc_bool(status.get("metadataOnly")).unwrap_or(false),
}
}
/// Compact BT progress snapshot used to detect observable runtime movement.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(super) struct BtRuntimeProgressSnapshot {
/// Downloaded payload bytes reported by `aria2.tellStatus`.
pub(super) completed_length: u64,
/// Uploaded payload bytes reported by `aria2.tellStatus`.
pub(super) upload_length: u64,
/// Active peer connection count reported by `aria2.tellStatus`.
pub(super) connections: u64,
/// Whether the session still operates in metadata-only magnet mode.
pub(super) metadata_only: bool,
/// Whether the runtime already reported terminal completion.
pub(super) complete: bool,
}
/// Extracts the BT progress counters relevant for foreground progress detection.
pub(super) fn bt_runtime_progress_snapshot(
status: &std::collections::BTreeMap<String, RpcValue>,
) -> BtRuntimeProgressSnapshot {
BtRuntimeProgressSnapshot {
completed_length: rpc_u64(status.get("completedLength")).unwrap_or(0),
upload_length: rpc_u64(status.get("uploadLength")).unwrap_or(0),
connections: rpc_u64(status.get("connections")).unwrap_or(0),
metadata_only: rpc_bool(status.get("metadataOnly")).unwrap_or(false),
complete: matches!(status.get("status"), Some(RpcValue::String(value)) if value == "complete"),
}
}
/// Returns whether a later BT snapshot shows visible progress versus the earlier one.
pub(super) const fn bt_runtime_has_progress(
before: BtRuntimeProgressSnapshot,
after: BtRuntimeProgressSnapshot,
) -> bool {
after.complete
|| after.completed_length > before.completed_length
|| after.upload_length > before.upload_length
|| after.connections > before.connections
|| after.metadata_only != before.metadata_only
}
/// Executes the currently available BT runtime phases for one registered download.
pub(super) fn execute_bt_runtime_for_gid(
dispatcher: &mut InProcessRpcDispatcher,
gid: &str,
) -> Result<(), CliError> {
let tracker_transport = ReqwestTrackerTransport::new().ok();
let dht_transport = StdDhtTransport::default();
let peer_wire_transport = StdTcpPeerWireTransportConnector::default();
let mut idle_rounds = 0_usize;
for _ in 0..MAX_BT_RUNTIME_ROUNDS {
let status = dispatcher_status_for_gid(dispatcher, gid)?;
let plan = bt_runtime_execution_plan(&status);
if !plan.is_bt {
return Ok(());
}
let before = bt_runtime_progress_snapshot(&status);
if before.complete {
break;
}
let tracker_transport_ref = tracker_transport
.as_ref()
.filter(|_| plan.has_live_http_tracker)
.map(|transport| -> &dyn aria2_rust_pro_protocol::TrackerTransport { transport });
let _ = dispatcher.drive_bt_runtime_once(
gid,
tracker_transport_ref,
Some(&dht_transport),
Some(&peer_wire_transport),
None,
);
let after_status = dispatcher_status_for_gid(dispatcher, gid)?;
let after = bt_runtime_progress_snapshot(&after_status);
if after.complete {
break;
}
if bt_runtime_has_progress(before, after) {
idle_rounds = 0;
} else {
idle_rounds = idle_rounds.saturating_add(1);
if idle_rounds >= MAX_BT_IDLE_ROUNDS {
break;
}
}
}
Ok(())
}
#[expect(
clippy::too_many_lines,
reason = "one URI execution keeps protocol dispatch, telemetry, and completion accounting in one observable flow"
)]
/// Executes one transfer URI through the in-process runtime surface.
pub(super) fn execute_transfer_for_uri<D: Downloader + Sync>(
dispatcher: &mut InProcessRpcDispatcher,
downloader: &D,
uri: &str,
gid: &str,
profile: Option<&ConfigProfile>,
http_session: &HttpSessionModel,
derived_runtime: &RuntimeConfig,
) -> Result<bool, CliError> {
match parse_protocol(uri) {
Some(Protocol::Http | Protocol::Https) => {
let timing_probe = env::var_os("ARIA2_RUST_PRO_HTTP_TIMING").is_some();
let overall_started = timing_probe.then(Instant::now);
let target_path = prepare_http_target_path(profile, uri)?;
let task = build_http_transfer_task_with_target(
gid.to_owned(),
uri.to_owned(),
http_session,
derived_runtime,
profile,
Some(target_path.clone()),
);
let mut cumulative_retry_count = 0_u32;
let mut uri_marked_complete = false;
let initial_plan_started = timing_probe.then(Instant::now);
let initial_plan =
build_initial_http_execution_plan(downloader, &task, derived_runtime);
let initial_plan_elapsed_ms = initial_plan_started
.as_ref()
.map(|started| started.elapsed().as_millis());
let bootstrap_task = initial_plan.task;
let bootstrap_execution = initial_plan.execution;
let mut planned_segment_tasks = initial_plan.planned_segments;
let _telemetry_trace = (
bootstrap_execution.retry_attempts.len(),
bootstrap_execution.planned_ranges.len(),
);
if let Some(ref response) = bootstrap_execution.response {
cumulative_retry_count =
cumulative_retry_count.saturating_add(bootstrap_execution.retry_count);
let persist_started = timing_probe.then(Instant::now);
persist_http_response_body_to_target(&target_path, &bootstrap_task, response)?;
let persist_elapsed_ms = persist_started
.as_ref()
.map(|started| started.elapsed().as_millis());
let completed_via_checksum = http_execution_completed_via_checksum(
&bootstrap_execution,
response,
profile,
uri,
&bootstrap_task,
);
let record_started = timing_probe.then(Instant::now);
dispatcher
.record_http_transfer_result(
gid,
response,
bootstrap_task.max_connections,
cumulative_retry_count,
!bootstrap_execution.checksum_observed || completed_via_checksum,
)
.map_err(|error| CliError::Rpc(error.message))?;
let record_elapsed_ms = record_started
.as_ref()
.map(|started| started.elapsed().as_millis());
if http_response_is_terminal_success(response)
&& (!bootstrap_execution.checksum_observed || completed_via_checksum)
{
uri_marked_complete = true;
}
if let Some(total_started) = overall_started.as_ref() {
eprintln!(
"http timing uri={uri} status={} initial_plan_ms={} persist_ms={} record_ms={} total_ms={} partial={} completed={} total_length={:?}",
response.status,
initial_plan_elapsed_ms.unwrap_or_default(),
persist_elapsed_ms.unwrap_or_default(),
record_elapsed_ms.unwrap_or_default(),
total_started.elapsed().as_millis(),
response.partial_content,
response.completed_length(),
response.total_length(),
);
}
let needs_segment_followups = response.partial_content
&& response
.total_length()
.is_some_and(|total| response.completed_length() < total);
if needs_segment_followups {
if planned_segment_tasks.is_empty() {
let group = dispatcher
.prepare_http_download(gid)
.map_err(|error| CliError::Rpc(error.message))?;
planned_segment_tasks = build_segment_transfer_tasks(&task, &group);
}
let segment_executions = super::execute_segment_transfers(
downloader,
planned_segment_tasks,
derived_runtime,
);
for (planned_task, execution) in segment_executions {
if let Some(ref response) = execution.response {
cumulative_retry_count =
cumulative_retry_count.saturating_add(execution.retry_count);
persist_http_response_body_to_target(
&target_path,
&planned_task,
response,
)?;
let completed_via_checksum = http_execution_completed_via_checksum(
&execution,
response,
profile,
uri,
&planned_task,
);
dispatcher
.record_http_transfer_result(
gid,
response,
bootstrap_task.max_connections,
cumulative_retry_count,
!execution.checksum_observed || completed_via_checksum,
)
.map_err(|error| CliError::Rpc(error.message))?;
if http_response_is_terminal_success(response)
&& (!execution.checksum_observed || completed_via_checksum)
{
uri_marked_complete = true;
}
}
}
}
}
Ok(uri_marked_complete)
}
Some(Protocol::Ftp) => {
let Some((config, request)) = build_ftp_transfer_parts(uri, profile, http_session)
else {
return Err(CliError::Io(format!("failed to parse ftp uri: {uri}")));
};
let response = downloader
.start_ftp_transfer(&config, &request)
.map_err(|error| CliError::Io(error.to_string()))?;
let payload_len = response
.data
.as_ref()
.map_or(0_u64, |data| lossless_u64_from_usize(data.len()));
dispatcher
.record_transfer_result(gid, payload_len, payload_len, 1, response.transferable, 0)
.map_err(|error| CliError::Rpc(error.message))?;
Ok(response.transferable)
}
Some(Protocol::Sftp) => {
let Some((config, request)) = build_sftp_transfer_parts(uri, http_session) else {
return Err(CliError::Io(format!("failed to parse sftp uri: {uri}")));
};
let response = downloader
.start_sftp_transfer(&config, &request)
.map_err(|error| CliError::Io(error.to_string()))?;
let payload_len = response
.payload
.as_ref()
.map_or(0_u64, |payload| lossless_u64_from_usize(payload.len()));
dispatcher
.record_transfer_result(
gid,
payload_len,
payload_len,
1,
response.transferable && response.ok,
0,
)
.map_err(|error| CliError::Rpc(error.message))?;
Ok(response.transferable && response.ok)
}
_ => Ok(false),
}
}
+277
View File
@@ -0,0 +1,277 @@
#![expect(
clippy::redundant_pub_crate,
reason = "this private CLI type hub intentionally reuses the split crate surface and keeps parent-visible types local to the crate API façade"
)]
use super::{ConfigParseError, ConfigProfile, HttpSessionModel, PathBuf, RuntimeConfig};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
/// Selects the top-level process mode implied by parsed CLI options.
pub enum RuntimeMode {
/// Execute downloads in the foreground process.
Foreground,
/// Start the RPC daemon after applying daemon-specific flags.
Daemon,
/// Start only the RPC daemon surface without foreground transfer output.
RpcOnly,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
/// Classifies a user-supplied transfer input by its visible surface.
pub enum TransferSelection {
/// A plain URI-style transfer input.
Uri,
/// A `.torrent` file or URL.
Torrent,
/// A Metalink document or file path.
Metalink,
/// A `BitTorrent` magnet URI.
Magnet,
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// Captures RPC daemon launch settings derived from startup flags.
pub struct RpcLaunchConfig {
/// Whether the RPC server should be enabled.
pub enabled: bool,
/// Host/IP the RPC listener should bind to.
pub listen_host: String,
/// TCP port the RPC listener should bind to.
pub listen_port: u16,
/// Optional shared-secret token accepted by the RPC surface.
pub secret: Option<String>,
/// HTTP path exposed by the RPC listener.
pub path: String,
}
impl Default for RpcLaunchConfig {
fn default() -> Self {
Self {
enabled: false,
listen_host: "127.0.0.1".to_owned(),
listen_port: 6800,
secret: None,
path: "/jsonrpc".to_owned(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// Normalized startup flags that shape execution after argument parsing.
pub struct StartupProfile {
/// Requested top-level runtime mode.
pub mode: RuntimeMode,
/// RPC launch overrides collected from CLI flags.
pub rpc: RpcLaunchConfig,
/// Whether daemonization was explicitly requested.
pub daemonize: bool,
/// Whether the invocation should validate config only.
pub dry_run: bool,
}
impl Default for StartupProfile {
fn default() -> Self {
Self {
mode: RuntimeMode::Foreground,
rpc: RpcLaunchConfig::default(),
daemonize: false,
dry_run: false,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// Full CLI parse result, including the selected invocation and startup flags.
pub struct ParsedArguments {
/// High-level action requested by the user.
pub invocation: Invocation,
/// Startup modifiers that further shape execution.
pub profile: StartupProfile,
/// CLI-originated compat directives that should override config-file values.
pub cli_profile: Option<ConfigProfile>,
/// Ordered CLI transfer sources so mixed positional URIs and `--input-file`
/// entries can preserve argv order.
pub(crate) cli_transfer_sources: Vec<CliTransferSource>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// One transfer source observed directly on the command line.
pub(crate) enum CliTransferSource {
/// A positional URI-like input.
Uri(String),
/// An input-file reference that should expand into one or more entities.
InputFile(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// Decides which high-level entrypoint should handle a parsed invocation.
pub enum CommandSurface {
/// Execute the invocation in the foreground CLI flow.
Foreground(Invocation),
/// Launch the RPC daemon with optional pre-seeded inputs.
RpcDaemon {
/// Optional config path to load before serving RPC.
config_path: Option<PathBuf>,
/// Inputs that should be registered before the daemon starts serving.
inputs: Vec<String>,
},
/// Print the version banner.
PrintVersion,
/// Print help text, optionally filtered by a query term.
PrintHelp {
/// Optional help topic or filter string.
query: Option<String>,
},
/// Validate the selected config file without starting transfers.
ValidateConfig {
/// Config file to validate.
config_path: PathBuf,
/// Whether strict parsing should be used.
strict: bool,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// High-level CLI invocations supported by the binary.
pub enum Invocation {
/// Print version information and exit.
Version,
/// Print help output and exit.
Help {
/// Optional help query for filtered help output.
query: Option<String>,
},
/// Execute or stage one or more transfer inputs.
Run {
/// Optional config file path supplied on the command line.
config_path: Option<PathBuf>,
/// Ordered transfer inputs supplied on the command line.
uris: Vec<String>,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// Errors surfaced by CLI parsing and execution.
pub enum CliError {
/// An unsupported flag or malformed option was provided.
UnknownOption(String),
/// A flag requiring a value was not followed by one.
MissingValue(String),
/// An OS argument could not be converted to UTF-8.
InvalidUtf8Argument,
/// Config parsing failed.
Config(ConfigParseError),
/// Local I/O failed.
Io(String),
/// The in-process RPC/runtime layer returned an application error.
Rpc(String),
}
impl std::fmt::Display for CliError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownOption(option) => write!(f, "unknown option: {option}"),
Self::MissingValue(option) => write!(f, "missing value for {option}"),
Self::InvalidUtf8Argument => f.write_str("invalid utf-8 in command-line argument"),
Self::Config(error) => write!(f, "{error}"),
Self::Io(error) | Self::Rpc(error) => write!(f, "{error}"),
}
}
}
impl std::error::Error for CliError {}
#[derive(Clone, Debug, Eq, PartialEq)]
/// Summarizes a parsed config file that was loaded by the CLI.
pub struct ConfigLoadReport {
/// Source path of the loaded config file.
pub path: PathBuf,
/// Number of directives accepted by the selected parser mode.
pub directive_count: usize,
/// Whether strict parsing was enabled.
pub strict: bool,
/// Normalized config profile projected for downstream runtime use.
pub profile: ConfigProfile,
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// Small compatibility snapshot exposed for documentation and smoke checks.
pub struct CompatibilitySnapshot {
/// Version text rendered by the CLI surface.
pub version_banner: String,
/// Number of help sections currently exposed.
pub help_sections: usize,
/// Number of tracked protocol entries in the compatibility ledger.
pub tracked_protocol_count: usize,
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// Structured execution summary returned by runtime-oriented entrypoints.
pub struct RuntimeReport {
/// Number of URI-like inputs accepted by the invocation.
pub accepted_uri_count: usize,
/// Number of downloads registered in the dispatcher/runtime.
pub tracked_download_count: usize,
/// Number of downloads observed as complete during execution.
pub completed_download_count: usize,
/// First registered GID, when one exists.
pub first_gid: Option<String>,
/// First visible status from `aria2.tellStatus`, when available.
pub first_status: Option<String>,
/// First visible total length from `aria2.tellStatus`, when available.
pub first_total_length: Option<u64>,
/// First visible completed length from `aria2.tellStatus`, when available.
pub first_completed_length: Option<u64>,
/// First visible connection count from `aria2.tellStatus`, when available.
pub first_connections: Option<u32>,
/// Recognized transfer schemes extracted from the provided inputs.
pub recognized_schemes: Vec<String>,
/// High-level transfer classifications derived from the inputs.
pub transfer_kinds: Vec<TransferSelection>,
/// Loaded config summary, when a config file participated in execution.
pub config_report: Option<ConfigLoadReport>,
/// Runtime config projected from the startup profile and config.
pub derived_runtime: RuntimeConfig,
/// HTTP session projected from the startup profile and config.
pub http_session: HttpSessionModel,
/// Major version of the control-file format surfaced by storage.
pub control_file_version_major: u16,
/// First visible `BitTorrent` status projection, when available.
pub first_bt_status: Option<BtStatusReport>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// One logical transfer entity after expanding CLI arguments and input files.
pub(crate) struct TransferInputEntry {
/// Ordered URI candidates for the entity. Multiple entries represent mirrors.
pub(crate) uris: Vec<String>,
/// Implied per-download defaults synthesized from the source document.
pub(crate) implied_profile: Option<ConfigProfile>,
/// Optional per-entry overrides originating from input-file indentation blocks.
pub(crate) profile: Option<ConfigProfile>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
/// CLI-facing projection of BitTorrent-specific tellStatus fields.
pub struct BtStatusReport {
/// Whether the download is BitTorrent-backed.
pub is_bt: Option<bool>,
/// Whether the download still represents metadata-only state.
pub metadata_only: Option<bool>,
/// Canonical magnet URI, when available.
pub magnet_uri: Option<String>,
/// Number of announce-list tiers surfaced by the runtime.
pub announce_list_tier_count: Option<usize>,
/// Whether the download is currently seeding.
pub seeder: Option<bool>,
/// Current visible seeder count.
pub num_seeders: Option<u64>,
/// Current share ratio string.
pub share_ratio: Option<String>,
/// In-progress share ratio projection.
pub share_ratio_progress: Option<String>,
/// Remaining share ratio projection.
pub share_ratio_remaining: Option<String>,
/// Current share time in seconds.
pub share_time: Option<u64>,
}