chore: initial sanitized public snapshot
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
¶llel_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,
|
||||
®istered_entries,
|
||||
base_profile,
|
||||
&context.derived_runtime,
|
||||
)?;
|
||||
let summary = collect_runtime_execution_summary(&mut dispatcher, ®istered_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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+141
@@ -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);
|
||||
}
|
||||
+225
@@ -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);
|
||||
}
|
||||
+222
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
Reference in New Issue
Block a user