chore: initial sanitized public snapshot

This commit is contained in:
Aria2 Rust Pro Contributors
2026-07-18 14:04:26 +08:00
commit 7b3816441c
320 changed files with 76813 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "aria2-rust-pro-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
description.workspace = true
readme.workspace = true
keywords.workspace = true
categories.workspace = true
rust-version.workspace = true
[[bin]]
name = "aria2-rust-pro"
path = "src/main.rs"
[dependencies]
aria2-rust-pro-compat.workspace = true
aria2-rust-pro-core.workspace = true
aria2-rust-pro-protocol.workspace = true
aria2-rust-pro-rpc.workspace = true
aria2-rust-pro-storage.workspace = true
[dev-dependencies]
ssh2 = "0.9.5"
[lints]
workspace = true
+511
View File
@@ -0,0 +1,511 @@
#![doc(hidden)]
#![expect(
clippy::redundant_pub_crate,
reason = "this private CLI parsing module exposes parent-only helpers while keeping documentation focused on the public command surface"
)]
use super::*;
/// Parses aria2-compatible command-line arguments into a high-level invocation.
///
/// # Errors
///
/// Returns an error when an option is unknown or when an option requiring a
/// value is missing that value.
pub fn parse_args(args: impl IntoIterator<Item = OsString>) -> Result<Invocation, CliError> {
Ok(parse_cli(args)?.invocation)
}
/// Returns whether a token can be consumed as aria2-compatible boolean text.
fn looks_like_bool_value(value: &str) -> bool {
parse_bool_text(value).is_some()
}
/// Builds a transient compat profile from CLI-originated directives.
fn build_cli_profile(directives: Vec<ConfigDirective>) -> Option<ConfigProfile> {
(!directives.is_empty()).then(|| ConfigProfile {
name: "cli".to_owned(),
source: ConfigSource::RuntimeOverride,
document: ConfigDocument {
directives,
location: ConfigLocationKind::Cli,
scope: ConfigScope::Mixed,
},
})
}
/// Merges a file-backed config profile with CLI overrides, keeping CLI values last.
pub(crate) fn merged_profile(
file_profile: Option<&ConfigProfile>,
cli_profile: Option<&ConfigProfile>,
) -> Option<ConfigProfile> {
match (file_profile, cli_profile) {
(None, None) => None,
(Some(profile), None) | (None, Some(profile)) => Some(profile.clone()),
(Some(file_profile), Some(cli_profile)) => {
let mut directives = file_profile.document.directives.clone();
directives.extend(cli_profile.document.directives.clone());
Some(ConfigProfile {
name: format!("{}+cli", file_profile.name),
source: ConfigSource::RuntimeOverride,
document: ConfigDocument {
directives,
location: ConfigLocationKind::Cli,
scope: ConfigScope::Mixed,
},
})
}
}
}
/// Reads text for an aria2 input file from disk or stdin.
fn read_input_file_text(path: &str) -> Result<String, CliError> {
if path == "-" {
let text = {
let stdin_handle = io::stdin();
let mut stdin = stdin_handle.lock();
let mut text = String::new();
stdin.read_to_string(&mut text).map_err(|error| {
CliError::Io(format!("failed to read input-file from stdin: {error}"))
})?;
text
};
Ok(text)
} else {
fs::read_to_string(path)
.map_err(|error| CliError::Io(format!("failed to read input-file {path}: {error}")))
}
}
/// Builds a profile for one logical input-file entity.
fn build_input_entry_profile(
name: &str,
directives: Vec<ConfigDirective>,
) -> Option<ConfigProfile> {
(!directives.is_empty()).then(|| ConfigProfile {
name: name.to_owned(),
source: ConfigSource::InputFile,
document: ConfigDocument {
directives,
location: ConfigLocationKind::Inline,
scope: ConfigScope::Mixed,
},
})
}
/// Parses aria2 input-file text into logical transfer entities.
fn parse_input_file_entries(name: &str, text: &str) -> Result<Vec<TransferInputEntry>, CliError> {
let mut entries = Vec::new();
let mut current_uris: Option<Vec<String>> = None;
let mut current_directives = Vec::new();
let finalize_current =
|entries: &mut Vec<TransferInputEntry>,
current_uris: &mut Option<Vec<String>>,
current_directives: &mut Vec<ConfigDirective>| {
if let Some(uris) = current_uris.take()
&& !uris.is_empty()
{
let profile = build_input_entry_profile(name, std::mem::take(current_directives));
entries.push(TransferInputEntry {
uris,
implied_profile: None,
profile,
});
}
};
for raw_line in text.lines() {
let trimmed = raw_line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
continue;
}
if raw_line.chars().next().is_some_and(char::is_whitespace) {
let Some(_) = current_uris else {
return Err(CliError::Config(ConfigParseError::InvalidDirective(
raw_line.to_owned(),
)));
};
if let Some(directive) = parse_config_line_strict(trimmed).map_err(CliError::Config)? {
current_directives.push(directive);
}
continue;
}
finalize_current(&mut entries, &mut current_uris, &mut current_directives);
let uris = raw_line
.split('\t')
.map(str::trim)
.filter(|uri| !uri.is_empty())
.map(str::to_owned)
.collect::<Vec<_>>();
if uris.is_empty() {
return Err(CliError::Config(ConfigParseError::InvalidDirective(
raw_line.to_owned(),
)));
}
current_uris = Some(uris);
}
finalize_current(&mut entries, &mut current_uris, &mut current_directives);
Ok(entries)
}
/// Expands CLI positional URIs plus any configured input-file into logical transfer entries.
pub(crate) fn expand_transfer_entries(
uris: &[String],
profile: Option<&ConfigProfile>,
cli_sources: Option<&[CliTransferSource]>,
) -> Result<Vec<TransferInputEntry>, CliError> {
let mut entries = Vec::new();
if let Some(cli_sources) = cli_sources
&& !cli_sources.is_empty()
{
for source in cli_sources {
match source {
CliTransferSource::Uri(uri) => entries.push(TransferInputEntry {
uris: vec![uri.clone()],
implied_profile: None,
profile: None,
}),
CliTransferSource::InputFile(path) => {
let text = read_input_file_text(path)?;
entries.extend(parse_input_file_entries(path, &text)?);
}
}
}
} else {
entries.extend(uris.iter().map(|uri| TransferInputEntry {
uris: vec![uri.clone()],
implied_profile: None,
profile: None,
}));
}
if let Some(path) = profile
.and_then(|profile| profile_option_value(profile, "input-file"))
.map(ToOwned::to_owned)
&& !cli_sources.unwrap_or(&[]).iter().any(
|source| matches!(source, CliTransferSource::InputFile(existing) if existing == &path),
)
{
let text = read_input_file_text(&path)?;
entries.extend(parse_input_file_entries(&path, &text)?);
}
Ok(entries)
}
/// Converts compat directives into an RPC option object.
pub(crate) fn rpc_option_object(profile: Option<&ConfigProfile>) -> Option<RpcValue> {
let profile = profile?;
let mut options = std::collections::BTreeMap::new();
for directive in &profile.document.directives {
let Some(value) = directive.value.as_ref() else {
continue;
};
options.insert(directive.name.clone(), RpcValue::String(value.clone()));
}
if options.is_empty() {
return None;
}
Some(RpcValue::Object(options))
}
/// Builds an inline compat profile from a small directive set.
pub(crate) fn config_profile_from_directives(
name: &str,
source: ConfigSource,
directives: Vec<ConfigDirective>,
) -> Option<ConfigProfile> {
(!directives.is_empty()).then(|| ConfigProfile {
name: name.to_owned(),
source,
document: ConfigDocument {
directives,
location: ConfigLocationKind::Inline,
scope: ConfigScope::PerDownload,
},
})
}
/// Parses aria2 checksum option text into an enabled checksum hook.
pub(crate) fn parse_checksum_hook_text(value: &str) -> Option<ChecksumHookModel> {
let (algorithm, expected_hex) = value.split_once('=')?;
let algorithm = algorithm.trim().to_ascii_lowercase();
let expected_hex = expected_hex.trim().to_ascii_lowercase();
if algorithm.is_empty() || expected_hex.is_empty() {
return None;
}
Some(ChecksumHookModel {
spec: ChecksumSpec {
algorithm,
expected_hex,
actual_hex: None,
},
enabled: true,
})
}
/// Synthesizes per-download defaults implied by a Metalink file entry.
pub(crate) fn metalink_entry_implied_profile(
file_name: &str,
checksum: Option<&ChecksumSpec>,
) -> Option<ConfigProfile> {
let mut directives = Vec::new();
if !file_name.trim().is_empty() {
directives.push(ConfigDirective {
name: "out".to_owned(),
value: Some(file_name.trim().to_owned()),
});
}
if let Some(checksum) = checksum {
directives.push(ConfigDirective {
name: "checksum".to_owned(),
value: Some(format!("{}={}", checksum.algorithm, checksum.expected_hex)),
});
}
config_profile_from_directives(
"metalink-implied",
ConfigSource::RuntimeOverride,
directives,
)
}
/// Parses an aria2-style CLI option into a compat directive.
///
/// Returns the parsed directive plus whether the next argv item was consumed.
fn parse_cli_override_argument(
arg: &str,
next: Option<&str>,
) -> Result<(ConfigDirective, bool), CliError> {
let option_name = arg.trim_start_matches('-');
if option_name.is_empty() {
return Err(CliError::UnknownOption(arg.to_owned()));
}
let parse_directive = |text: &str| -> Result<ConfigDirective, CliError> {
parse_config_line_strict(text)
.map_err(CliError::Config)?
.ok_or_else(|| CliError::UnknownOption(arg.to_owned()))
};
if arg.contains('=') {
return Ok((parse_directive(arg)?, false));
}
let spec = option_spec(option_name).ok_or_else(|| CliError::UnknownOption(arg.to_owned()))?;
if spec.kind == OptionKind::Bool {
if let Some(value) = next.filter(|candidate| looks_like_bool_value(candidate)) {
Ok((parse_directive(&format!("{arg}={value}"))?, true))
} else {
Ok((parse_directive(&format!("{arg}=true"))?, false))
}
} else {
let value = next.ok_or_else(|| CliError::MissingValue(arg.to_owned()))?;
Ok((parse_directive(&format!("{arg}={value}"))?, true))
}
}
/// Consumes the next CLI token as a required value-bearing argument.
fn next_cli_value<I>(args: &mut std::iter::Peekable<I>, option: &str) -> Result<String, CliError>
where
I: Iterator<Item = String>,
{
args.next()
.ok_or_else(|| CliError::MissingValue(option.to_owned()))
}
/// Parses aria2-compatible command-line arguments into a richer startup model.
///
/// # Errors
///
/// Returns an error when an option is unknown or when an option requiring a
/// value is missing that value.
#[expect(
clippy::too_many_lines,
reason = "CLI flag parsing stays linear to preserve aria2-compatible option precedence"
)]
pub fn parse_cli(args: impl IntoIterator<Item = OsString>) -> Result<ParsedArguments, CliError> {
let mut args = args
.into_iter()
.map(|arg| arg.into_string().map_err(|_| CliError::InvalidUtf8Argument))
.collect::<Result<Vec<_>, _>>()?
.into_iter();
let _program = args.next();
let mut args = args.peekable();
let mut config_path = None;
let mut uris = Vec::new();
let mut profile = StartupProfile::default();
let mut cli_directives = Vec::new();
let mut cli_transfer_sources = Vec::new();
while let Some(arg) = args.next() {
match arg.as_str() {
"--version" | "-v" => {
return Ok(ParsedArguments {
invocation: Invocation::Version,
profile,
cli_profile: None,
cli_transfer_sources: Vec::new(),
});
}
"--help" | "-h" | "--help=#all" => {
return Ok(ParsedArguments {
invocation: Invocation::Help { query: None },
profile,
cli_profile: None,
cli_transfer_sources: Vec::new(),
});
}
_ if arg.starts_with("--help=") || arg.starts_with("-h=") => {
let query = arg
.strip_prefix("--help=")
.or_else(|| arg.strip_prefix("-h="))
.map(str::to_owned);
return Ok(ParsedArguments {
invocation: Invocation::Help { query },
profile,
cli_profile: None,
cli_transfer_sources: Vec::new(),
});
}
"--conf-path" => {
config_path = Some(PathBuf::from(next_cli_value(&mut args, &arg)?));
}
"--enable-rpc" => {
let (directive, consumed_next) =
parse_cli_override_argument(&arg, args.peek().map(String::as_str))?;
if directive
.value
.as_deref()
.and_then(parse_bool_text)
.unwrap_or(true)
{
profile.rpc.enabled = true;
}
cli_directives.push(directive);
if consumed_next {
let _ = args.next();
}
}
"--rpc-listen-all" => {
let (directive, consumed_next) =
parse_cli_override_argument(&arg, args.peek().map(String::as_str))?;
if directive
.value
.as_deref()
.and_then(parse_bool_text)
.unwrap_or(true)
{
"0.0.0.0".clone_into(&mut profile.rpc.listen_host);
}
cli_directives.push(directive);
if consumed_next {
let _ = args.next();
}
}
"--daemon" | "-D" => {
profile.mode = RuntimeMode::Daemon;
profile.daemonize = true;
}
"--rpc-only" => profile.mode = RuntimeMode::RpcOnly,
"--dry-run" => profile.dry_run = true,
_ if arg.starts_with("--conf-path=") => {
config_path = Some(PathBuf::from(arg.trim_start_matches("--conf-path=")));
}
_ if arg.starts_with("--rpc-listen-port=") => {
let value = arg.trim_start_matches("--rpc-listen-port=");
profile.rpc.listen_port = value
.parse()
.map_err(|_| CliError::UnknownOption(arg.clone()))?;
cli_directives.push(parse_cli_override_argument(&arg, None)?.0);
}
"--rpc-listen-port" => {
let value = next_cli_value(&mut args, &arg)?;
profile.rpc.listen_port = value
.parse()
.map_err(|_| CliError::UnknownOption(arg.clone()))?;
cli_directives.push(parse_cli_override_argument(&arg, Some(value.as_str()))?.0);
}
_ if arg.starts_with("--rpc-secret=") => {
profile.rpc.secret = Some(arg.trim_start_matches("--rpc-secret=").to_owned());
}
"--rpc-secret" => {
let value = next_cli_value(&mut args, &arg)?;
profile.rpc.secret = Some(value.clone());
}
_ if arg.starts_with("--rpc-path=") => {
arg.trim_start_matches("--rpc-path=")
.clone_into(&mut profile.rpc.path);
}
"--rpc-path" => {
next_cli_value(&mut args, &arg)?.clone_into(&mut profile.rpc.path);
}
"--input-file" | "-i" => {
let value = next_cli_value(&mut args, &arg)?;
cli_directives.push(parse_cli_override_argument(&arg, Some(value.as_str()))?.0);
cli_transfer_sources.push(CliTransferSource::InputFile(value));
}
_ if arg.starts_with("--input-file=") || arg.starts_with("-i=") => {
let value = arg
.split_once('=')
.map(|(_, value)| value.to_owned())
.ok_or_else(|| CliError::MissingValue(arg.clone()))?;
cli_directives.push(parse_cli_override_argument(&arg, None)?.0);
cli_transfer_sources.push(CliTransferSource::InputFile(value));
}
_ if arg.starts_with('-') => {
let (directive, consumed_next) =
parse_cli_override_argument(&arg, args.peek().map(String::as_str))?;
cli_directives.push(directive);
if consumed_next {
let _ = args.next();
}
}
_ => {
uris.push(arg.clone());
cli_transfer_sources.push(CliTransferSource::Uri(arg.clone()));
}
}
}
Ok(ParsedArguments {
invocation: Invocation::Run { config_path, uris },
profile,
cli_profile: build_cli_profile(cli_directives),
cli_transfer_sources,
})
}
#[must_use]
/// Renders the CLI help surface, optionally filtered by a help query.
pub fn render_help(query: Option<&str>) -> String {
query.map_or_else(help_text, |query| help_text_for_query(Some(query)))
}
#[must_use]
/// Renders the compatibility-oriented help surface.
pub fn render_compatibility_help() -> String {
compatibility_help_text()
}
#[must_use]
/// Renders the CLI version banner.
pub fn render_version() -> String {
cli_version_text()
}
#[must_use]
/// Captures a small compatibility snapshot for smoke tests and docs.
pub fn compatibility_snapshot() -> CompatibilitySnapshot {
CompatibilitySnapshot {
version_banner: render_version(),
help_sections: help_sections().len(),
tracked_protocol_count: compat_ledger().entries.len(),
}
}
@@ -0,0 +1,39 @@
#![doc(hidden)]
#![expect(
clippy::redundant_pub_crate,
reason = "this private HTTP runtime helper facade keeps parent-only transfer helpers available without forcing public-facing docs onto every internal step"
)]
use std::{
borrow::Cow,
collections::VecDeque,
fs,
io::{self, Write as _},
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use aria2_rust_pro_compat::ConfigProfile;
use aria2_rust_pro_core::{RequestGroup, RuntimeConfig};
use aria2_rust_pro_protocol::http::HttpResponseSinkTarget;
use aria2_rust_pro_protocol::{
Downloader, HeaderKind, HttpBody, HttpCompletionState, HttpHeader, HttpMethod,
HttpRequestHeaders, HttpRequestModel, HttpResponseHeaders, HttpResponseModel, HttpSessionModel,
HttpTransferTaskModel, HttpVersion, RangeSpec, RangeUnit, ResponseBody, RetryPolicy,
};
use super::{
CliError, lossless_u64_from_usize, parse_checksum_hook_text, profile_option_value,
saturating_u16_from_usize, saturating_u32_from_usize, saturating_usize_from_u64,
};
pub(crate) use self::{build::*, execution::*, persist::*, planning::*};
#[doc(hidden)]
mod build;
#[doc(hidden)]
mod execution;
#[doc(hidden)]
mod persist;
#[doc(hidden)]
mod planning;
@@ -0,0 +1,98 @@
#![doc(hidden)]
use super::{
ConfigProfile, HeaderKind, HttpBody, HttpHeader, HttpMethod, HttpRequestHeaders,
HttpRequestModel, HttpResponseHeaders, HttpResponseSinkTarget, HttpSessionModel,
HttpTransferTaskModel, HttpVersion, PathBuf, RangeSpec, RangeUnit, RequestGroup, ResponseBody,
RuntimeConfig, parse_checksum_hook_text, profile_option_value, saturating_u16_from_usize,
};
pub(crate) fn build_http_transfer_task(
task_id: String,
uri: String,
session: &HttpSessionModel,
runtime: &RuntimeConfig,
profile: Option<&ConfigProfile>,
) -> HttpTransferTaskModel {
build_http_transfer_task_with_target(task_id, uri, session, runtime, profile, None)
}
pub(crate) fn build_http_transfer_task_with_target(
task_id: String,
uri: String,
session: &HttpSessionModel,
runtime: &RuntimeConfig,
profile: Option<&ConfigProfile>,
target_path: Option<PathBuf>,
) -> HttpTransferTaskModel {
let mut headers = session.default_headers.clone();
if let Some(user_agent) = &session.user_agent {
headers.push(HttpHeader {
name: "user-agent".to_owned(),
value: user_agent.clone(),
kind: HeaderKind::Request,
});
}
let connection_budget =
saturating_u16_from_usize(runtime.max_connections_per_server.min(runtime.split.max(1)));
let checksum_hook = profile
.and_then(|profile| profile_option_value(profile, "checksum"))
.and_then(parse_checksum_hook_text);
let response_sink = target_path
.filter(|_| checksum_hook.is_none())
.map(|target_path| HttpResponseSinkTarget { target_path });
let request = HttpRequestModel {
method: HttpMethod::Get,
url: uri,
version: HttpVersion::Http11,
headers: HttpRequestHeaders { headers },
query: std::collections::HashMap::new(),
range: None,
body: HttpBody::Empty,
retry: session.retry,
auth: session.auth.clone(),
proxy: session.proxy.clone(),
response_sink,
};
HttpTransferTaskModel {
task_id,
request,
response_headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Empty,
resume_state: None,
retry_attempts: Vec::new(),
checksum_hook,
max_connections: connection_budget.max(1),
retry: session.retry,
}
}
pub(crate) fn build_segment_transfer_tasks(
base_task: &HttpTransferTaskModel,
group: &RequestGroup,
) -> Vec<HttpTransferTaskModel> {
if group.segment_assignments().is_empty() {
return vec![base_task.clone()];
}
group
.segment_assignments()
.iter()
.map(|assignment| {
let mut task = base_task.clone();
task.request.range = Some(RangeSpec {
start: assignment.range.start,
end_inclusive: Some(assignment.range.end.saturating_sub(1)),
unit: RangeUnit::Bytes,
});
task.resume_state = Some(aria2_rust_pro_protocol::ResumeState {
requested_offset: assignment.range.start,
accepted_offset: None,
resumed: assignment.range.start > 0,
});
task
})
.collect()
}
@@ -0,0 +1,409 @@
#![doc(hidden)]
use super::{
Arc, Cow, Downloader, HttpResponseModel, HttpTransferTaskModel, Mutex, RangeSpec, RangeUnit,
RetryPolicy, RuntimeConfig, VecDeque, planned_segment_span, saturating_u32_from_usize,
saturating_usize_from_u64,
};
const fn should_retry_response(response: &HttpResponseModel, policy: &RetryPolicy) -> bool {
match response.status {
300..=399 => policy.retry_on_3xx,
400..=499 => policy.retry_on_4xx,
500..=599 => policy.retry_on_5xx,
_ => false,
}
}
fn response_total_length(response: &HttpResponseModel) -> Option<u64> {
response.total_length()
}
fn response_completed_length(response: &HttpResponseModel) -> u64 {
response.completed_length()
}
pub(crate) struct HttpTransferExecution {
pub(crate) response: Option<HttpResponseModel>,
pub(crate) retry_count: u32,
pub(crate) retry_attempts: Vec<aria2_rust_pro_protocol::RetryAttempt>,
pub(crate) planned_ranges: Vec<Option<RangeSpec>>,
pub(crate) checksum_observed: bool,
pub(crate) checksum_complete: bool,
}
#[expect(
clippy::single_match_else,
clippy::too_many_lines,
reason = "retry, resume, checksum, and terminal-status branches are kept together to preserve transfer semantics"
)]
pub(crate) fn execute_http_transfer_with_retry<D: Downloader>(
downloader: &D,
base_task: &HttpTransferTaskModel,
runtime: &RuntimeConfig,
) -> HttpTransferExecution {
let max_attempts = base_task.retry.policy.max_attempts.max(1);
let requested_start = base_task
.request
.range
.as_ref()
.map_or(0, |range| range.start);
let requested_end_exclusive = base_task
.request
.range
.as_ref()
.and_then(|range| range.end_inclusive.map(|end| end.saturating_add(1)));
let mut completed_length = requested_start;
let mut retry_attempts = Vec::new();
let mut current_total_length = 0_u64;
let mut planned_ranges = Vec::new();
let mut checksum_observed = false;
let mut checksum_complete = false;
for attempt in 0..max_attempts {
let next_attempt = attempt.saturating_add(1);
let task = if completed_length > requested_start || !retry_attempts.is_empty() {
let mut owned_task = base_task.clone();
if completed_length > requested_start {
let end_inclusive = requested_end_exclusive
.map(|end| end.saturating_sub(1))
.or_else(|| {
planned_segment_span(current_total_length, runtime).and_then(|span| {
let next_end = completed_length.saturating_add(span).saturating_sub(1);
(current_total_length > 0)
.then_some(next_end.min(current_total_length.saturating_sub(1)))
})
});
owned_task.request.range = Some(RangeSpec {
start: completed_length,
end_inclusive,
unit: RangeUnit::Bytes,
});
owned_task.resume_state = Some(aria2_rust_pro_protocol::ResumeState {
requested_offset: completed_length,
accepted_offset: None,
resumed: true,
});
}
if !retry_attempts.is_empty() {
owned_task.retry_attempts.clone_from(&retry_attempts);
}
Cow::Owned(owned_task)
} else {
Cow::Borrowed(base_task)
};
planned_ranges.push(task.request.range);
match downloader.start_http_transfer(task.as_ref()) {
Ok(response) => {
let success = (200..=299).contains(&response.status);
let total_length = response_total_length(&response).unwrap_or(0);
if response.checksum.is_some() {
checksum_observed = true;
}
if total_length > 0 {
current_total_length = total_length;
}
let completed_after = response_completed_length(&response);
if success {
completed_length = completed_length.max(completed_after);
if response.checksum.is_some()
&& total_length > 0
&& completed_length >= total_length
&& response.completion_model().checksum_verified
{
checksum_complete = true;
}
}
let terminal_success = if let Some(segment_end) = requested_end_exclusive {
success && completed_length >= segment_end
} else {
success
&& (!response.partial_content
|| (total_length > 0 && completed_length >= total_length))
};
if terminal_success || next_attempt >= max_attempts {
return HttpTransferExecution {
response: Some(response),
retry_count: saturating_u32_from_usize(retry_attempts.len()),
retry_attempts,
planned_ranges,
checksum_observed,
checksum_complete,
};
}
if success
&& response.partial_content
&& requested_end_exclusive
.is_some_and(|segment_end| completed_length < segment_end)
{
retry_attempts.push(aria2_rust_pro_protocol::RetryAttempt {
attempt: next_attempt,
reason: aria2_rust_pro_protocol::RetryReason::Other,
status: Some(response.status),
backoff_ms: Some(0),
});
continue;
}
if !should_retry_response(&response, &task.retry.policy) {
return HttpTransferExecution {
response: Some(response),
retry_count: saturating_u32_from_usize(retry_attempts.len()),
retry_attempts,
planned_ranges,
checksum_observed,
checksum_complete,
};
}
let retry_reason = match response.status {
300..=399 => aria2_rust_pro_protocol::RetryReason::Http3xx,
400..=499 => aria2_rust_pro_protocol::RetryReason::Http4xx,
500..=599 => aria2_rust_pro_protocol::RetryReason::Http5xx,
_ => aria2_rust_pro_protocol::RetryReason::Other,
};
retry_attempts.push(aria2_rust_pro_protocol::RetryAttempt {
attempt: next_attempt,
reason: retry_reason,
status: Some(response.status),
backoff_ms: None,
});
}
Err(_) => {
if next_attempt >= max_attempts || !task.retry.policy.retry_on_network_error {
return HttpTransferExecution {
response: None,
retry_count: saturating_u32_from_usize(retry_attempts.len()),
retry_attempts,
planned_ranges,
checksum_observed,
checksum_complete,
};
}
retry_attempts.push(aria2_rust_pro_protocol::RetryAttempt {
attempt: next_attempt,
reason: aria2_rust_pro_protocol::RetryReason::NetworkError,
status: None,
backoff_ms: None,
});
}
}
}
HttpTransferExecution {
response: None,
retry_count: saturating_u32_from_usize(retry_attempts.len()),
retry_attempts,
planned_ranges,
checksum_observed,
checksum_complete,
}
}
pub(crate) fn execute_tagged_segment_transfers<D: Downloader + Sync, T: Send>(
downloader: &D,
planned_tasks: Vec<(T, HttpTransferTaskModel)>,
runtime: &RuntimeConfig,
) -> Vec<(T, HttpTransferTaskModel, HttpTransferExecution)> {
let parallelism = effective_segment_transfer_parallelism(runtime, planned_tasks.len());
execute_tagged_segment_transfers_with_parallelism(
downloader,
planned_tasks,
runtime,
parallelism,
)
}
pub(crate) fn execute_tagged_segment_transfers_with_parallelism<D: Downloader + Sync, T: Send>(
downloader: &D,
planned_tasks: Vec<(T, HttpTransferTaskModel)>,
runtime: &RuntimeConfig,
parallelism: usize,
) -> Vec<(T, HttpTransferTaskModel, HttpTransferExecution)> {
if planned_tasks.len() <= 1 || parallelism <= 1 {
return planned_tasks
.into_iter()
.map(|(tag, task)| {
let execution = execute_http_transfer_with_retry(downloader, &task, runtime);
(tag, task, execution)
})
.collect();
}
if planned_tasks.len() <= parallelism.saturating_mul(4) {
return execute_tagged_segment_transfers_static_partitioned(
downloader,
planned_tasks,
runtime,
parallelism,
);
}
let task_count = planned_tasks.len();
let chunk_size = task_count
.div_ceil(parallelism.saturating_mul(4).max(1))
.max(1);
let mut chunk_queue = VecDeque::new();
let mut current_chunk = Vec::with_capacity(chunk_size);
for item in planned_tasks.into_iter().enumerate() {
current_chunk.push(item);
if current_chunk.len() >= chunk_size {
chunk_queue.push_back(std::mem::take(&mut current_chunk));
current_chunk = Vec::with_capacity(chunk_size);
}
}
if !current_chunk.is_empty() {
chunk_queue.push_back(current_chunk);
}
let work_chunks = Arc::new(Mutex::new(chunk_queue));
let mut indexed_results = std::thread::scope(|scope| {
let mut handles = Vec::with_capacity(parallelism);
for _ in 0..parallelism {
let work_chunks = Arc::clone(&work_chunks);
handles.push(scope.spawn(move || {
let mut local_results = Vec::new();
loop {
let Some(chunk) = work_chunks
.lock()
.expect("segment work chunk mutex should not be poisoned")
.pop_front()
else {
break;
};
local_results.reserve(chunk.len());
for (index, (tag, task)) in chunk {
let execution =
execute_http_transfer_with_retry(downloader, &task, runtime);
local_results.push((index, (tag, task, execution)));
}
}
local_results
}));
}
handles
.into_iter()
.flat_map(|handle| {
handle
.join()
.expect("dynamic segment transfer worker should not panic")
})
.collect::<Vec<_>>()
});
debug_assert_eq!(indexed_results.len(), task_count);
indexed_results.sort_by_key(|(index, _)| *index);
indexed_results
.into_iter()
.map(|(_, result)| result)
.collect()
}
fn execute_tagged_segment_transfers_static_partitioned<D: Downloader + Sync, T: Send>(
downloader: &D,
planned_tasks: Vec<(T, HttpTransferTaskModel)>,
runtime: &RuntimeConfig,
parallelism: usize,
) -> Vec<(T, HttpTransferTaskModel, HttpTransferExecution)> {
let task_count = planned_tasks.len();
let indexed_chunks = partition_indexed_work_evenly(
planned_tasks.into_iter().enumerate(),
task_count,
parallelism,
);
std::thread::scope(|scope| {
let mut handles = Vec::with_capacity(indexed_chunks.len());
for chunk in indexed_chunks {
handles.push(scope.spawn(move || {
chunk
.into_iter()
.map(|(index, (tag, task))| {
let execution =
execute_http_transfer_with_retry(downloader, &task, runtime);
(index, (tag, task, execution))
})
.collect::<Vec<_>>()
}));
}
let mut ordered_results = std::iter::repeat_with(|| None)
.take(task_count)
.collect::<Vec<_>>();
for handle in handles {
for (index, result) in handle
.join()
.expect("static segment transfer worker should not panic")
{
*ordered_results
.get_mut(index)
.expect("static segment transfer worker should return an in-bounds index") =
Some(result);
}
}
ordered_results
.into_iter()
.map(|result| result.expect("static segment transfer worker should fill every slot"))
.collect()
})
}
pub(crate) fn execute_segment_transfers<D: Downloader + Sync>(
downloader: &D,
planned_tasks: Vec<HttpTransferTaskModel>,
runtime: &RuntimeConfig,
) -> Vec<(HttpTransferTaskModel, HttpTransferExecution)> {
execute_tagged_segment_transfers(
downloader,
planned_tasks.into_iter().map(|task| ((), task)).collect(),
runtime,
)
.into_iter()
.map(|((), task, execution)| (task, execution))
.collect()
}
pub(crate) fn partition_indexed_work_evenly<T>(
indexed_work: impl IntoIterator<Item = (usize, T)>,
task_count: usize,
parallelism: usize,
) -> Vec<Vec<(usize, T)>> {
if task_count == 0 {
return Vec::new();
}
let worker_count = parallelism.max(1).min(task_count);
let mut chunks = std::iter::repeat_with(Vec::new)
.take(worker_count)
.collect::<Vec<_>>();
for (ordinal, item) in indexed_work.into_iter().enumerate() {
let chunk_index = ordinal
.checked_rem(worker_count)
.expect("worker count is nonzero when partitioning indexed work");
chunks
.get_mut(chunk_index)
.expect("round-robin chunk index should be in bounds")
.push(item);
}
chunks.retain(|chunk| !chunk.is_empty());
chunks
}
pub(crate) fn effective_segment_transfer_parallelism(
runtime: &RuntimeConfig,
planned_tasks: usize,
) -> usize {
if planned_tasks <= 1 {
return planned_tasks;
}
let segment_unit = runtime.min_split_size.max(runtime.piece_length).max(1);
let mut parallelism = planned_tasks;
if let Some(limit) = runtime
.max_download_limit
.map(|bytes_per_second| saturating_usize_from_u64(bytes_per_second.div_ceil(segment_unit)))
{
parallelism = parallelism.min(limit.max(1));
}
parallelism.max(1).min(planned_tasks)
}
@@ -0,0 +1,173 @@
#![doc(hidden)]
use super::*;
fn derive_http_target_path(profile: Option<&ConfigProfile>, uri: &str) -> PathBuf {
let dir = profile
.and_then(|profile| profile_option_value(profile, "dir"))
.map(PathBuf::from);
let file_name = profile
.and_then(|profile| profile_option_value(profile, "out"))
.map(ToOwned::to_owned)
.or_else(|| {
uri.rsplit('/')
.next()
.map(|segment| segment.split(['?', '#']).next().unwrap_or(segment))
.filter(|segment| !segment.is_empty())
.map(str::to_owned)
})
.unwrap_or_else(|| "download.bin".to_owned());
match dir {
Some(dir) => dir.join(file_name),
None => PathBuf::from(file_name),
}
}
pub(crate) fn prepare_http_target_path(
profile: Option<&ConfigProfile>,
uri: &str,
) -> Result<PathBuf, CliError> {
let target_path = derive_http_target_path(profile, uri);
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
CliError::Io(format!("failed to create download directory: {error}"))
})?;
}
Ok(target_path)
}
fn copy_temp_file_contents_to_target(
temp_path: &Path,
target_path: &Path,
offset: u64,
truncate: bool,
) -> io::Result<()> {
let mut source = fs::File::open(temp_path)?;
let mut target = fs::OpenOptions::new()
.create(true)
.truncate(truncate)
.write(true)
.open(target_path)?;
io::Seek::seek(&mut target, io::SeekFrom::Start(offset))?;
io::copy(&mut source, &mut target)?;
Ok(())
}
fn write_temp_file_to_target(temp_path: &Path, target_path: &Path, offset: u64) -> io::Result<()> {
if offset == 0 {
if fs::rename(temp_path, target_path).is_ok() {
return Ok(());
}
return copy_temp_file_contents_to_target(temp_path, target_path, 0, true);
}
copy_temp_file_contents_to_target(temp_path, target_path, offset, false)
}
fn write_bytes_to_target(target_path: &Path, offset: u64, payload: &[u8]) -> io::Result<()> {
let mut target = fs::OpenOptions::new()
.create(true)
.truncate(offset == 0)
.write(true)
.open(target_path)?;
io::Seek::seek(&mut target, io::SeekFrom::Start(offset))?;
target.write_all(payload)?;
Ok(())
}
pub(crate) fn persist_http_response_body_to_target(
target_path: &Path,
task: &HttpTransferTaskModel,
response: &HttpResponseModel,
) -> Result<(), CliError> {
let offset = response
.content_range
.as_ref()
.map(|range| range.start)
.or_else(|| task.request.range.as_ref().map(|range| range.start))
.unwrap_or(0);
match &response.body {
ResponseBody::Empty => {
if offset == 0 && !response.partial_content {
let _ = fs::File::create(target_path).map_err(|error| {
CliError::Io(format!(
"failed to create download target {}: {error}",
target_path.display()
))
})?;
}
}
ResponseBody::Inline(bytes) => {
write_bytes_to_target(target_path, offset, bytes).map_err(|error| {
CliError::Io(format!(
"failed to persist inline response body to {}: {error}",
target_path.display()
))
})?;
}
ResponseBody::Streamed { temp_path, .. } => {
if let Some(temp_path) = temp_path {
let write_result = write_temp_file_to_target(temp_path, target_path, offset)
.map_err(|error| {
CliError::Io(format!(
"failed to persist streamed response body to {}: {error}",
target_path.display()
))
});
let _ = fs::remove_file(temp_path);
write_result?;
}
}
}
Ok(())
}
fn persisted_http_target_satisfies_completion(
profile: Option<&ConfigProfile>,
uri: &str,
task: &HttpTransferTaskModel,
response: &HttpResponseModel,
) -> bool {
let Some(total_length) = response.total_length() else {
return false;
};
let target_path = derive_http_target_path(profile, uri);
let Ok(metadata) = fs::metadata(&target_path) else {
return false;
};
if metadata.len() < total_length {
return false;
}
let Some(checksum_hook) = task.checksum_hook.as_ref().filter(|hook| hook.enabled) else {
return true;
};
fs::read(target_path)
.ok()
.and_then(|bytes| checksum_hook.spec.verify_payload(&bytes))
.unwrap_or(false)
}
pub(crate) fn http_execution_completed_via_checksum(
execution: &HttpTransferExecution,
response: &HttpResponseModel,
profile: Option<&ConfigProfile>,
uri: &str,
task: &HttpTransferTaskModel,
) -> bool {
if !execution.checksum_observed {
return false;
}
let completion = response.completion_model();
let response_checksum_complete = matches!(
completion.state,
HttpCompletionState::Complete | HttpCompletionState::Verified
) && execution.checksum_complete;
response_checksum_complete
|| persisted_http_target_satisfies_completion(profile, uri, task, response)
}
@@ -0,0 +1,167 @@
#![doc(hidden)]
use super::{
Downloader, HttpTransferExecution, HttpTransferTaskModel, RangeSpec, RangeUnit, RuntimeConfig,
execute_http_transfer_with_retry, lossless_u64_from_usize,
};
const SMALL_SEGMENT_PROBE_ALIGNMENT_LIMIT: u64 = 64 * 1_024;
pub(crate) struct InitialHttpExecutionPlan {
pub(crate) task: HttpTransferTaskModel,
pub(crate) execution: HttpTransferExecution,
pub(crate) planned_segments: Vec<HttpTransferTaskModel>,
}
pub(crate) fn planned_segment_span(total_length: u64, runtime: &RuntimeConfig) -> Option<u64> {
if runtime.split <= 1 || total_length == 0 {
return None;
}
let split_budget = lossless_u64_from_usize(runtime.split.max(1));
let planned = total_length.div_ceil(split_budget);
Some(
planned
.max(runtime.min_split_size)
.max(runtime.piece_length)
.min(total_length),
)
}
pub(crate) fn build_initial_http_execution_plan<D: Downloader>(
downloader: &D,
base_task: &HttpTransferTaskModel,
runtime: &RuntimeConfig,
) -> InitialHttpExecutionPlan {
if !should_attempt_initial_segment_probe(base_task) {
return InitialHttpExecutionPlan {
task: base_task.clone(),
execution: execute_http_transfer_with_retry(downloader, base_task, runtime),
planned_segments: Vec::new(),
};
}
let probe_task = build_initial_segment_probe_task(base_task, runtime);
let probe_execution = execute_http_transfer_with_retry(downloader, &probe_task, runtime);
let planned_segments = probe_execution
.response
.as_ref()
.and_then(|response| {
response
.partial_content
.then_some((response.total_length(), response.completed_length()))
})
.and_then(|(total_length, completed_length)| {
total_length.map(|total_length| {
build_balanced_segment_transfer_tasks(
base_task,
runtime,
completed_length.min(total_length),
total_length,
)
})
})
.unwrap_or_default();
InitialHttpExecutionPlan {
task: probe_task,
execution: probe_execution,
planned_segments,
}
}
const fn should_attempt_initial_segment_probe(task: &HttpTransferTaskModel) -> bool {
task.request.range.is_none() && task.max_connections > 1
}
fn build_initial_segment_probe_task(
base_task: &HttpTransferTaskModel,
runtime: &RuntimeConfig,
) -> HttpTransferTaskModel {
let mut probe_task = base_task.clone();
let alignment = runtime.min_split_size.max(runtime.piece_length).max(1);
let probe_span =
if base_task.max_connections >= 4 && alignment <= SMALL_SEGMENT_PROBE_ALIGNMENT_LIMIT {
alignment
.saturating_mul(u64::from(base_task.max_connections))
.saturating_mul(2)
} else if base_task.max_connections >= 4 {
alignment.saturating_mul(2)
} else {
alignment
};
probe_task.request.range = Some(RangeSpec {
start: 0,
end_inclusive: Some(probe_span.saturating_sub(1)),
unit: RangeUnit::Bytes,
});
probe_task.resume_state = Some(aria2_rust_pro_protocol::ResumeState {
requested_offset: 0,
accepted_offset: None,
resumed: false,
});
probe_task
}
fn build_balanced_segment_transfer_tasks(
base_task: &HttpTransferTaskModel,
runtime: &RuntimeConfig,
start_offset: u64,
total_length: u64,
) -> Vec<HttpTransferTaskModel> {
if total_length <= start_offset {
return Vec::new();
}
let desired_segments = usize::from(base_task.max_connections.max(1));
let alignment = runtime.min_split_size.max(runtime.piece_length).max(1);
let remaining = total_length.saturating_sub(start_offset);
let mut segment_count = desired_segments
.min(
usize::try_from(remaining.div_ceil(runtime.min_split_size.max(1)))
.unwrap_or(usize::MAX),
)
.max(1);
if desired_segments >= 4 && segment_count > 1 && remaining <= alignment.saturating_mul(2) {
segment_count = 1;
} else if segment_count > 2 && remaining <= alignment.saturating_mul(3) {
segment_count = 2;
}
let mut cursor = start_offset;
let mut planned_tasks = Vec::with_capacity(segment_count);
for segment_index in 0..segment_count {
if cursor >= total_length {
break;
}
let remaining_segments = segment_count.saturating_sub(segment_index);
let remaining_bytes = total_length.saturating_sub(cursor);
let span = if remaining_segments <= 1 {
remaining_bytes
} else {
let target = remaining_bytes.div_ceil(lossless_u64_from_usize(remaining_segments));
let aligned_target = target.div_ceil(alignment).saturating_mul(alignment);
let min_tail = lossless_u64_from_usize(remaining_segments.saturating_sub(1));
aligned_target
.min(remaining_bytes.saturating_sub(min_tail).max(1))
.max(1)
};
let end_exclusive = cursor.saturating_add(span).min(total_length);
let mut task = base_task.clone();
task.request.range = Some(RangeSpec {
start: cursor,
end_inclusive: Some(end_exclusive.saturating_sub(1)),
unit: RangeUnit::Bytes,
});
task.resume_state = Some(aria2_rust_pro_protocol::ResumeState {
requested_offset: cursor,
accepted_offset: None,
resumed: cursor > 0,
});
planned_tasks.push(task);
cursor = end_exclusive;
}
planned_tasks
}
+248
View File
@@ -0,0 +1,248 @@
//! CLI entrypoints and runtime glue for the `aria2-rust-pro` binary.
//!
//! This crate keeps the user-facing command surface, config projection, and
//! downloader-backed execution bridge in one place so the integration suite can
//! verify the observable behavior without reaching into lower layers directly.
#![forbid(unsafe_code)]
#![expect(
clippy::multiple_crate_versions,
reason = "workspace dependency resolution is shared across crates and not owned by cli alone"
)]
use std::{
env,
ffi::OsString,
fs,
io::{self, Read as _},
path::{Path, PathBuf},
};
use aria2_rust_pro_compat::{
ConfigDirective, ConfigDocument, ConfigLocationKind, ConfigParseError, ConfigProfile,
ConfigScope, ConfigSource, OptionKind, cli_version_text, compat::compat_ledger,
compatibility_help_text, help::help_sections, help_text, help_text_for_query, option_spec,
parse_config_line_strict,
};
use aria2_rust_pro_core::RuntimeConfig;
use aria2_rust_pro_protocol::{
ChecksumHookModel, ChecksumSpec, Downloader, HeaderKind, HttpHeader, HttpSessionModel,
Protocol, ReqwestHttpConnector, downloader::ConnectorBackedDownloader,
};
use aria2_rust_pro_rpc::{InProcessRpcDispatcher, RpcValue};
/// CLI argument parsing and compatibility rendering helpers.
mod args;
/// HTTP transfer task planning, retry, and persistence helpers.
mod http_runtime;
/// Parallel HTTP bootstrap and segment follow-up execution helpers.
mod parallel_http_runtime;
/// CLI/config projection and typed option conversion helpers.
mod projection;
/// RPC-daemon launch assembly and listener bootstrap helpers.
mod rpc_daemon;
/// Registered transfer execution and runtime dispatch helpers.
mod runtime_execution;
/// Foreground runtime orchestration façade for parsed invocations.
mod runtime_host;
/// Runtime input expansion, resolution, and dispatcher registration helpers.
mod runtime_planning;
/// Terminal runtime summary collection helpers.
mod runtime_summary;
/// Transfer registration, Metalink expansion, and bootstrap payload helpers.
mod transfer_resolution;
/// Per-transfer execution, BT runtime driving, and live completion helpers.
mod transfer_runtime;
/// CLI-facing domain types used across the private split modules.
mod types;
pub use self::{
args::{
compatibility_snapshot, parse_args, parse_cli, render_compatibility_help, render_help,
render_version,
},
projection::{
classify_transfer, command_surface, derive_http_session, derive_runtime_config,
load_config_report, parse_protocol, profile_option_map,
},
types::{
BtStatusReport, CliError, CommandSurface, CompatibilitySnapshot, ConfigLoadReport,
Invocation, ParsedArguments, RpcLaunchConfig, RuntimeMode, RuntimeReport, StartupProfile,
TransferSelection,
},
};
use self::{
args::{
expand_transfer_entries, merged_profile, metalink_entry_implied_profile,
parse_checksum_hook_text, rpc_option_object,
},
http_runtime::{
build_http_transfer_task, build_http_transfer_task_with_target,
build_initial_http_execution_plan, build_segment_transfer_tasks,
execute_http_transfer_with_retry, execute_segment_transfers,
execute_tagged_segment_transfers, http_execution_completed_via_checksum,
persist_http_response_body_to_target, prepare_http_target_path,
},
projection::{
apply_proxy_auth_overrides, derive_rpc_listen_host, derive_rpc_secret,
lossless_u64_from_usize, parse_bool_text, parse_bt_status_report, parse_csv_text,
parse_proxy_text, profile_option_value, rpc_bool, rpc_u64, saturating_u16_from_usize,
saturating_u32_from_usize, saturating_usize_from_u64,
},
transfer_resolution::{
DispatcherRegistrationKind, build_ftp_transfer_parts, build_sftp_transfer_parts,
register_resolved_entry_with_dispatcher, resolve_transfer_entry_with_downloader,
},
transfer_runtime::{
dispatcher_status_for_gid, dispatcher_status_summary_for_gid, execute_bt_runtime_for_gid,
execute_transfer_for_uri, http_response_is_terminal_success, rpc_status_text,
},
types::{CliTransferSource, TransferInputEntry},
};
#[cfg(test)]
use self::http_runtime::planned_segment_span;
/// Executes a parsed invocation and returns a structured runtime report.
///
/// # Errors
///
/// Returns config, I/O, or in-process RPC errors encountered while wiring the
/// runtime execution surface.
pub fn execute_runtime(invocation: Invocation) -> Result<RuntimeReport, CliError> {
let connector = ReqwestHttpConnector::new().map_err(|error| {
CliError::Io(format!("failed to initialize reqwest connector: {error}"))
})?;
let downloader = ConnectorBackedDownloader::new(connector.clone(), connector);
execute_runtime_with_context(
invocation,
&StartupProfile::default(),
None,
None,
&downloader,
)
}
/// Executes a runtime invocation with explicit startup and CLI override context.
fn execute_runtime_with_context<D: Downloader + Sync>(
invocation: Invocation,
startup: &StartupProfile,
cli_profile: Option<&ConfigProfile>,
cli_transfer_sources: Option<&[CliTransferSource]>,
downloader: &D,
) -> Result<RuntimeReport, CliError> {
runtime_host::execute_runtime_with_downloader_impl(
invocation,
startup,
cli_profile,
cli_transfer_sources,
downloader,
)
}
/// Executes a parsed invocation against the supplied downloader.
///
/// # Errors
///
/// Returns config, I/O, protocol, or in-process RPC errors encountered while
/// wiring the runtime execution surface.
///
/// # Panics
///
/// Panics if a scoped HTTP worker thread panics while the runtime is executing
/// parallel transfer work.
pub fn execute_runtime_with_downloader<D: Downloader + Sync>(
invocation: Invocation,
downloader: &D,
) -> Result<RuntimeReport, CliError> {
execute_runtime_with_context(
invocation,
&StartupProfile::default(),
None,
None,
downloader,
)
}
/// Shared runtime execution entrypoint used by public and parsed-command flows.
///
/// # Errors
///
/// Returns config, I/O, protocol, or in-process RPC errors encountered while
/// executing the supplied invocation with explicit startup and CLI override
/// context.
/// Executes a parsed invocation.
///
/// # Errors
///
/// Returns an error when a referenced config file cannot be read or when the
/// config parser rejects the file contents.
pub fn execute(invocation: Invocation) -> Result<(), CliError> {
match invocation.clone() {
Invocation::Version => {
println!("{}", render_version());
Ok(())
}
Invocation::Help { query } => {
println!("{}", render_help(query.as_deref()));
Ok(())
}
Invocation::Run { .. } => {
let report = execute_runtime(invocation)?;
println!(
"accepted {} uri(s); tracked {}; control-file v{}",
report.accepted_uri_count,
report.tracked_download_count,
report.control_file_version_major
);
Ok(())
}
}
}
/// Runs the RPC daemon surface implied by a parsed CLI invocation.
/// Parses process arguments and executes the resulting invocation.
///
/// # Errors
///
/// Returns any argument parsing, config reading, or config parsing error
/// produced by [`parse_cli`] or [`execute`].
pub fn run_from_env() -> Result<(), CliError> {
let parsed = parse_cli(env::args_os())?;
match command_surface(&parsed) {
CommandSurface::PrintVersion => execute(Invocation::Version),
CommandSurface::PrintHelp { query } => execute(Invocation::Help { query }),
CommandSurface::ValidateConfig {
config_path,
strict,
} => {
let _ = load_config_report(&config_path, strict)?;
Ok(())
}
CommandSurface::Foreground(invocation) => {
let connector = ReqwestHttpConnector::new().map_err(|error| {
CliError::Io(format!("failed to initialize reqwest connector: {error}"))
})?;
let downloader = ConnectorBackedDownloader::new(connector.clone(), connector);
let report = execute_runtime_with_context(
invocation,
&parsed.profile,
parsed.cli_profile.as_ref(),
Some(&parsed.cli_transfer_sources),
&downloader,
)?;
println!(
"accepted {} uri(s); tracked {}; control-file v{}",
report.accepted_uri_count,
report.tracked_download_count,
report.control_file_version_major
);
Ok(())
}
CommandSurface::RpcDaemon { .. } => rpc_daemon::run_rpc_daemon(parsed),
}
}
#[cfg(test)]
/// Regression coverage for CLI parsing, config merging, runtime wiring, and live transport smokes.
mod tests;
+21
View File
@@ -0,0 +1,21 @@
//! Binary entrypoint for the `aria2-rust-pro` CLI.
#![forbid(unsafe_code)]
#![doc = "Binary entrypoint for the aria2-rust-pro CLI."]
use aria2_rust_pro_cli::run_from_env;
use aria2_rust_pro_compat as _;
use aria2_rust_pro_core as _;
use aria2_rust_pro_protocol as _;
use aria2_rust_pro_rpc as _;
use aria2_rust_pro_storage as _;
#[cfg(test)]
use ssh2 as _;
/// Runs the CLI process and exits with a non-zero status on failure.
fn main() {
if let Err(error) = run_from_env() {
eprintln!("{error}");
std::process::exit(1);
}
}
@@ -0,0 +1,627 @@
#![doc(hidden)]
#![expect(
clippy::redundant_pub_crate,
clippy::indexing_slicing,
reason = "this private parallel HTTP runtime module keeps batch execution and follow-up coordination together, and the index usage is constrained by precomputed entry partitions"
)]
use aria2_rust_pro_compat::ConfigProfile;
use aria2_rust_pro_core::RuntimeConfig;
use aria2_rust_pro_protocol::{Downloader, HttpTransferTaskModel};
use std::{
collections::VecDeque,
env,
path::PathBuf,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::Instant,
};
use super::{
CliError, InProcessRpcDispatcher, build_http_transfer_task_with_target,
build_initial_http_execution_plan, build_segment_transfer_tasks,
execute_tagged_segment_transfers, http_execution_completed_via_checksum,
http_response_is_terminal_success, persist_http_response_body_to_target,
prepare_http_target_path,
};
use crate::http_runtime::{
HttpTransferExecution, effective_segment_transfer_parallelism,
execute_tagged_segment_transfers_with_parallelism, partition_indexed_work_evenly,
};
use crate::runtime_planning::RegisteredRuntimeEntry;
struct PreparedParallelHttpEntry {
index: usize,
target_path: PathBuf,
task: HttpTransferTaskModel,
bootstrap_execution: HttpTransferExecution,
planned_segments: Vec<HttpTransferTaskModel>,
}
type PlannedSegmentBatchTag = usize;
fn execute_shared_runtime_segment_batches<D: Downloader + Sync>(
downloader: &D,
planned_segment_batches: Vec<(PlannedSegmentBatchTag, HttpTransferTaskModel)>,
runtime: &RuntimeConfig,
active_download_count: usize,
) -> Vec<(
PlannedSegmentBatchTag,
HttpTransferTaskModel,
HttpTransferExecution,
)> {
if planned_segment_batches.len() <= 1 {
return execute_tagged_segment_transfers(downloader, planned_segment_batches, runtime);
}
let queue_slot_count = planned_segment_batches
.iter()
.map(|(tag, _)| *tag)
.max()
.map_or(0, |index| index.saturating_add(1));
let planned_segment_count = planned_segment_batches.len();
let mut per_download_queues = std::iter::repeat_with(VecDeque::new)
.take(queue_slot_count)
.collect::<Vec<_>>();
let mut active_queue_count = 0usize;
for (tag, task) in planned_segment_batches {
if let Some(queue) = per_download_queues.get_mut(tag) {
if queue.is_empty() {
active_queue_count = active_queue_count.saturating_add(1);
}
queue.push_back((tag, task));
}
}
let mut completed = Vec::with_capacity(planned_segment_count);
while active_queue_count > 0 {
let mut batch = Vec::with_capacity(active_queue_count);
for queue in per_download_queues
.iter_mut()
.filter(|queue| !queue.is_empty())
{
let per_download_parallelism =
effective_segment_transfer_parallelism(runtime, queue.len());
for _ in 0..per_download_parallelism {
if let Some(item) = queue.pop_front() {
batch.push(item);
} else {
break;
}
}
if queue.is_empty() {
active_queue_count = active_queue_count.saturating_sub(1);
}
}
if batch.is_empty() {
break;
}
let parallelism = effective_shared_runtime_segment_parallelism(
runtime,
active_download_count,
batch.len(),
);
completed.extend(execute_tagged_segment_transfers_with_parallelism(
downloader,
batch,
runtime,
parallelism,
));
}
completed
}
fn prepare_parallel_http_entry<D: Downloader + Sync>(
downloader: &D,
entry: &RegisteredRuntimeEntry,
base_profile: Option<&ConfigProfile>,
index: usize,
) -> Result<PreparedParallelHttpEntry, CliError> {
let target_path = prepare_http_target_path(
entry.resolved.entry_profile.as_ref().or(base_profile),
&entry.resolved.uri,
)?;
let task = build_http_transfer_task_with_target(
entry.gid.clone(),
entry.resolved.uri.clone(),
&entry.resolved.http_session,
&entry.resolved.runtime,
entry.resolved.entry_profile.as_ref().or(base_profile),
Some(target_path.clone()),
);
let plan = build_initial_http_execution_plan(downloader, &task, &entry.resolved.runtime);
Ok(PreparedParallelHttpEntry {
index,
target_path,
task: plan.task,
bootstrap_execution: plan.execution,
planned_segments: plan.planned_segments,
})
}
fn prepare_parallel_http_entries_static_partitioned<D: Downloader + Sync>(
downloader: &D,
entries: &[RegisteredRuntimeEntry],
base_profile: Option<&ConfigProfile>,
parallel_http_indices: &[usize],
bootstrap_parallelism: usize,
) -> Result<Vec<PreparedParallelHttpEntry>, CliError> {
let task_count = parallel_http_indices.len();
let indexed_chunks = partition_indexed_work_evenly(
parallel_http_indices.iter().copied().enumerate(),
task_count,
bootstrap_parallelism,
);
std::thread::scope(|scope| {
let mut handles = Vec::with_capacity(indexed_chunks.len());
for chunk in indexed_chunks {
handles.push(scope.spawn(move || {
chunk
.into_iter()
.map(|(position, index)| {
(
position,
prepare_parallel_http_entry(
downloader,
&entries[index],
base_profile,
index,
),
)
})
.collect::<Vec<_>>()
}));
}
let mut ordered_results = std::iter::repeat_with(|| None)
.take(task_count)
.collect::<Vec<_>>();
for handle in handles {
for (position, prepared) in handle
.join()
.expect("static parallel http prepare worker should not panic")
{
ordered_results[position] = Some(prepared);
}
}
ordered_results
.into_iter()
.map(|prepared| {
prepared.expect("static parallel http prepare worker should fill every slot")
})
.collect()
})
}
#[expect(
clippy::too_many_arguments,
reason = "shared-runtime bookkeeping needs dispatcher state, timing, retry counters, and queued follow-up batches in one place"
)]
fn process_prepared_parallel_http_entry(
dispatcher: &mut InProcessRpcDispatcher,
entries: &[RegisteredRuntimeEntry],
base_profile: Option<&ConfigProfile>,
timing_probe: bool,
prepared: PreparedParallelHttpEntry,
planned_segment_batches: &mut Vec<(usize, HttpTransferTaskModel)>,
segment_target_paths: &mut [Option<PathBuf>],
cumulative_retry_counts: &mut [u32],
max_connections: &mut [u16],
) -> Result<(), CliError> {
let PreparedParallelHttpEntry {
index,
target_path,
task,
bootstrap_execution,
mut planned_segments,
} = prepared;
max_connections[index] = task.max_connections;
if let Some(ref response) = bootstrap_execution.response {
cumulative_retry_counts[index] =
cumulative_retry_counts[index].saturating_add(bootstrap_execution.retry_count);
let entry = &entries[index];
let persist_started = timing_probe.then(Instant::now);
persist_http_response_body_to_target(&target_path, &task, response)?;
let persist_elapsed_ms = persist_started
.as_ref()
.map(|started| started.elapsed().as_millis())
.unwrap_or_default();
let completed_via_checksum = http_execution_completed_via_checksum(
&bootstrap_execution,
response,
entry.resolved.entry_profile.as_ref().or(base_profile),
&entry.resolved.uri,
&task,
);
let record_started = timing_probe.then(Instant::now);
dispatcher
.record_http_transfer_result(
&entry.gid,
response,
task.max_connections,
cumulative_retry_counts[index],
!bootstrap_execution.checksum_observed || completed_via_checksum,
)
.map_err(|error| CliError::Rpc(error.message))?;
let record_elapsed_ms = record_started
.as_ref()
.map(|started| started.elapsed().as_millis())
.unwrap_or_default();
let _uri_marked_complete = http_response_is_terminal_success(response)
&& (!bootstrap_execution.checksum_observed || completed_via_checksum);
let needs_segment_followups = response.partial_content
&& response
.total_length()
.is_some_and(|total| response.completed_length() < total);
if needs_segment_followups {
if planned_segments.is_empty() {
let group = dispatcher
.prepare_http_download(&entry.gid)
.map_err(|error| CliError::Rpc(error.message))?;
planned_segments = build_segment_transfer_tasks(&task, &group);
}
if timing_probe {
eprintln!(
"parallel http timing uri={} bootstrap_persist_ms={} bootstrap_record_ms={} planned_segments={} status={} completed={} total_length={:?}",
entry.resolved.uri,
persist_elapsed_ms,
record_elapsed_ms,
planned_segments.len(),
response.status,
response.completed_length(),
response.total_length(),
);
}
planned_segment_batches.extend(
planned_segments
.into_iter()
.map(|planned_task| (index, planned_task)),
);
segment_target_paths[index] = Some(target_path);
} else if timing_probe {
eprintln!(
"parallel http timing uri={} bootstrap_persist_ms={} bootstrap_record_ms={} planned_segments=0 status={} completed={} total_length={:?}",
entry.resolved.uri,
persist_elapsed_ms,
record_elapsed_ms,
response.status,
response.completed_length(),
response.total_length(),
);
}
}
Ok(())
}
#[expect(
clippy::too_many_lines,
reason = "parallel HTTP execution intentionally keeps bootstrap, retry, and segment scheduling in one stateful control flow"
)]
pub(super) fn execute_parallel_http_entries<D: Downloader + Sync>(
dispatcher: &mut InProcessRpcDispatcher,
downloader: &D,
entries: &[RegisteredRuntimeEntry],
base_profile: Option<&ConfigProfile>,
derived_runtime: &RuntimeConfig,
parallel_http_indices: &[usize],
) -> Result<(), CliError> {
let timing_probe = env::var_os("ARIA2_RUST_PRO_HTTP_TIMING").is_some();
let bootstrap_parallelism =
effective_http_bootstrap_parallelism(derived_runtime, parallel_http_indices.len());
let mut planned_segment_batches = Vec::new();
let mut segment_target_paths = std::iter::repeat_with(|| None)
.take(entries.len())
.collect::<Vec<_>>();
let mut cumulative_retry_counts = vec![0_u32; entries.len()];
let mut max_connections = vec![0_u16; entries.len()];
if parallel_http_indices.len() <= 1 || bootstrap_parallelism <= 1 {
for index in parallel_http_indices {
let prepared =
prepare_parallel_http_entry(downloader, &entries[*index], base_profile, *index)?;
process_prepared_parallel_http_entry(
dispatcher,
entries,
base_profile,
timing_probe,
prepared,
&mut planned_segment_batches,
&mut segment_target_paths,
&mut cumulative_retry_counts,
&mut max_connections,
)?;
}
} else if parallel_http_indices.len() <= bootstrap_parallelism.saturating_mul(4) {
for prepared in prepare_parallel_http_entries_static_partitioned(
downloader,
entries,
base_profile,
parallel_http_indices,
bootstrap_parallelism,
)? {
process_prepared_parallel_http_entry(
dispatcher,
entries,
base_profile,
timing_probe,
prepared,
&mut planned_segment_batches,
&mut segment_target_paths,
&mut cumulative_retry_counts,
&mut max_connections,
)?;
}
} else {
let work_indices = Arc::new(parallel_http_indices.to_vec());
let next_index = AtomicUsize::new(0);
let prepared_results = std::thread::scope(|scope| {
let mut handles = Vec::with_capacity(bootstrap_parallelism);
for _ in 0..bootstrap_parallelism {
let work_indices = Arc::clone(&work_indices);
let next_index = &next_index;
handles.push(scope.spawn(move || {
let mut local_results = Vec::new();
loop {
let position = next_index.fetch_add(1, Ordering::Relaxed);
if position >= work_indices.len() {
break;
}
let index = work_indices[position];
let prepared = prepare_parallel_http_entry(
downloader,
&entries[index],
base_profile,
index,
);
local_results.push((position, prepared));
}
local_results
}));
}
handles
.into_iter()
.flat_map(|handle| {
handle
.join()
.expect("parallel http prepare worker should not panic")
})
.collect::<Vec<_>>()
});
let mut prepared_results = prepared_results;
prepared_results.sort_by_key(|(position, _)| *position);
for (_, prepared) in prepared_results {
process_prepared_parallel_http_entry(
dispatcher,
entries,
base_profile,
timing_probe,
prepared?,
&mut planned_segment_batches,
&mut segment_target_paths,
&mut cumulative_retry_counts,
&mut max_connections,
)?;
}
}
let mut pending_segment_records = std::iter::repeat_with(|| None)
.take(entries.len())
.collect::<Vec<_>>();
for (index, planned_task, execution) in execute_shared_runtime_segment_batches(
downloader,
planned_segment_batches,
derived_runtime,
parallel_http_indices.len(),
) {
if let Some(ref response) = execution.response {
cumulative_retry_counts[index] =
cumulative_retry_counts[index].saturating_add(execution.retry_count);
let target_path = segment_target_paths[index]
.as_ref()
.expect("segment follow-up target path should be recorded");
let persist_started = timing_probe.then(Instant::now);
persist_http_response_body_to_target(target_path, &planned_task, response)?;
let persist_elapsed_ms = persist_started
.as_ref()
.map(|started| started.elapsed().as_millis())
.unwrap_or_default();
if timing_probe {
let entry = &entries[index];
eprintln!(
"parallel http segment timing uri={} persist_ms={} status={} completed={} total_length={:?}",
entry.resolved.uri,
persist_elapsed_ms,
response.status,
response.completed_length(),
response.total_length(),
);
}
pending_segment_records[index] = Some((planned_task, execution));
}
}
for (index, pending_record) in pending_segment_records.into_iter().enumerate() {
let Some((planned_task, execution)) = pending_record else {
continue;
};
let Some(ref response) = execution.response else {
continue;
};
let entry = &entries[index];
let completed_via_checksum = http_execution_completed_via_checksum(
&execution,
response,
entry.resolved.entry_profile.as_ref().or(base_profile),
&entry.resolved.uri,
&planned_task,
);
let record_started = timing_probe.then(Instant::now);
dispatcher
.record_http_transfer_result(
&entry.gid,
response,
max_connections[index],
cumulative_retry_counts[index],
!execution.checksum_observed || completed_via_checksum,
)
.map_err(|error| CliError::Rpc(error.message))?;
let record_elapsed_ms = record_started
.as_ref()
.map(|started| started.elapsed().as_millis())
.unwrap_or_default();
if timing_probe {
eprintln!(
"parallel http segment record timing uri={} record_ms={} status={} completed={} total_length={:?}",
entry.resolved.uri,
record_elapsed_ms,
response.status,
response.completed_length(),
response.total_length(),
);
}
}
Ok(())
}
fn effective_http_bootstrap_parallelism(runtime: &RuntimeConfig, entry_count: usize) -> usize {
if entry_count <= 1 {
return entry_count;
}
entry_count.min(runtime.max_downloads.max(1)).max(1)
}
fn effective_shared_runtime_segment_parallelism(
runtime: &RuntimeConfig,
entry_count: usize,
batch_len: usize,
) -> usize {
if batch_len <= 1 {
return batch_len;
}
let active_downloads = entry_count.min(runtime.max_downloads.max(1)).max(1);
let connection_budget =
active_downloads.saturating_mul(runtime.effective_max_connections_per_server().max(1));
batch_len
.min(connection_budget.max(runtime.worker_threads.max(1)))
.max(1)
}
#[cfg(test)]
mod tests {
use super::{
effective_shared_runtime_segment_parallelism, execute_shared_runtime_segment_batches,
};
use aria2_rust_pro_core::RuntimeConfig;
use aria2_rust_pro_protocol::{
FixtureHttpDownloader, HttpBody, HttpMethod, HttpRequestHeaders, HttpRequestModel,
HttpResponseHeaders, HttpTransferTaskModel, HttpVersion, ResponseBody, RetryPolicy,
RetryStrategy,
};
use std::collections::HashMap;
fn retry_strategy() -> RetryStrategy {
RetryStrategy {
policy: RetryPolicy {
max_attempts: 1,
initial_backoff_ms: 0,
max_backoff_ms: 0,
retry_on_3xx: false,
retry_on_4xx: false,
retry_on_5xx: false,
retry_on_network_error: false,
retry_on_timeout: false,
},
jitter: None,
max_elapsed_ms: None,
}
}
fn task(url: &str) -> HttpTransferTaskModel {
HttpTransferTaskModel {
task_id: url.to_owned(),
request: HttpRequestModel {
method: HttpMethod::Get,
url: url.to_owned(),
version: HttpVersion::Http11,
headers: HttpRequestHeaders {
headers: Vec::new(),
},
query: HashMap::new(),
range: None,
body: HttpBody::Empty,
retry: retry_strategy(),
auth: None,
proxy: None,
response_sink: None,
},
response_headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Empty,
resume_state: None,
retry_attempts: Vec::new(),
checksum_hook: None,
max_connections: 1,
retry: retry_strategy(),
}
}
#[test]
fn shared_runtime_segment_batches_keep_sparse_entry_indices() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://127.0.0.1/sparse-two", b"two");
downloader.register("http://127.0.0.1/sparse-five", b"five");
let completed = execute_shared_runtime_segment_batches(
&downloader,
vec![
(2, task("http://127.0.0.1/sparse-two")),
(5, task("http://127.0.0.1/sparse-five")),
],
&RuntimeConfig::default(),
2,
);
assert_eq!(completed.len(), 2);
let mut indices = completed
.into_iter()
.map(|(index, _, _)| index)
.collect::<Vec<_>>();
indices.sort_unstable();
assert_eq!(indices, vec![2, 5]);
}
#[test]
fn shared_runtime_segment_parallelism_uses_admitted_download_count() {
let runtime = RuntimeConfig {
worker_threads: 4,
max_active_downloads: 5,
max_downloads: 16,
max_connections_per_server: 4,
max_connection_per_server: 4,
..RuntimeConfig::default()
};
assert_eq!(
effective_shared_runtime_segment_parallelism(&runtime, 6, 24),
24
);
}
}
+708
View File
@@ -0,0 +1,708 @@
#![doc(hidden)]
#![expect(
clippy::redundant_pub_crate,
reason = "this private projection module centralizes CLI/config coercion helpers and reuses the split CLI surface without repeating a long import list"
)]
use super::{
BtStatusReport, CliError, CommandSurface, ConfigDocument, ConfigLoadReport, ConfigLocationKind,
ConfigProfile, ConfigScope, ConfigSource, HeaderKind, HttpHeader, HttpSessionModel, Invocation,
ParsedArguments, Path, PathBuf, Protocol, RpcValue, RuntimeConfig, RuntimeMode, StartupProfile,
TransferSelection, fs,
};
use aria2_rust_pro_compat::{parse_config, parse_config_lenient};
use aria2_rust_pro_protocol::{ProxyConfig, RetryPolicy, RetryStrategy, TlsConfig};
/// Extracts a string-like RPC field from a generic RPC value.
fn rpc_string(value: Option<&RpcValue>) -> Option<String> {
match value {
Some(RpcValue::String(value)) => Some(value.clone()),
Some(RpcValue::Number(value)) => Some(value.to_string()),
Some(RpcValue::Bool(value)) => Some(value.to_string()),
_ => None,
}
}
/// Extracts a boolean-like RPC field from a generic RPC value.
pub(crate) fn rpc_bool(value: Option<&RpcValue>) -> Option<bool> {
match value {
Some(RpcValue::Bool(value)) => Some(*value),
Some(RpcValue::String(value)) => parse_bool_text(value),
Some(RpcValue::Number(value)) => Some(*value != 0),
_ => None,
}
}
/// Extracts an unsigned integer-like RPC field from a generic RPC value.
pub(crate) fn rpc_u64(value: Option<&RpcValue>) -> Option<u64> {
match value {
Some(RpcValue::Number(value)) => (*value).try_into().ok(),
Some(RpcValue::String(value)) => value.parse().ok(),
Some(RpcValue::Bool(value)) => Some(u64::from(*value)),
_ => None,
}
}
/// Returns the length of an RPC array field when the value is an array.
const fn rpc_array_len(value: Option<&RpcValue>) -> Option<usize> {
match value {
Some(RpcValue::Array(values)) => Some(values.len()),
_ => None,
}
}
/// Projects `BitTorrent`-specific tellStatus fields into the CLI report model.
pub(crate) fn parse_bt_status_report(
status: &std::collections::BTreeMap<String, RpcValue>,
) -> Option<BtStatusReport> {
let is_bt = rpc_bool(status.get("isBt"));
let metadata_only = rpc_bool(status.get("metadataOnly"));
let magnet_uri = rpc_string(status.get("magnetUri"));
let announce_list_tier_count = rpc_array_len(status.get("announceList"));
let seeder = rpc_bool(status.get("seeder"));
let num_seeders = rpc_u64(status.get("numSeeders"));
let share_ratio = rpc_string(status.get("shareRatio"));
let share_ratio_progress = rpc_string(status.get("shareRatioProgress"));
let share_ratio_remaining = rpc_string(status.get("shareRatioRemaining"));
let share_time = rpc_u64(status.get("shareTime"));
if is_bt.is_none()
&& metadata_only.is_none()
&& magnet_uri.is_none()
&& announce_list_tier_count.is_none()
&& seeder.is_none()
&& num_seeders.is_none()
&& share_ratio.is_none()
&& share_ratio_progress.is_none()
&& share_ratio_remaining.is_none()
&& share_time.is_none()
{
return None;
}
Some(BtStatusReport {
is_bt,
metadata_only,
magnet_uri,
announce_list_tier_count,
seeder,
num_seeders,
share_ratio,
share_ratio_progress,
share_ratio_remaining,
share_time,
})
}
/// Returns whether an input ends with an ASCII suffix, ignoring case.
fn has_ascii_case_insensitive_suffix(input: &str, suffix: &str) -> bool {
input
.get(input.len().saturating_sub(suffix.len())..)
.is_some_and(|tail| tail.eq_ignore_ascii_case(suffix))
}
#[must_use]
/// Classifies a transfer input by the user-visible download surface it implies.
pub fn classify_transfer(input: &str) -> TransferSelection {
if input
.get(.."magnet:?".len())
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("magnet:?"))
{
TransferSelection::Magnet
} else if has_ascii_case_insensitive_suffix(input, ".torrent") {
TransferSelection::Torrent
} else if has_ascii_case_insensitive_suffix(input, ".meta4")
|| has_ascii_case_insensitive_suffix(input, ".metalink")
{
TransferSelection::Metalink
} else {
TransferSelection::Uri
}
}
#[must_use]
/// Parses a supported transfer protocol from a URI-like input.
pub fn parse_protocol(input: &str) -> Option<Protocol> {
let (scheme, _) = input.split_once(':')?;
if scheme.eq_ignore_ascii_case("http") {
Some(Protocol::Http)
} else if scheme.eq_ignore_ascii_case("https") {
Some(Protocol::Https)
} else if scheme.eq_ignore_ascii_case("ftp") {
Some(Protocol::Ftp)
} else if scheme.eq_ignore_ascii_case("sftp") {
Some(Protocol::Sftp)
} else if scheme.eq_ignore_ascii_case("magnet") {
Some(Protocol::Magnet)
} else if scheme.eq_ignore_ascii_case("file") {
Some(Protocol::File)
} else {
None
}
}
#[must_use]
/// Chooses the execution surface implied by a parsed invocation.
pub fn command_surface(parsed: &ParsedArguments) -> CommandSurface {
match &parsed.invocation {
Invocation::Version => CommandSurface::PrintVersion,
Invocation::Help { query } => CommandSurface::PrintHelp {
query: query.clone(),
},
Invocation::Run { config_path, uris } => {
if parsed.profile.dry_run {
config_path.clone().map_or_else(
|| CommandSurface::Foreground(parsed.invocation.clone()),
|config_path| CommandSurface::ValidateConfig {
config_path,
strict: true,
},
)
} else if parsed.profile.rpc.enabled || parsed.profile.mode != RuntimeMode::Foreground {
CommandSurface::RpcDaemon {
config_path: config_path.clone(),
inputs: uris.clone(),
}
} else {
CommandSurface::Foreground(parsed.invocation.clone())
}
}
}
}
/// Loads a config file and returns a summary report.
///
/// # Errors
///
/// Returns I/O or parse errors while reading the config file.
pub fn load_config_report(path: &Path, strict: bool) -> Result<ConfigLoadReport, CliError> {
let config = fs::read_to_string(path).map_err(|error| CliError::Io(error.to_string()))?;
let directives = if strict {
parse_config(&config).map_err(CliError::Config)?
} else {
parse_config_lenient(&config).map_err(CliError::Config)?
};
let directive_count = directives.len();
Ok(ConfigLoadReport {
path: path.to_path_buf(),
directive_count,
strict,
profile: ConfigProfile {
name: path.to_string_lossy().into_owned(),
source: ConfigSource::UserConfig,
document: ConfigDocument {
directives,
location: ConfigLocationKind::File,
scope: ConfigScope::Mixed,
},
},
})
}
#[must_use]
/// Projects the effective config directives into a simple option map.
pub fn profile_option_map(profile: &ConfigProfile) -> std::collections::BTreeMap<String, String> {
profile
.document
.directives
.iter()
.filter_map(|directive| {
directive
.value
.as_ref()
.map(|value| (directive.name.clone(), value.clone()))
})
.collect()
}
#[must_use]
/// Returns the effective last-wins directive value for one option name.
pub(crate) fn profile_option_value<'a>(
profile: &'a ConfigProfile,
option_name: &str,
) -> Option<&'a str> {
for directive in profile.document.directives.iter().rev() {
if directive.name == option_name {
return directive.value.as_deref();
}
}
None
}
/// Parses aria2-style boolean text accepted by config and RPC surfaces.
pub(crate) fn parse_bool_text(value: &str) -> Option<bool> {
let value = value.trim();
if matches!(value, "1")
|| value.eq_ignore_ascii_case("true")
|| value.eq_ignore_ascii_case("yes")
|| value.eq_ignore_ascii_case("on")
{
Some(true)
} else if matches!(value, "0")
|| value.eq_ignore_ascii_case("false")
|| value.eq_ignore_ascii_case("no")
|| value.eq_ignore_ascii_case("off")
{
Some(false)
} else {
None
}
}
/// Parses a trimmed unsigned integer from text.
pub(crate) fn parse_u64_text(value: &str) -> Option<u64> {
value.trim().parse().ok()
}
/// Parses a trimmed TCP/UDP port from text.
pub(crate) fn parse_u16_text(value: &str) -> Option<u16> {
value.trim().parse().ok()
}
/// Parses an aria2-style size literal such as `4M`.
pub(crate) fn parse_size_text(value: &str) -> Option<u64> {
let trimmed = value.trim();
let digits = trimmed.trim_end_matches(|c: char| c.is_ascii_alphabetic());
let suffix = &trimmed[digits.len()..];
let base: u64 = digits.parse().ok()?;
let multiplier = if suffix.is_empty() {
1
} else if suffix.eq_ignore_ascii_case("k") {
1024
} else if suffix.eq_ignore_ascii_case("m") {
1024 * 1024
} else if suffix.eq_ignore_ascii_case("g") {
1024 * 1024 * 1024
} else {
return None;
};
Some(base.saturating_mul(multiplier))
}
/// Splits a comma-separated option value into trimmed entries.
pub(crate) fn parse_csv_text(value: &str) -> Vec<String> {
value
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned)
.collect()
}
/// Derives the effective RPC listen host from config and startup overrides.
pub(crate) fn derive_rpc_listen_host(
profile: Option<&ConfigProfile>,
startup: &StartupProfile,
) -> String {
let mut listen_host = startup.rpc.listen_host.clone();
if let Some(profile) = profile
&& parse_bool_text(profile_option_value(profile, "rpc-listen-all").unwrap_or_default())
== Some(true)
{
"0.0.0.0".clone_into(&mut listen_host);
}
listen_host
}
/// Derives the effective RPC secret from startup overrides or config.
pub(crate) fn derive_rpc_secret(
profile: Option<&ConfigProfile>,
startup: &StartupProfile,
) -> Option<String> {
if let Some(secret) = startup.rpc.secret.clone() {
return Some(secret);
}
profile
.and_then(|profile| profile_option_value(profile, "rpc-secret"))
.map(ToOwned::to_owned)
}
/// Parses a proxy URL into the protocol-layer proxy model.
pub(crate) fn parse_proxy_text(value: &str, bypass_hosts: Vec<String>) -> Option<ProxyConfig> {
let (scheme, rest) = value.split_once("://").unwrap_or(("http", value));
let (auth_part, host_part) = match rest.rsplit_once('@') {
Some((auth, host)) => (Some(auth), host),
None => (None, rest),
};
let (host, port) = host_part.rsplit_once(':')?;
let (username, password) = match auth_part.and_then(|auth| auth.split_once(':')) {
Some((username, password)) => (Some(username.to_owned()), Some(password.to_owned())),
None => (auth_part.map(str::to_owned), None),
};
Some(ProxyConfig {
scheme: scheme.to_owned(),
host: host.to_owned(),
port: port.parse().ok()?,
username,
password,
bypass_hosts,
no_proxy: false,
})
}
/// Returns the layered proxy-auth override value for a selected proxy family.
pub(crate) fn layered_proxy_auth_override(
profile: &ConfigProfile,
primary_key: &str,
fallback_key: &str,
) -> Option<String> {
profile_option_value(profile, primary_key)
.map(ToOwned::to_owned)
.or_else(|| {
if primary_key == fallback_key {
None
} else {
profile_option_value(profile, fallback_key).map(ToOwned::to_owned)
}
})
}
/// Applies aria2-style proxy auth overrides on top of a parsed proxy endpoint.
pub(crate) fn apply_proxy_auth_overrides(
proxy: &mut ProxyConfig,
profile: &ConfigProfile,
user_key: &str,
password_key: &str,
) {
if let Some(username) = layered_proxy_auth_override(profile, user_key, "all-proxy-user") {
proxy.username = Some(username);
}
if let Some(password) = layered_proxy_auth_override(profile, password_key, "all-proxy-passwd") {
proxy.password = Some(password);
}
}
/// Saturating conversion from `u64` to `usize`.
pub(crate) fn saturating_usize_from_u64(value: u64) -> usize {
usize::try_from(value).unwrap_or(usize::MAX)
}
/// Saturating conversion from `u64` to `u32`.
pub(crate) fn saturating_u32_from_u64(value: u64) -> u32 {
u32::try_from(value).unwrap_or(u32::MAX)
}
/// Saturating conversion from `usize` to `u32`.
pub(crate) fn saturating_u32_from_usize(value: usize) -> u32 {
u32::try_from(value).unwrap_or(u32::MAX)
}
/// Saturating conversion from `usize` to `u16`.
pub(crate) fn saturating_u16_from_usize(value: usize) -> u16 {
u16::try_from(value).unwrap_or(u16::MAX)
}
/// Fallible-in-practice conversion from `usize` to `u64` with saturation fallback.
pub(crate) fn lossless_u64_from_usize(value: usize) -> u64 {
u64::try_from(value).unwrap_or(u64::MAX)
}
/// Derives the core runtime configuration from CLI and config inputs.
#[must_use]
pub fn derive_runtime_config(
profile: Option<&ConfigProfile>,
startup: &StartupProfile,
) -> RuntimeConfig {
let mut runtime = RuntimeConfig::default();
runtime.allow_jsonrpc = startup.rpc.enabled || startup.mode == RuntimeMode::RpcOnly;
runtime.allow_xmlrpc = runtime.allow_jsonrpc;
runtime.rpc_port = startup.rpc.listen_port;
if let Some(profile) = profile {
let option = |name| profile_option_value(profile, name);
if let Some(value) = option("rpc-listen-port").and_then(parse_u16_text) {
runtime.rpc_port = value;
}
if let Some(value) = option("listen-port").and_then(parse_u16_text) {
runtime.listen_port = value;
}
if let Some(value) = option("dht-listen-port").and_then(parse_u16_text) {
runtime.listen_port = value;
}
if let Some(value) = option("max-concurrent-downloads").and_then(parse_u64_text) {
runtime.max_active_downloads = saturating_usize_from_u64(value.max(1));
}
if let Some(value) = option("max-connection-per-server").and_then(parse_u64_text) {
let connection_budget = saturating_usize_from_u64(value);
runtime.max_connections_per_server = connection_budget;
runtime.max_connection_per_server = connection_budget;
}
if let Some(value) = option("max-overall-download-limit").and_then(parse_size_text) {
runtime.max_overall_download_limit = (value > 0).then_some(value);
}
if let Some(value) = option("max-download-limit").and_then(parse_size_text) {
runtime.max_download_limit = (value > 0).then_some(value);
}
if let Some(value) = option("max-overall-upload-limit").and_then(parse_size_text) {
runtime.max_overall_upload_limit = (value > 0).then_some(value);
}
if let Some(value) = option("max-upload-limit").and_then(parse_size_text) {
runtime.max_upload_limit = (value > 0).then_some(value);
}
if let Some(value) = option("split").and_then(parse_u64_text) {
runtime.split = saturating_usize_from_u64(value.max(1));
}
if let Some(value) = option("disk-cache").and_then(parse_size_text) {
runtime.disk_cache_bytes = value;
}
if let Some(value) = option("min-split-size").and_then(parse_size_text) {
runtime.min_split_size = value;
}
if let Some(value) = option("piece-length").and_then(parse_size_text) {
runtime.piece_length = value;
}
if let Some(value) = option("save-session") {
runtime.session_path = Some(PathBuf::from(value));
}
if let Some(value) = option("save-session-interval").and_then(parse_u64_text) {
runtime.save_session_interval_secs = value;
}
if let Some(value) = option("enable-rpc").and_then(parse_bool_text) {
runtime.allow_jsonrpc = value;
runtime.allow_xmlrpc = value;
}
if let Some(value) = option("disable-ipv6").and_then(parse_bool_text) {
runtime.enable_ipv6 = !value;
}
if let Some(value) = option("retry-on-400").and_then(parse_bool_text) {
runtime.retry_on_400 = value;
}
if let Some(value) = option("retry-on-403").and_then(parse_bool_text) {
runtime.retry_on_403 = value;
}
if let Some(value) = option("retry-on-406").and_then(parse_bool_text) {
runtime.retry_on_406 = value;
}
if let Some(value) = option("retry-on-unknown").and_then(parse_bool_text) {
runtime.retry_on_unknown = value;
}
}
runtime
}
/// Derives the HTTP session model from CLI and config inputs.
#[must_use]
#[expect(
clippy::too_many_lines,
reason = "session derivation intentionally keeps option-to-field mapping in one audit-friendly routine"
)]
pub fn derive_http_session(
profile: Option<&ConfigProfile>,
startup: &StartupProfile,
) -> HttpSessionModel {
let mut session = HttpSessionModel {
session_id: "local-http-session".to_owned(),
user_agent: None,
default_headers: Vec::new(),
cookies: Vec::new(),
auth: None,
proxy: None,
tls: Some(TlsConfig {
verify_peer: true,
verify_host: true,
min_version: None,
max_version: None,
ca_file: None,
cert_file: None,
key_file: None,
}),
retry: default_retry_strategy(),
};
let _ = startup;
if let Some(profile) = profile {
let option = |name| profile_option_value(profile, name);
if let Some(value) = option("user-agent") {
session.user_agent = Some(value.to_owned());
}
if let Some(value) = option("header") {
session.default_headers = parse_csv_text(value)
.into_iter()
.filter_map(|header| {
header.split_once(':').map(|(name, value)| HttpHeader {
name: name.trim().to_owned(),
value: value.trim().to_owned(),
kind: HeaderKind::Request,
})
})
.collect();
}
let bypass_hosts = option("no-proxy").map_or_else(Vec::new, parse_csv_text);
if let Some(proxy_text) = option("https-proxy") {
session.proxy = parse_proxy_text(proxy_text, bypass_hosts);
if let Some(proxy) = &mut session.proxy {
apply_proxy_auth_overrides(
proxy,
profile,
"https-proxy-user",
"https-proxy-passwd",
);
}
} else if let Some(proxy_text) = option("http-proxy") {
session.proxy = parse_proxy_text(proxy_text, bypass_hosts);
if let Some(proxy) = &mut session.proxy {
apply_proxy_auth_overrides(proxy, profile, "http-proxy-user", "http-proxy-passwd");
}
} else if let Some(proxy_text) = option("all-proxy") {
session.proxy = parse_proxy_text(proxy_text, bypass_hosts);
if let Some(proxy) = &mut session.proxy {
apply_proxy_auth_overrides(proxy, profile, "all-proxy-user", "all-proxy-passwd");
}
}
if let Some(check) = option("check-certificate").and_then(parse_bool_text)
&& let Some(tls) = &mut session.tls
{
tls.verify_peer = check;
tls.verify_host = check;
}
if let Some(value) = option("ca-certificate")
&& let Some(tls) = &mut session.tls
{
tls.ca_file = Some(value.to_owned());
}
if let Some(value) = option("certificate")
&& let Some(tls) = &mut session.tls
{
tls.cert_file = Some(value.to_owned());
}
if let Some(value) = option("private-key")
&& let Some(tls) = &mut session.tls
{
tls.key_file = Some(value.to_owned());
}
if let Some(value) = option("retry-wait").and_then(parse_u64_text) {
session.retry.policy.initial_backoff_ms = value.saturating_mul(1000);
session.retry.policy.max_backoff_ms = value.saturating_mul(1000);
if value > 0 {
session.retry.policy.retry_on_5xx = true;
session.retry.policy.retry_on_timeout = true;
session.retry.policy.retry_on_network_error = true;
}
}
if let Some(value) = option("max-tries").and_then(parse_u64_text) {
session.retry.policy.max_attempts = saturating_u32_from_u64(value);
if value > 1 {
session.retry.policy.retry_on_5xx = true;
session.retry.policy.retry_on_timeout = true;
session.retry.policy.retry_on_network_error = true;
}
}
if let Some(value) = option("retry-on-400").and_then(parse_bool_text) {
session.retry.policy.retry_on_4xx |= value;
}
if let Some(value) = option("retry-on-403").and_then(parse_bool_text) {
session.retry.policy.retry_on_4xx |= value;
}
if let Some(value) = option("retry-on-406").and_then(parse_bool_text) {
session.retry.policy.retry_on_4xx |= value;
}
if let Some(value) = option("retry-on-unknown").and_then(parse_bool_text) {
session.retry.policy.retry_on_network_error |= value;
}
}
session
}
/// Returns the default retry strategy used for one-shot synthetic transfer tasks.
pub(crate) const fn default_retry_strategy() -> RetryStrategy {
RetryStrategy {
policy: RetryPolicy {
max_attempts: 1,
initial_backoff_ms: 0,
max_backoff_ms: 0,
retry_on_3xx: false,
retry_on_4xx: false,
retry_on_5xx: false,
retry_on_network_error: false,
retry_on_timeout: false,
},
jitter: None,
max_elapsed_ms: None,
}
}
#[cfg(test)]
mod tests {
use super::{load_config_report, profile_option_value};
use crate::CliError;
use aria2_rust_pro_compat::{
ConfigDirective, ConfigDocument, ConfigLocationKind, ConfigProfile, ConfigScope,
ConfigSource,
};
use std::fs;
#[test]
fn load_config_report_strict_rejects_unknown_options() {
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-projection-strict");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(&config_path, "split=4\nunknown-option=yes\n")
.expect("config should be writable");
let error =
load_config_report(&config_path, true).expect_err("strict config load should fail");
assert!(matches!(error, CliError::Config(_)));
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn load_config_report_lenient_preserves_known_directives() {
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-projection-lenient");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
"split=4\nunknown-option=yes\nmin-split-size=1M\n",
)
.expect("config should be writable");
let report =
load_config_report(&config_path, false).expect("lenient config load should work");
assert_eq!(report.directive_count, 3);
assert_eq!(report.profile.document.directives.len(), 3);
let directive_names = report
.profile
.document
.directives
.iter()
.map(|directive| directive.name.as_str())
.collect::<Vec<_>>();
assert_eq!(
directive_names,
["split", "unknown-option", "min-split-size"]
);
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn profile_option_value_uses_last_wins_directive_order() {
let profile = ConfigProfile {
name: "test".to_owned(),
source: ConfigSource::RuntimeOverride,
document: ConfigDocument {
directives: vec![
ConfigDirective {
name: "dir".to_owned(),
value: Some("downloads-a".to_owned()),
},
ConfigDirective {
name: "split".to_owned(),
value: Some("2".to_owned()),
},
ConfigDirective {
name: "dir".to_owned(),
value: Some("downloads-b".to_owned()),
},
],
location: ConfigLocationKind::Inline,
scope: ConfigScope::Mixed,
},
};
assert_eq!(profile_option_value(&profile, "dir"), Some("downloads-b"));
assert_eq!(profile_option_value(&profile, "split"), Some("2"));
assert_eq!(profile_option_value(&profile, "missing"), None);
}
}
+126
View File
@@ -0,0 +1,126 @@
#![doc(hidden)]
#![expect(
clippy::needless_pass_by_value,
clippy::redundant_pub_crate,
reason = "this private daemon-launch module keeps ownership explicit across listener bootstrap helpers"
)]
use std::{
net::{IpAddr, Ipv4Addr, TcpListener},
sync::{Arc, Mutex},
};
use aria2_rust_pro_core::RuntimeConfig;
use aria2_rust_pro_rpc::{
InProcessRpcDispatcher, JsonRpcRequest, RpcMeta, RpcMethod, RpcServerConfig, RpcValue,
serve_rpc_listener,
};
use super::{
CliError, ParsedArguments, TransferInputEntry, derive_rpc_listen_host, derive_rpc_secret,
derive_runtime_config, expand_transfer_entries, load_config_report, merged_profile,
rpc_option_object,
};
use crate::Invocation;
#[derive(Debug)]
struct RpcDaemonLaunch {
runtime: RuntimeConfig,
listen_ip: IpAddr,
secret: Option<String>,
input_entries: Vec<TransferInputEntry>,
}
/// Runs the RPC daemon surface implied by a parsed CLI invocation.
pub(super) fn run_rpc_daemon(parsed: ParsedArguments) -> Result<(), CliError> {
let (config_path, inputs) = match &parsed.invocation {
Invocation::Run { config_path, uris } => (config_path.clone(), uris.clone()),
Invocation::Version | Invocation::Help { .. } => {
return Err(CliError::Io(
"rpc daemon launch requires a run invocation".to_owned(),
));
}
};
let launch = build_rpc_daemon_launch(&parsed, config_path, inputs)?;
let listener = bind_rpc_listener(launch.listen_ip, launch.runtime.rpc_port)?;
let mut dispatcher = InProcessRpcDispatcher::with_runtime(launch.runtime);
seed_dispatcher_with_inputs(&mut dispatcher, launch.input_entries);
let server_config = build_rpc_server_config(&listener, launch.secret)?;
println!(
"rpc daemon listening on {}:{}",
launch.listen_ip,
server_config.listen_addr.port()
);
serve_rpc_listener(listener, server_config, Arc::new(Mutex::new(dispatcher)))
.map_err(|error| CliError::Io(format!("rpc daemon serve failed: {error}")))
}
fn build_rpc_daemon_launch(
parsed: &ParsedArguments,
config_path: Option<std::path::PathBuf>,
inputs: Vec<String>,
) -> Result<RpcDaemonLaunch, CliError> {
let config_report = config_path
.as_ref()
.map(|path| load_config_report(path, true))
.transpose()?;
let file_profile = config_report.as_ref().map(|report| &report.profile);
let effective_profile = merged_profile(file_profile, parsed.cli_profile.as_ref());
let profile = effective_profile.as_ref();
let runtime = derive_runtime_config(profile, &parsed.profile);
let listen_host = derive_rpc_listen_host(profile, &parsed.profile);
let secret = derive_rpc_secret(profile, &parsed.profile);
let listen_ip = listen_host
.parse::<IpAddr>()
.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
let input_entries =
expand_transfer_entries(&inputs, profile, Some(&parsed.cli_transfer_sources))?;
Ok(RpcDaemonLaunch {
runtime,
listen_ip,
secret,
input_entries,
})
}
fn bind_rpc_listener(listen_ip: IpAddr, listen_port: u16) -> Result<TcpListener, CliError> {
TcpListener::bind((listen_ip, listen_port))
.map_err(|error| CliError::Io(format!("rpc daemon bind failed: {error}")))
}
fn build_rpc_server_config(
listener: &TcpListener,
secret: Option<String>,
) -> Result<RpcServerConfig, CliError> {
Ok(RpcServerConfig {
listen_addr: listener
.local_addr()
.map_err(|error| CliError::Io(format!("rpc daemon local addr failed: {error}")))?,
secret_token: secret,
..RpcServerConfig::default()
})
}
fn seed_dispatcher_with_inputs(
dispatcher: &mut InProcessRpcDispatcher,
input_entries: Vec<TransferInputEntry>,
) {
for entry in input_entries {
let mut params = vec![RpcValue::Array(
entry.uris.into_iter().map(RpcValue::String).collect(),
)];
if let Some(options) = rpc_option_object(entry.profile.as_ref()) {
params.push(options);
}
let _ = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params,
meta: RpcMeta::default(),
});
}
}
@@ -0,0 +1,88 @@
#![doc(hidden)]
#![expect(
clippy::redundant_pub_crate,
reason = "this private runtime-execution module exposes parent-only helpers across the split CLI runtime facade"
)]
use aria2_rust_pro_compat::ConfigProfile;
use aria2_rust_pro_core::RuntimeConfig;
use aria2_rust_pro_protocol::{Downloader, Protocol};
use super::{
CliError, DispatcherRegistrationKind, InProcessRpcDispatcher, TransferSelection,
classify_transfer, execute_bt_runtime_for_gid, execute_transfer_for_uri, parse_protocol,
};
use crate::{
parallel_http_runtime::execute_parallel_http_entries, runtime_planning::RegisteredRuntimeEntry,
};
pub(super) use crate::runtime_summary::collect_runtime_execution_summary;
pub(super) fn execute_registered_transfers<D: Downloader + Sync>(
dispatcher: &mut InProcessRpcDispatcher,
downloader: &D,
entries: &[RegisteredRuntimeEntry],
base_profile: Option<&ConfigProfile>,
derived_runtime: &RuntimeConfig,
) -> Result<(), CliError> {
let (mut serial_indices, parallel_http_indices) = partition_runtime_entries(entries);
if parallel_http_indices.len() > 1 {
execute_parallel_http_entries(
dispatcher,
downloader,
entries,
base_profile,
derived_runtime,
&parallel_http_indices,
)?;
} else {
serial_indices.extend(parallel_http_indices);
}
for index in serial_indices {
let entry = entries
.get(index)
.expect("serial runtime entry index must come from partition_runtime_entries");
match classify_transfer(&entry.resolved.uri) {
TransferSelection::Magnet | TransferSelection::Torrent => {
execute_bt_runtime_for_gid(dispatcher, &entry.gid)?;
}
_ => match entry.registration_kind {
DispatcherRegistrationKind::Uri => {
execute_transfer_for_uri(
dispatcher,
downloader,
&entry.resolved.uri,
&entry.gid,
entry.resolved.entry_profile.as_ref().or(base_profile),
&entry.resolved.http_session,
&entry.resolved.runtime,
)?;
}
DispatcherRegistrationKind::Torrent => {
execute_bt_runtime_for_gid(dispatcher, &entry.gid)?;
}
},
}
}
Ok(())
}
fn partition_runtime_entries(entries: &[RegisteredRuntimeEntry]) -> (Vec<usize>, Vec<usize>) {
let mut serial_indices = Vec::new();
let mut parallel_http_indices = Vec::new();
for (index, entry) in entries.iter().enumerate() {
if entry.registration_kind == DispatcherRegistrationKind::Uri
&& classify_transfer(&entry.resolved.uri) == TransferSelection::Uri
&& matches!(
parse_protocol(&entry.resolved.uri),
Some(Protocol::Http | Protocol::Https)
)
{
parallel_http_indices.push(index);
} else {
serial_indices.push(index);
}
}
(serial_indices, parallel_http_indices)
}
@@ -0,0 +1,113 @@
#![doc(hidden)]
#![expect(
clippy::redundant_pub_crate,
reason = "this private runtime host module is an internal orchestration façade for the CLI crate split"
)]
use std::path::PathBuf;
use aria2_rust_pro_compat::ConfigProfile;
use aria2_rust_pro_core::RuntimeConfig;
use aria2_rust_pro_protocol::Downloader;
use aria2_rust_pro_storage::ControlFileVersion;
use super::{
CliError, CliTransferSource, Invocation, RuntimeReport, StartupProfile, derive_http_session,
};
use crate::{
runtime_execution::{collect_runtime_execution_summary, execute_registered_transfers},
runtime_planning::{build_runtime_input_context, register_runtime_entries},
};
pub(super) fn execute_runtime_with_downloader_impl<D: Downloader + Sync>(
invocation: Invocation,
startup: &StartupProfile,
cli_profile: Option<&ConfigProfile>,
cli_transfer_sources: Option<&[CliTransferSource]>,
downloader: &D,
) -> Result<RuntimeReport, CliError> {
match invocation {
Invocation::Version | Invocation::Help { .. } => Ok(empty_runtime_report()),
Invocation::Run { config_path, uris } => execute_run_invocation(
config_path,
uris,
startup,
cli_profile,
cli_transfer_sources,
downloader,
),
}
}
fn empty_runtime_report() -> RuntimeReport {
RuntimeReport {
accepted_uri_count: 0,
tracked_download_count: 0,
completed_download_count: 0,
first_gid: None,
first_status: None,
first_total_length: None,
first_completed_length: None,
first_connections: None,
recognized_schemes: Vec::new(),
transfer_kinds: Vec::new(),
config_report: None,
derived_runtime: RuntimeConfig::default(),
http_session: derive_http_session(None, &StartupProfile::default()),
control_file_version_major: ControlFileVersion::CURRENT.major(),
first_bt_status: None,
}
}
fn execute_run_invocation<D: Downloader + Sync>(
config_path: Option<PathBuf>,
uris: Vec<String>,
startup: &StartupProfile,
cli_profile: Option<&ConfigProfile>,
cli_transfer_sources: Option<&[CliTransferSource]>,
downloader: &D,
) -> Result<RuntimeReport, CliError> {
let context = build_runtime_input_context(
config_path,
uris,
startup,
cli_profile,
cli_transfer_sources,
downloader,
)?;
let base_profile = context.effective_profile.as_ref();
let mut dispatcher =
super::InProcessRpcDispatcher::with_runtime(context.derived_runtime.clone());
let registered_entries = register_runtime_entries(
&mut dispatcher,
downloader,
context.resolved_entries,
base_profile,
)?;
execute_registered_transfers(
&mut dispatcher,
downloader,
&registered_entries,
base_profile,
&context.derived_runtime,
)?;
let summary = collect_runtime_execution_summary(&mut dispatcher, &registered_entries)?;
Ok(RuntimeReport {
accepted_uri_count: registered_entries.len(),
tracked_download_count: summary.tracked_download_count,
completed_download_count: summary.completed_download_count,
first_gid: summary.first_gid,
first_status: summary.first_status,
first_total_length: summary.first_total_length,
first_completed_length: summary.first_completed_length,
first_connections: summary.first_connections,
recognized_schemes: context.recognized_schemes,
transfer_kinds: context.transfer_kinds,
config_report: context.config_report,
derived_runtime: context.derived_runtime,
http_session: context.http_session,
control_file_version_major: ControlFileVersion::CURRENT.major(),
first_bt_status: summary.first_bt_status,
})
}
@@ -0,0 +1,222 @@
#![doc(hidden)]
#![expect(
clippy::needless_pass_by_value,
clippy::redundant_pub_crate,
reason = "this private runtime-planning module shares parent-only planning structs and helpers across the split CLI runtime"
)]
use std::path::PathBuf;
use aria2_rust_pro_compat::{ConfigParseError, ConfigProfile};
use aria2_rust_pro_core::RuntimeConfig;
use aria2_rust_pro_protocol::{Downloader, HttpSessionModel, Protocol};
use super::{
CliError, CliTransferSource, ConfigLoadReport, DispatcherRegistrationKind,
InProcessRpcDispatcher, StartupProfile, TransferInputEntry, TransferSelection,
classify_transfer, derive_http_session, derive_runtime_config, expand_transfer_entries,
load_config_report, merged_profile, parse_protocol, register_resolved_entry_with_dispatcher,
resolve_transfer_entry_with_downloader,
};
#[derive(Clone, Debug)]
pub(super) struct RuntimeInputContext {
pub(super) config_report: Option<ConfigLoadReport>,
pub(super) effective_profile: Option<ConfigProfile>,
pub(super) recognized_schemes: Vec<String>,
pub(super) transfer_kinds: Vec<TransferSelection>,
pub(super) derived_runtime: RuntimeConfig,
pub(super) http_session: HttpSessionModel,
pub(super) resolved_entries: Vec<ResolvedRuntimeEntry>,
}
#[derive(Clone, Debug)]
pub(super) struct ResolvedRuntimeEntry {
pub(super) entry: TransferInputEntry,
pub(super) entry_profile: Option<ConfigProfile>,
pub(super) runtime: RuntimeConfig,
pub(super) http_session: HttpSessionModel,
pub(super) uri: String,
}
#[derive(Clone, Debug)]
pub(super) struct RegisteredRuntimeEntry {
pub(super) resolved: ResolvedRuntimeEntry,
pub(super) gid: String,
pub(super) registration_kind: DispatcherRegistrationKind,
}
#[derive(Clone, Debug)]
struct DerivedEntryContext {
runtime: RuntimeConfig,
http_session: HttpSessionModel,
}
pub(super) fn build_runtime_input_context<D: Downloader + Sync>(
config_path: Option<PathBuf>,
uris: Vec<String>,
startup: &StartupProfile,
cli_profile: Option<&ConfigProfile>,
cli_transfer_sources: Option<&[CliTransferSource]>,
downloader: &D,
) -> Result<RuntimeInputContext, CliError> {
let config_report = config_path
.as_ref()
.map(|path| load_config_report(path, true))
.transpose()?;
let file_profile = config_report.as_ref().map(|report| &report.profile);
let effective_profile = merged_profile(file_profile, cli_profile);
let profile = effective_profile.as_ref();
let input_entries = expand_transfer_entries(&uris, profile, cli_transfer_sources)?;
let first_entry_profile = input_entries.first().and_then(|entry| {
let implied = merged_profile(entry.implied_profile.as_ref(), profile);
merged_profile(implied.as_ref(), entry.profile.as_ref())
});
let report_profile = first_entry_profile.as_ref().or(profile);
let derived_runtime = derive_runtime_config(report_profile, startup);
let http_session = derive_http_session(report_profile, startup);
let recognized_schemes = input_entries
.iter()
.filter_map(|entry| entry.uris.first())
.filter_map(|uri| parse_protocol(uri).map(Protocol::as_str))
.map(str::to_owned)
.collect::<Vec<_>>();
let transfer_kinds = input_entries
.iter()
.filter_map(|entry| entry.uris.first())
.map(String::as_str)
.map(classify_transfer)
.collect::<Vec<_>>();
let resolved_entries = resolve_runtime_entries(&input_entries, startup, profile, downloader)?;
Ok(RuntimeInputContext {
config_report,
effective_profile,
recognized_schemes,
transfer_kinds,
derived_runtime,
http_session,
resolved_entries,
})
}
pub(super) fn register_runtime_entries<D: Downloader + Sync>(
dispatcher: &mut InProcessRpcDispatcher,
downloader: &D,
entries: Vec<ResolvedRuntimeEntry>,
base_profile: Option<&ConfigProfile>,
) -> Result<Vec<RegisteredRuntimeEntry>, CliError> {
let mut registered_entries = Vec::with_capacity(entries.len());
for resolved in entries {
let (gid, registration_kind) = register_resolved_entry_with_dispatcher(
dispatcher,
downloader,
&resolved.entry,
&resolved.uri,
resolved.entry_profile.as_ref().or(base_profile),
&resolved.http_session,
&resolved.runtime,
)?;
registered_entries.push(RegisteredRuntimeEntry {
resolved,
gid,
registration_kind,
});
}
Ok(registered_entries)
}
fn resolve_runtime_entries<D: Downloader + Sync>(
input_entries: &[TransferInputEntry],
startup: &StartupProfile,
profile: Option<&ConfigProfile>,
downloader: &D,
) -> Result<Vec<ResolvedRuntimeEntry>, CliError> {
let mut resolved_entries = Vec::new();
let mut derived_context_cache = Vec::new();
for entry in input_entries {
let direct_passthrough = entry
.uris
.first()
.is_some_and(|uri| classify_transfer(uri) != TransferSelection::Metalink);
let pre_resolve_profile = merged_profile(
merged_profile(entry.implied_profile.as_ref(), profile).as_ref(),
entry.profile.as_ref(),
);
let pre_resolve_context = derived_entry_context(
&mut derived_context_cache,
pre_resolve_profile.as_ref(),
startup,
);
let expanded_entries = resolve_transfer_entry_with_downloader(
entry,
downloader,
&pre_resolve_context.http_session,
&pre_resolve_context.runtime,
)?;
if direct_passthrough && expanded_entries.len() == 1 {
let resolved_uri = entry.uris.first().cloned().ok_or_else(|| {
CliError::Config(ConfigParseError::InvalidDirective(
"input-file entry missing URI".to_owned(),
))
})?;
let expanded_entry = expanded_entries
.into_iter()
.next()
.expect("direct entry fast path should preserve one resolved entry");
resolved_entries.push(ResolvedRuntimeEntry {
entry: expanded_entry,
entry_profile: pre_resolve_profile,
runtime: pre_resolve_context.runtime,
http_session: pre_resolve_context.http_session,
uri: resolved_uri,
});
continue;
}
for expanded_entry in expanded_entries {
let entry_profile = merged_profile(
merged_profile(expanded_entry.implied_profile.as_ref(), profile).as_ref(),
expanded_entry.profile.as_ref(),
);
let entry_context =
derived_entry_context(&mut derived_context_cache, entry_profile.as_ref(), startup);
let resolved_uri = expanded_entry.uris.first().cloned().ok_or_else(|| {
CliError::Config(ConfigParseError::InvalidDirective(
"input-file entry missing URI".to_owned(),
))
})?;
resolved_entries.push(ResolvedRuntimeEntry {
entry: expanded_entry,
entry_profile,
runtime: entry_context.runtime,
http_session: entry_context.http_session,
uri: resolved_uri,
});
}
}
Ok(resolved_entries)
}
fn derived_entry_context(
cache: &mut Vec<(Option<ConfigProfile>, DerivedEntryContext)>,
profile: Option<&ConfigProfile>,
startup: &StartupProfile,
) -> DerivedEntryContext {
if let Some((_, cached)) = cache
.iter()
.find(|(cached_profile, _)| cached_profile.as_ref() == profile)
{
return cached.clone();
}
let derived = DerivedEntryContext {
runtime: derive_runtime_config(profile, startup),
http_session: derive_http_session(profile, startup),
};
cache.push((profile.cloned(), derived.clone()));
derived
}
@@ -0,0 +1,54 @@
#![doc(hidden)]
#![expect(
clippy::redundant_pub_crate,
reason = "this private runtime summary helper only feeds the CLI runtime host and does not define public-facing API contracts"
)]
use super::{
BtStatusReport, CliError, InProcessRpcDispatcher, dispatcher_status_for_gid,
dispatcher_status_summary_for_gid, parse_bt_status_report, rpc_status_text,
};
use crate::runtime_planning::RegisteredRuntimeEntry;
#[derive(Clone, Debug, Default)]
pub(super) struct RuntimeExecutionSummary {
pub(super) first_gid: Option<String>,
pub(super) tracked_download_count: usize,
pub(super) completed_download_count: usize,
pub(super) first_status: Option<String>,
pub(super) first_total_length: Option<u64>,
pub(super) first_completed_length: Option<u64>,
pub(super) first_connections: Option<u32>,
pub(super) first_bt_status: Option<BtStatusReport>,
}
pub(super) fn collect_runtime_execution_summary(
dispatcher: &mut InProcessRpcDispatcher,
entries: &[RegisteredRuntimeEntry],
) -> Result<RuntimeExecutionSummary, CliError> {
let mut summary = RuntimeExecutionSummary {
first_gid: entries.first().map(|entry| entry.gid.clone()),
tracked_download_count: dispatcher.tracked_download_count(),
..RuntimeExecutionSummary::default()
};
for (index, entry) in entries.iter().enumerate() {
let status = dispatcher_status_summary_for_gid(dispatcher, &entry.gid)?;
let status_value = rpc_status_text(status.status);
if status.status.as_rpc_status() == "complete" {
summary.completed_download_count = summary.completed_download_count.saturating_add(1);
}
if index == 0 {
summary.first_total_length = Some(status.total_length);
summary.first_completed_length = Some(status.completed_length);
summary.first_connections = Some(status.connections);
summary.first_status = Some(status_value);
if status.is_bt {
let full_status = dispatcher_status_for_gid(dispatcher, &entry.gid)?;
summary.first_bt_status = parse_bt_status_report(&full_status);
}
}
}
Ok(summary)
}
+930
View File
@@ -0,0 +1,930 @@
use std::{
collections::VecDeque,
ffi::OsString,
fs,
io::{BufRead, BufReader, Read, Write},
net::{TcpListener, TcpStream},
process::{Child, Command, ExitStatus, Stdio},
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
},
thread,
time::{Duration, Instant},
};
use aria2_rust_pro_core::RuntimeConfig;
use aria2_rust_pro_protocol::{
Downloader, FixtureHttpDownloader, FtpConfigModel, FtpRequestModel, FtpResponseModel,
HttpResponseHeaders, HttpResponseModel, HttpVersion, Protocol, ReqwestHttpConnector,
ResponseBody, SftpConfigModel, SftpRequestModel, SftpResponseModel,
transport::{TransportError, TransportErrorKind},
};
use aria2_rust_pro_rpc::{InProcessRpcDispatcher, JsonRpcRequest, RpcMethod, RpcValue};
use super::http_runtime::partition_indexed_work_evenly;
use super::{
CliError, CliTransferSource, CommandSurface, Invocation, RuntimeMode, StartupProfile,
TransferSelection, build_ftp_transfer_parts, build_http_transfer_task, classify_transfer,
command_surface, derive_http_session, derive_runtime_config, execute,
execute_http_transfer_with_retry, execute_runtime, execute_runtime_with_context,
execute_runtime_with_downloader, execute_segment_transfers, load_config_report, merged_profile,
parse_args, parse_cli, parse_protocol, planned_segment_span, profile_option_map, render_help,
render_version,
};
fn percent_encode_uri_component(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
if matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~') {
encoded.push(char::from(byte));
} else {
encoded.push('%');
std::fmt::Write::write_fmt(&mut encoded, format_args!("{byte:02X}"))
.expect("writing percent-encoded byte into string must succeed");
}
}
encoded
}
fn start_live_bt_tracker_fixture() -> (String, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("local listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let handle = thread::spawn(move || {
for _ in 0..2 {
let (mut stream, _) = listener.accept().expect("tracker client should connect");
let mut request = [0_u8; 2048];
let read = stream.read(&mut request).expect("request should read");
let request_slice = request
.get(..read)
.expect("read length must stay within request buffer");
let request_text = String::from_utf8_lossy(request_slice);
let (payload, path) = if request_text.starts_with("GET /announce?") {
(
b"d8:intervali600e10:tracker id12:cli-live-0015:peers6:\x7f\x00\x00\x01\x1a\xe1e"
.to_vec(),
"/announce",
)
} else {
(
b"d8:completei4e10:downloadedi9e10:incompletei2ee".to_vec(),
"/scrape",
)
};
assert!(request_text.contains(path));
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\n\r\n",
payload.len()
);
stream
.write_all(response.as_bytes())
.expect("headers should write");
stream.write_all(&payload).expect("payload should write");
}
});
(format!("http://{addr}/announce"), handle)
}
fn build_single_file_torrent_bytes(
announce_url: &str,
file_name: &str,
total_length: u64,
piece_length: u64,
) -> Vec<u8> {
fn bencode_bytes(value: &[u8]) -> Vec<u8> {
let mut encoded = format!("{}:", value.len()).into_bytes();
encoded.extend_from_slice(value);
encoded
}
fn bencode_int(value: u64) -> Vec<u8> {
format!("i{value}e").into_bytes()
}
let mut torrent = Vec::new();
torrent.extend_from_slice(b"d8:announce");
torrent.extend_from_slice(&bencode_bytes(announce_url.as_bytes()));
torrent.extend_from_slice(b"4:infod6:length");
torrent.extend_from_slice(&bencode_int(total_length));
torrent.extend_from_slice(b"4:name");
torrent.extend_from_slice(&bencode_bytes(file_name.as_bytes()));
torrent.extend_from_slice(b"12:piece length");
torrent.extend_from_slice(&bencode_int(piece_length));
torrent.extend_from_slice(b"6:pieces20:");
torrent.extend_from_slice(&[0_u8; 20]);
torrent.extend_from_slice(b"ee");
torrent
}
#[test]
fn execute_runtime_with_local_metalink_fixture_expands_multiple_files_and_uses_implied_output_names()
{
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://example.com/alpha.bin", b"abc");
downloader.register("http://example.com/beta.bin", b"hello");
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-metalink-multifile-test");
let _ = fs::create_dir_all(&temp_dir);
let download_dir = temp_dir.join("downloads");
let metalink_path = temp_dir.join("fixture.meta4");
let config_path = temp_dir.join("aria2.conf");
fs::write(
&metalink_path,
r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0">
<file name="alpha.bin">
<hash type="md5">900150983cd24fb0d6963f7d28e17f72</hash>
<url priority="1">http://example.com/alpha.bin</url>
</file>
<file name="beta.bin">
<url priority="1">http://example.com/beta.bin</url>
</file>
</metalink>"#,
)
.expect("metalink file should write");
fs::write(&config_path, format!("dir={}\n", download_dir.display()))
.expect("config should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path),
uris: vec![metalink_path.to_string_lossy().into_owned()],
},
&downloader,
)
.expect("runtime should execute with expanded metalink fixture");
assert_eq!(report.accepted_uri_count, 2);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(3));
assert_eq!(report.first_completed_length, Some(3));
assert_eq!(report.transfer_kinds, vec![TransferSelection::Metalink]);
assert_eq!(report.completed_download_count, 2);
assert_eq!(
fs::read(download_dir.join("alpha.bin")).expect("alpha output should exist"),
b"abc"
);
assert_eq!(
fs::read(download_dir.join("beta.bin")).expect("beta output should exist"),
b"hello"
);
let _ = fs::remove_dir_all(temp_dir);
}
#[derive(Clone, Debug)]
struct SequencedHttpDownloader {
responses: Arc<Mutex<Vec<Result<HttpResponseModel, TransportError>>>>,
requests: Arc<Mutex<Vec<aria2_rust_pro_protocol::HttpTransferTaskModel>>>,
}
struct LocalFtpTestServer {
control_port: u16,
join: Option<thread::JoinHandle<()>>,
}
impl LocalFtpTestServer {
fn spawn(payload: Vec<u8>) -> Self {
let control_listener =
TcpListener::bind("127.0.0.1:0").expect("control listener should bind");
let control_port = control_listener
.local_addr()
.expect("control addr should exist")
.port();
let data_listener = TcpListener::bind("127.0.0.1:0").expect("data listener should bind");
let data_addr = data_listener.local_addr().expect("data addr should exist");
let join = thread::spawn(move || {
let (mut control_stream, _) = control_listener
.accept()
.expect("control connection should arrive");
control_stream
.write_all(b"220 local ftp ready\r\n")
.expect("welcome should write");
let mut control_reader =
BufReader::new(control_stream.try_clone().expect("clone should work"));
loop {
let mut line = String::new();
let read = control_reader
.read_line(&mut line)
.expect("control line should read");
if read == 0 {
break;
}
if line.starts_with("USER ") {
control_stream
.write_all(b"331 password required\r\n")
.expect("USER response should write");
} else if line.starts_with("PASS ") {
control_stream
.write_all(b"230 login ok\r\n")
.expect("PASS response should write");
} else if line.starts_with("TYPE ") {
control_stream
.write_all(b"200 type ok\r\n")
.expect("TYPE response should write");
} else if line.starts_with("PASV") {
let port_hi = data_addr.port().div_euclid(256);
let port_lo = data_addr.port().rem_euclid(256);
let response =
format!("227 Entering Passive Mode (127,0,0,1,{port_hi},{port_lo})\r\n");
control_stream
.write_all(response.as_bytes())
.expect("PASV response should write");
} else if line.starts_with("RETR ") {
control_stream
.write_all(b"150 opening data\r\n")
.expect("RETR prelim response should write");
let (mut data_stream, _) = data_listener
.accept()
.expect("data connection should arrive");
data_stream
.write_all(&payload)
.expect("payload should write");
drop(data_stream);
control_stream
.write_all(b"226 transfer complete\r\n")
.expect("RETR completion should write");
} else if line.starts_with("QUIT") {
control_stream
.write_all(b"221 bye\r\n")
.expect("QUIT response should write");
break;
} else {
control_stream
.write_all(b"500 unsupported\r\n")
.expect("fallback response should write");
}
}
});
Self {
control_port,
join: Some(join),
}
}
fn control_port(&self) -> u16 {
self.control_port
}
fn join(mut self) {
if let Some(join) = self.join.take() {
join.join().expect("ftp server thread should join");
}
}
}
struct LiveFtpSmokeDownloader;
struct LiveSftpSmokeDownloader;
#[derive(Debug)]
struct LocalSftpDockerServer {
container_name: String,
port: u16,
payload_len: usize,
}
impl LocalSftpDockerServer {
const DOCKER_EXEC_TIMEOUT: Duration = Duration::from_secs(5);
const DOCKER_RM_TIMEOUT: Duration = Duration::from_secs(5);
const DOCKER_RUN_TIMEOUT: Duration = Duration::from_secs(30);
fn spawn() -> Option<Self> {
let docker = Command::new("docker")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.ok()?;
if !docker.success() {
return None;
}
let port = Self::pick_port();
let container_name = format!("aria2-rust-pro-sftp-smoke-{port}");
let payload = "hello-from-live-sftp\n";
let payload_len = payload.len();
let port_binding = format!("{port}:22");
let run = Self::docker_status_with_timeout(
&[
"run",
"--rm",
"-d",
"--name",
&container_name,
"-p",
&port_binding,
"atmoz/sftp:debian",
"foo:pass:1001::upload",
],
Self::DOCKER_RUN_TIMEOUT,
)?;
if !run.success() {
return None;
}
for _ in 0..20 {
if Self::docker_status_with_timeout(
&[
"exec",
&container_name,
"sh",
"-lc",
"echo 'hello-from-live-sftp' > /home/foo/upload/hello.txt",
],
Self::DOCKER_EXEC_TIMEOUT,
)
.is_some_and(|status| status.success())
{
if Self::wait_until_ready(port) {
return Some(Self {
container_name,
port,
payload_len,
});
}
break;
}
thread::sleep(Duration::from_millis(250));
}
let _ = Self::docker_status_with_timeout(
&["rm", "-f", &container_name],
Self::DOCKER_RM_TIMEOUT,
);
None
}
fn pick_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.expect("test should pick local port")
.local_addr()
.expect("local addr should exist")
.port()
}
fn host_port(&self) -> u16 {
self.port
}
fn payload_len(&self) -> usize {
self.payload_len
}
fn wait_until_ready(port: u16) -> bool {
for _ in 0..40 {
if let Ok(stream) = TcpStream::connect(("127.0.0.1", port)) {
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(2)));
if let Ok(mut session) = ssh2::Session::new() {
session.set_tcp_stream(stream);
if session.handshake().is_ok()
&& session.userauth_password("foo", "pass").is_ok()
{
return true;
}
}
}
thread::sleep(Duration::from_millis(250));
}
false
}
fn docker_status_with_timeout(args: &[&str], timeout: Duration) -> Option<ExitStatus> {
let mut child = Command::new("docker")
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()?;
Self::wait_child_with_timeout(&mut child, timeout)
}
fn wait_child_with_timeout(child: &mut Child, timeout: Duration) -> Option<ExitStatus> {
let started_at = Instant::now();
loop {
if let Some(status) = child.try_wait().ok()? {
return Some(status);
}
if started_at.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
return None;
}
thread::sleep(Duration::from_millis(100));
}
}
}
impl Drop for LocalSftpDockerServer {
fn drop(&mut self) {
let _ = Self::docker_status_with_timeout(
&["rm", "-f", &self.container_name],
Self::DOCKER_RM_TIMEOUT,
);
}
}
impl LiveFtpSmokeDownloader {
#[expect(
clippy::result_large_err,
reason = "test-only live FTP smoke helpers bubble full transport context for assertions"
)]
fn read_response_line(reader: &mut BufReader<TcpStream>) -> Result<String, TransportError> {
let mut line = String::new();
reader
.read_line(&mut line)
.map_err(|error| TransportError {
kind: TransportErrorKind::Io,
message: format!("failed to read ftp response: {error}"),
source: Some(error.to_string()),
context: None,
})?;
Ok(line)
}
#[expect(
clippy::result_large_err,
reason = "test-only live FTP smoke helpers bubble full transport context for assertions"
)]
fn expect_code(
reader: &mut BufReader<TcpStream>,
expected: u16,
) -> Result<String, TransportError> {
let line = Self::read_response_line(reader)?;
let code = line
.get(0..3)
.and_then(|digits| digits.parse::<u16>().ok())
.ok_or_else(|| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("invalid ftp response line: {line:?}"),
source: None,
context: None,
})?;
if code != expected {
return Err(TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("expected ftp code {expected}, got {code}: {line}"),
source: None,
context: None,
});
}
Ok(line)
}
#[expect(
clippy::result_large_err,
reason = "test-only live FTP smoke helpers bubble full transport context for assertions"
)]
fn write_command(stream: &mut TcpStream, command: &str) -> Result<(), TransportError> {
stream
.write_all(command.as_bytes())
.map_err(|error| TransportError {
kind: TransportErrorKind::Io,
message: format!("failed to write ftp command {command:?}: {error}"),
source: Some(error.to_string()),
context: None,
})
}
#[expect(
clippy::result_large_err,
reason = "test-only live FTP smoke helpers bubble full transport context for assertions"
)]
fn parse_pasv_addr(line: &str) -> Result<(String, u16), TransportError> {
let (_, after_open) = line.split_once('(').ok_or_else(|| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("missing PASV tuple in response: {line}"),
source: None,
context: None,
})?;
let (tuple_text, _) = after_open.split_once(')').ok_or_else(|| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("unterminated PASV tuple in response: {line}"),
source: None,
context: None,
})?;
let parts = tuple_text
.split(',')
.map(str::trim)
.map(str::parse::<u16>)
.collect::<Result<Vec<_>, _>>()
.map_err(|error| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("invalid PASV tuple in response: {line}: {error}"),
source: Some(error.to_string()),
context: None,
})?;
let [a, b, c, d, hi, lo]: [u16; 6] =
parts.try_into().map_err(|parts: Vec<u16>| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("expected 6 PASV tuple parts, got {}: {line}", parts.len()),
source: None,
context: None,
})?;
let host = format!("{a}.{b}.{c}.{d}");
let port = hi
.checked_mul(256)
.and_then(|value| value.checked_add(lo))
.ok_or_else(|| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("invalid PASV port tuple in response: {line}"),
source: None,
context: None,
})?;
Ok((host, port))
}
}
impl Downloader for LiveFtpSmokeDownloader {
fn start_http_transfer(
&self,
_task: &aria2_rust_pro_protocol::HttpTransferTaskModel,
) -> Result<HttpResponseModel, TransportError> {
Err(TransportError {
kind: TransportErrorKind::UnsupportedScheme,
message: "http unused in live ftp smoke".to_owned(),
source: None,
context: None,
})
}
fn start_ftp_transfer(
&self,
config: &FtpConfigModel,
request: &FtpRequestModel,
) -> Result<FtpResponseModel, TransportError> {
let mut control_stream =
TcpStream::connect((config.host.as_str(), config.port)).map_err(|error| {
TransportError {
kind: TransportErrorKind::NotConnected,
message: format!("failed to connect to live ftp smoke server: {error}"),
source: Some(error.to_string()),
context: None,
}
})?;
control_stream
.set_read_timeout(Some(Duration::from_secs(5)))
.expect("read timeout should set");
control_stream
.set_write_timeout(Some(Duration::from_secs(5)))
.expect("write timeout should set");
let mut control_reader =
BufReader::new(control_stream.try_clone().expect("clone should work"));
let _ = Self::expect_code(&mut control_reader, 220)?;
Self::write_command(
&mut control_stream,
&format!(
"USER {}\r\n",
config.username.as_deref().unwrap_or("anonymous")
),
)?;
let _ = Self::expect_code(&mut control_reader, 331)?;
Self::write_command(
&mut control_stream,
&format!("PASS {}\r\n", config.password.as_deref().unwrap_or("")),
)?;
let _ = Self::expect_code(&mut control_reader, 230)?;
Self::write_command(&mut control_stream, "TYPE I\r\n")?;
let _ = Self::expect_code(&mut control_reader, 200)?;
Self::write_command(&mut control_stream, "PASV\r\n")?;
let pasv = Self::expect_code(&mut control_reader, 227)?;
let (data_host, data_port) = Self::parse_pasv_addr(&pasv)?;
let mut data_stream =
TcpStream::connect((data_host.as_str(), data_port)).map_err(|error| {
TransportError {
kind: TransportErrorKind::NotConnected,
message: format!("failed to connect ftp data socket: {error}"),
source: Some(error.to_string()),
context: None,
}
})?;
let retr_path = request.path.as_deref().unwrap_or("/file.bin");
Self::write_command(&mut control_stream, &format!("RETR {retr_path}\r\n"))?;
let _ = Self::expect_code(&mut control_reader, 150)?;
let mut payload = Vec::new();
data_stream
.read_to_end(&mut payload)
.map_err(|error| TransportError {
kind: TransportErrorKind::Io,
message: format!("failed to read ftp data payload: {error}"),
source: Some(error.to_string()),
context: None,
})?;
let completion = Self::expect_code(&mut control_reader, 226)?;
let _ = Self::write_command(&mut control_stream, "QUIT\r\n");
Ok(FtpResponseModel {
code: 226,
message: completion.trim().to_owned(),
data: Some(payload),
path: request.path.clone(),
transferable: true,
})
}
fn start_sftp_transfer(
&self,
_config: &SftpConfigModel,
_request: &SftpRequestModel,
) -> Result<SftpResponseModel, TransportError> {
Err(TransportError {
kind: TransportErrorKind::UnsupportedScheme,
message: "sftp unused in live ftp smoke".to_owned(),
source: None,
context: None,
})
}
}
impl Downloader for LiveSftpSmokeDownloader {
fn start_http_transfer(
&self,
_task: &aria2_rust_pro_protocol::HttpTransferTaskModel,
) -> Result<HttpResponseModel, TransportError> {
Err(TransportError {
kind: TransportErrorKind::UnsupportedScheme,
message: "http unused in live sftp smoke".to_owned(),
source: None,
context: None,
})
}
fn start_ftp_transfer(
&self,
_config: &FtpConfigModel,
_request: &FtpRequestModel,
) -> Result<FtpResponseModel, TransportError> {
Err(TransportError {
kind: TransportErrorKind::UnsupportedScheme,
message: "ftp unused in live sftp smoke".to_owned(),
source: None,
context: None,
})
}
fn start_sftp_transfer(
&self,
config: &SftpConfigModel,
request: &SftpRequestModel,
) -> Result<SftpResponseModel, TransportError> {
let tcp = TcpStream::connect((config.host.as_str(), config.port)).map_err(|error| {
TransportError {
kind: TransportErrorKind::NotConnected,
message: format!("failed to connect live sftp smoke server: {error}"),
source: Some(error.to_string()),
context: None,
}
})?;
tcp.set_read_timeout(Some(Duration::from_secs(8))).ok();
tcp.set_write_timeout(Some(Duration::from_secs(8))).ok();
let mut session = ssh2::Session::new().map_err(|error| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("failed to build ssh2 session: {error}"),
source: Some(error.to_string()),
context: None,
})?;
session.set_tcp_stream(tcp);
session.handshake().map_err(|error| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("ssh handshake failed: {error}"),
source: Some(error.to_string()),
context: None,
})?;
let user = config.username.as_deref().unwrap_or("foo");
let pass = config.password.as_deref().unwrap_or("pass");
session
.userauth_password(user, pass)
.map_err(|error| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("ssh auth failed: {error}"),
source: Some(error.to_string()),
context: None,
})?;
let sftp = session.sftp().map_err(|error| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("failed to start sftp subsystem: {error}"),
source: Some(error.to_string()),
context: None,
})?;
let path = request.path.as_deref().unwrap_or("/upload/hello.txt");
let mut remote_file = sftp.open(path).map_err(|error| TransportError {
kind: TransportErrorKind::Io,
message: format!("failed to open remote path {path}: {error}"),
source: Some(error.to_string()),
context: None,
})?;
let mut payload = Vec::new();
remote_file
.read_to_end(&mut payload)
.map_err(|error| TransportError {
kind: TransportErrorKind::Io,
message: format!("failed to read remote payload: {error}"),
source: Some(error.to_string()),
context: None,
})?;
Ok(SftpResponseModel {
ok: true,
message: "sftp read ok".to_owned(),
payload: Some(payload),
path: request.path.clone(),
transferable: true,
})
}
}
impl SequencedHttpDownloader {
fn new(responses: Vec<Result<HttpResponseModel, TransportError>>) -> Self {
Self {
responses: Arc::new(Mutex::new(responses)),
requests: Arc::new(Mutex::new(Vec::new())),
}
}
fn recorded_requests(&self) -> Vec<aria2_rust_pro_protocol::HttpTransferTaskModel> {
self.requests.lock().expect("lock should work").clone()
}
}
impl Downloader for SequencedHttpDownloader {
fn start_http_transfer(
&self,
task: &aria2_rust_pro_protocol::HttpTransferTaskModel,
) -> Result<HttpResponseModel, TransportError> {
self.requests
.lock()
.expect("lock should work")
.push(task.clone());
let mut guard = self.responses.lock().expect("lock should work");
if guard.is_empty() {
return Err(TransportError {
kind: TransportErrorKind::Io,
message: "no scripted HTTP response remaining".to_owned(),
source: None,
context: None,
});
}
guard.remove(0)
}
fn start_ftp_transfer(
&self,
_config: &FtpConfigModel,
_request: &FtpRequestModel,
) -> Result<FtpResponseModel, TransportError> {
Err(TransportError {
kind: TransportErrorKind::UnsupportedScheme,
message: "unused in test".to_owned(),
source: None,
context: None,
})
}
fn start_sftp_transfer(
&self,
_config: &SftpConfigModel,
_request: &SftpRequestModel,
) -> Result<SftpResponseModel, TransportError> {
Err(TransportError {
kind: TransportErrorKind::UnsupportedScheme,
message: "unused in test".to_owned(),
source: None,
context: None,
})
}
}
#[derive(Clone, Debug)]
struct ConcurrentProbeDownloader {
bootstrap: HttpResponseModel,
bootstrap_delay: Duration,
ranged_responses: Arc<Mutex<VecDeque<(u64, HttpResponseModel)>>>,
requests: Arc<Mutex<Vec<aria2_rust_pro_protocol::HttpTransferTaskModel>>>,
active_calls: Arc<AtomicUsize>,
max_concurrent_calls: Arc<AtomicUsize>,
ranged_delay: Duration,
}
impl ConcurrentProbeDownloader {
fn new(
bootstrap: HttpResponseModel,
ranged_responses: Vec<(u64, HttpResponseModel)>,
ranged_delay: Duration,
) -> Self {
Self {
bootstrap,
bootstrap_delay: Duration::ZERO,
ranged_responses: Arc::new(Mutex::new(VecDeque::from(ranged_responses))),
requests: Arc::new(Mutex::new(Vec::new())),
active_calls: Arc::new(AtomicUsize::new(0)),
max_concurrent_calls: Arc::new(AtomicUsize::new(0)),
ranged_delay,
}
}
fn with_bootstrap_delay(
bootstrap: HttpResponseModel,
bootstrap_delay: Duration,
ranged_responses: Vec<(u64, HttpResponseModel)>,
ranged_delay: Duration,
) -> Self {
Self {
bootstrap,
bootstrap_delay,
ranged_responses: Arc::new(Mutex::new(VecDeque::from(ranged_responses))),
requests: Arc::new(Mutex::new(Vec::new())),
active_calls: Arc::new(AtomicUsize::new(0)),
max_concurrent_calls: Arc::new(AtomicUsize::new(0)),
ranged_delay,
}
}
fn recorded_requests(&self) -> Vec<aria2_rust_pro_protocol::HttpTransferTaskModel> {
self.requests.lock().expect("lock should work").clone()
}
fn max_concurrent_calls(&self) -> usize {
self.max_concurrent_calls.load(Ordering::SeqCst)
}
fn note_active_call(&self) {
let current = self
.active_calls
.fetch_add(1, Ordering::SeqCst)
.saturating_add(1);
let _ =
self.max_concurrent_calls
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |seen| {
(current > seen).then_some(current)
});
}
fn finish_active_call(&self) {
self.active_calls.fetch_sub(1, Ordering::SeqCst);
}
}
impl Downloader for ConcurrentProbeDownloader {
fn start_http_transfer(
&self,
task: &aria2_rust_pro_protocol::HttpTransferTaskModel,
) -> Result<HttpResponseModel, TransportError> {
self.requests
.lock()
.expect("lock should work")
.push(task.clone());
self.note_active_call();
let response = task.request.range.as_ref().map_or_else(
|| {
if !self.bootstrap_delay.is_zero() {
thread::sleep(self.bootstrap_delay);
}
Ok(self.bootstrap.clone())
},
|range| {
thread::sleep(self.ranged_delay);
let mut queued = self.ranged_responses.lock().expect("lock should work");
let index = queued
.iter()
.position(|(start, _)| *start == range.start)
.expect("matching ranged response should exist");
Ok(queued
.remove(index)
.expect("queued ranged response should exist")
.1)
},
);
self.finish_active_call();
response
}
fn start_ftp_transfer(
&self,
_config: &FtpConfigModel,
_request: &FtpRequestModel,
) -> Result<FtpResponseModel, TransportError> {
Err(TransportError {
kind: TransportErrorKind::UnsupportedScheme,
message: "probe downloader does not implement ftp".to_owned(),
source: None,
context: None,
})
}
fn start_sftp_transfer(
&self,
_config: &SftpConfigModel,
_request: &SftpRequestModel,
) -> Result<SftpResponseModel, TransportError> {
Err(TransportError {
kind: TransportErrorKind::UnsupportedScheme,
message: "probe downloader does not implement sftp".to_owned(),
source: None,
context: None,
})
}
}
mod command_surface;
mod integration_surface;
mod runtime_execution;
@@ -0,0 +1,478 @@
use super::*;
#[test]
fn parses_version_switch() {
let invocation = parse_args(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--version"),
])
.expect("version should parse");
assert_eq!(invocation, Invocation::Version);
}
#[test]
fn parses_help_switch() {
let invocation = parse_args(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--help"),
])
.expect("help should parse");
assert_eq!(invocation, Invocation::Help { query: None });
}
#[test]
fn parses_help_filter_switch() {
let invocation = parse_args(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--help=#http"),
])
.expect("filtered help should parse");
assert_eq!(
invocation,
Invocation::Help {
query: Some("#http".to_owned())
}
);
}
#[test]
fn parses_config_path_and_uris() {
let invocation = parse_args(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--conf-path"),
OsString::from("aria2.conf"),
OsString::from("https://example.com/file"),
])
.expect("config args should parse");
match invocation {
Invocation::Run { config_path, uris } => {
assert_eq!(
config_path.as_deref(),
Some(std::path::Path::new("aria2.conf"))
);
assert_eq!(uris, vec!["https://example.com/file"]);
}
other => panic!("unexpected invocation: {other:?}"),
}
}
#[test]
fn renderers_include_the_product_name() {
assert!(render_help(None).contains("Usage: aria2c [OPTIONS]"));
assert!(render_help(Some("#http")).contains("Printing options tagged with \"#http\"."));
assert!(render_version().contains("aria2-rust-pro"));
}
#[test]
fn execute_reads_configuration_files() {
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-test");
fs::create_dir_all(&temp_dir).expect("temp dir should be creatable");
let config_path = temp_dir.join("aria2.conf");
fs::write(&config_path, "max-connection-per-server=4\n").expect("config should be writable");
let invocation = Invocation::Run {
config_path: Some(config_path),
uris: Vec::new(),
};
execute(invocation).expect("config should execute");
}
#[test]
fn rejects_unknown_options() {
let error = parse_args(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--unknown-option"),
])
.expect_err("unknown options should fail");
assert!(matches!(error, CliError::UnknownOption(_)));
}
#[test]
fn execute_runtime_tracks_uris_through_rpc_and_core() {
let report = execute_runtime(Invocation::Run {
config_path: None,
uris: vec![
"https://example.org/file.iso".to_owned(),
"http://example.org/file-2.iso".to_owned(),
],
})
.expect("runtime should execute");
assert_eq!(report.accepted_uri_count, 2);
assert_eq!(report.tracked_download_count, 2);
assert_eq!(report.first_status.as_deref(), Some("error"));
assert_eq!(report.recognized_schemes, vec!["https", "http"]);
assert_eq!(
report.transfer_kinds,
vec![TransferSelection::Uri, TransferSelection::Uri]
);
}
#[test]
fn execute_runtime_with_fixture_http_marks_download_complete() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://example.com/file.bin", b"fixture-body");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec!["http://example.com/file.bin".to_owned()],
},
&downloader,
)
.expect("runtime should execute with fixture downloader");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.tracked_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(12));
assert_eq!(report.first_completed_length, Some(12));
assert_eq!(report.first_connections, Some(1));
assert_eq!(report.recognized_schemes, vec!["http"]);
assert_eq!(report.completed_download_count, 1);
}
#[test]
fn execute_runtime_runs_multiple_http_uris_concurrently_in_one_runtime() {
let downloader = ConcurrentProbeDownloader::with_bootstrap_delay(
HttpResponseModel {
status: 200,
reason: "OK".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
}],
},
body: ResponseBody::Inline(b"test".to_vec()),
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
},
Duration::from_millis(80),
Vec::new(),
Duration::ZERO,
);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec![
"http://example.com/parallel-a.bin".to_owned(),
"http://example.com/parallel-b.bin".to_owned(),
],
},
&downloader,
)
.expect("runtime should execute multiple http uris in one runtime");
assert_eq!(report.accepted_uri_count, 2);
assert_eq!(report.tracked_download_count, 2);
assert_eq!(report.completed_download_count, 2);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert!(
downloader.max_concurrent_calls() >= 2,
"multi-uri same-runtime execution should overlap bootstrap HTTP transfers"
);
assert_eq!(downloader.recorded_requests().len(), 2);
}
#[test]
fn execute_runtime_overlaps_registered_parallel_http_bootstrap_fanout() {
let downloader = ConcurrentProbeDownloader::with_bootstrap_delay(
HttpResponseModel {
status: 200,
reason: "OK".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
}],
},
body: ResponseBody::Inline(b"test".to_vec()),
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
},
Duration::from_millis(80),
Vec::new(),
Duration::ZERO,
);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec![
"http://example.com/capped-a.bin".to_owned(),
"http://example.com/capped-b.bin".to_owned(),
"http://example.com/capped-c.bin".to_owned(),
"http://example.com/capped-d.bin".to_owned(),
],
},
&downloader,
)
.expect("runtime should execute capped parallel http bootstrap in one runtime");
assert_eq!(report.accepted_uri_count, 4);
assert_eq!(report.tracked_download_count, 4);
assert_eq!(report.completed_download_count, 4);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(
downloader.max_concurrent_calls(),
4,
"bootstrap http fanout should use the registered same-runtime HTTP work set"
);
assert_eq!(downloader.recorded_requests().len(), 4);
}
#[test]
fn static_http_work_partition_uses_available_parallelism_evenly() {
let chunks = partition_indexed_work_evenly((0..6).map(|index| (index, index)), 6, 5);
assert_eq!(chunks.len(), 5);
assert_eq!(
chunks.iter().map(Vec::len).max(),
Some(2),
"six tasks over five workers should not collapse to three two-item workers"
);
}
#[test]
fn execute_runtime_with_fixture_ftp_marks_download_complete() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register_ftp(
"ftp://example.com:21/file.bin",
FtpResponseModel {
code: 226,
message: "transfer complete".to_owned(),
data: Some(b"ftp-payload".to_vec()),
path: Some("/file.bin".to_owned()),
transferable: true,
},
);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec!["ftp://example.com/file.bin".to_owned()],
},
&downloader,
)
.expect("runtime should execute with ftp fixture downloader");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(11));
assert_eq!(report.first_completed_length, Some(11));
assert_eq!(report.recognized_schemes, vec!["ftp"]);
assert_eq!(report.completed_download_count, 1);
}
#[test]
fn execute_runtime_with_live_ftp_server_marks_download_complete() {
let payload = b"live-ftp-payload".to_vec();
let ftp_server = LocalFtpTestServer::spawn(payload.clone());
let downloader = LiveFtpSmokeDownloader;
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec![format!(
"ftp://user:pass@127.0.0.1:{}/file.bin",
ftp_server.control_port()
)],
},
&downloader,
)
.expect("runtime should execute with live ftp server");
let payload_len = u64::try_from(payload.len()).expect("payload length should fit in u64");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(payload_len));
assert_eq!(report.first_completed_length, Some(payload_len));
assert_eq!(report.recognized_schemes, vec!["ftp"]);
assert_eq!(report.completed_download_count, 1);
ftp_server.join();
}
#[test]
fn execute_runtime_with_fixture_sftp_marks_download_complete() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register_sftp(
"sftp://example.com:22/file.bin",
SftpResponseModel {
ok: true,
message: "read ok".to_owned(),
payload: Some(b"sftp-payload".to_vec()),
path: Some("/file.bin".to_owned()),
transferable: true,
},
);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec!["sftp://example.com/file.bin".to_owned()],
},
&downloader,
)
.expect("runtime should execute with sftp fixture downloader");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(12));
assert_eq!(report.first_completed_length, Some(12));
assert_eq!(report.recognized_schemes, vec!["sftp"]);
assert_eq!(report.completed_download_count, 1);
}
#[test]
fn execute_runtime_with_live_sftp_server_marks_download_complete() {
let Some(server) = LocalSftpDockerServer::spawn() else {
eprintln!("skipping live sftp smoke: docker unavailable");
return;
};
let downloader = LiveSftpSmokeDownloader;
let expected_len = server.payload_len();
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec![format!(
"sftp://foo:pass@127.0.0.1:{}/upload/hello.txt",
server.host_port()
)],
},
&downloader,
)
.expect("runtime should execute with live sftp server");
let expected_len = u64::try_from(expected_len).expect("payload length should fit in u64");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(expected_len));
assert_eq!(report.first_completed_length, Some(expected_len));
assert_eq!(report.recognized_schemes, vec!["sftp"]);
assert_eq!(report.completed_download_count, 1);
}
#[test]
fn execute_runtime_with_local_metalink_fixture_http_marks_download_complete() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://example.com/file.bin", b"metalink-body");
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-metalink-runtime-test");
let _ = fs::create_dir_all(&temp_dir);
let metalink_path = temp_dir.join("fixture.meta4");
fs::write(
&metalink_path,
r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0">
<file name="file.bin">
<size>13</size>
<url priority="1">http://example.com/file.bin</url>
</file>
</metalink>"#,
)
.expect("metalink file should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec![metalink_path.to_string_lossy().into_owned()],
},
&downloader,
)
.expect("runtime should execute with local metalink fixture");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(13));
assert_eq!(report.first_completed_length, Some(13));
assert_eq!(report.transfer_kinds, vec![TransferSelection::Metalink]);
assert_eq!(report.completed_download_count, 1);
let _ = fs::remove_file(metalink_path);
}
#[test]
fn execute_runtime_with_local_metalink_fixture_prefers_protocol_candidate_resource() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://example.com/preferred.bin", b"preferred-body");
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-metalink-preferred-test");
let _ = fs::create_dir_all(&temp_dir);
let metalink_path = temp_dir.join("fixture.meta4");
fs::write(
&metalink_path,
r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0">
<file name="ignored.bin">
<url priority="1"></url>
</file>
<file name="picked.bin">
<size>14</size>
<url priority="2">http://example.com/first.bin</url>
<url priority="1">http://example.com/preferred.bin</url>
</file>
</metalink>"#,
)
.expect("metalink file should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec![metalink_path.to_string_lossy().into_owned()],
},
&downloader,
)
.expect("runtime should execute with preferred metalink resource");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(14));
assert_eq!(report.first_completed_length, Some(14));
assert_eq!(report.transfer_kinds, vec![TransferSelection::Metalink]);
assert_eq!(report.completed_download_count, 1);
let _ = fs::remove_file(metalink_path);
}
#[test]
fn execute_runtime_with_remote_metalink_fixture_fetches_document_then_downloads_resource() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register(
"http://example.com/doc.meta4",
r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0">
<file name="remote.bin">
<size>11</size>
<url priority="1">http://example.com/remote.bin</url>
</file>
</metalink>"#,
);
downloader.register("http://example.com/remote.bin", b"remote-body");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec!["http://example.com/doc.meta4".to_owned()],
},
&downloader,
)
.expect("runtime should execute with remote metalink fixture");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(11));
assert_eq!(report.first_completed_length, Some(11));
assert_eq!(report.transfer_kinds, vec![TransferSelection::Metalink]);
assert_eq!(report.recognized_schemes, vec!["http"]);
assert_eq!(report.completed_download_count, 1);
}
@@ -0,0 +1,9 @@
pub(super) use super::*;
pub(super) use crate::{args, derive_rpc_listen_host};
mod bt_runtime_and_tracker;
mod cli_overrides_and_proxy;
mod config_and_protocol_surface;
mod http_runtime_and_checksum;
mod input_file_and_source_order;
mod rpc_pressure_and_command_surface;
@@ -0,0 +1,105 @@
use super::*;
#[test]
fn execute_runtime_reports_bt_status_snapshot_for_magnet_inputs() {
let report = execute_runtime(Invocation::Run {
config_path: None,
uris: vec![String::from(
"magnet:?xt=urn:btih:00112233445566778899aabbccddeeff00112233&dn=bt-dht.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce&tr=udp%3A%2F%2Ftracker.example.org%3A6969&x.pe=198.51.100.9%3A51413",
)],
})
.expect("runtime should execute for a bt magnet");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.tracked_download_count, 1);
assert_eq!(report.transfer_kinds, vec![TransferSelection::Magnet]);
assert_eq!(report.recognized_schemes, vec![String::from("magnet")]);
let bt = report
.first_bt_status
.as_ref()
.expect("bt status snapshot should exist");
assert_eq!(bt.is_bt, Some(true));
assert_eq!(bt.metadata_only, Some(true));
assert_eq!(bt.share_time, Some(0));
assert_eq!(bt.share_ratio.as_deref(), Some("0.000"));
assert_eq!(bt.share_ratio_progress.as_deref(), Some("0.000"));
assert_eq!(bt.share_ratio_remaining.as_deref(), Some("0.000"));
assert_eq!(bt.num_seeders, Some(0));
assert!(
bt.announce_list_tier_count.unwrap_or_default() >= 1,
"magnet inputs should retain announce tiers in the cli-visible bt snapshot"
);
assert!(
matches!(bt.magnet_uri.as_deref(), Some(uri) if uri.starts_with("magnet:?xt=urn:btih:")),
"runtime report should preserve the canonical magnet uri"
);
}
#[test]
fn execute_runtime_with_magnet_executes_live_tracker_announce_when_available() {
let (tracker_url, handle) = start_live_bt_tracker_fixture();
let magnet_uri = format!(
"magnet:?xt=urn:btih:00112233445566778899aabbccddeeff00112233&dn=live-tracker.iso&tr={}",
percent_encode_uri_component(&tracker_url)
);
let report = execute_runtime(Invocation::Run {
config_path: None,
uris: vec![magnet_uri],
})
.expect("runtime should execute live tracker-backed magnet orchestration");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.tracked_download_count, 1);
assert_eq!(report.first_connections, Some(1));
let bt = report
.first_bt_status
.as_ref()
.expect("bt status snapshot should exist for tracker-backed magnet");
assert_eq!(bt.is_bt, Some(true));
assert_eq!(bt.metadata_only, Some(true));
assert_eq!(bt.num_seeders, Some(4));
assert!(
bt.announce_list_tier_count.unwrap_or_default() >= 1,
"tracker-backed magnet should retain announce tiers"
);
handle.join().expect("tracker server thread should join");
}
#[test]
fn execute_runtime_with_remote_torrent_url_fetches_payload_and_executes_live_tracker_announce() {
let (tracker_url, handle) = start_live_bt_tracker_fixture();
let torrent_bytes =
build_single_file_torrent_bytes(&tracker_url, "live-tracker.iso", 16_384, 16_384);
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://example.com/live-tracker.torrent", &torrent_bytes);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec!["http://example.com/live-tracker.torrent".to_owned()],
},
&downloader,
)
.expect("runtime should fetch torrent payload then execute live tracker announce");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.tracked_download_count, 1);
assert_eq!(report.transfer_kinds, vec![TransferSelection::Torrent]);
assert_eq!(report.first_connections, Some(1));
let bt = report
.first_bt_status
.as_ref()
.expect("bt status snapshot should exist for remote torrent input");
assert_eq!(bt.is_bt, Some(true));
assert_eq!(bt.metadata_only, Some(false));
assert_eq!(bt.num_seeders, Some(4));
assert!(
bt.announce_list_tier_count.unwrap_or_default() >= 1,
"remote torrent input should register announce tiers through addTorrent"
);
handle.join().expect("tracker server thread should join");
}
@@ -0,0 +1,334 @@
use super::*;
#[test]
fn parse_cli_tracks_rpc_profile() {
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--enable-rpc"),
OsString::from("--rpc-listen-all"),
OsString::from("--rpc-listen-port=16800"),
OsString::from("--rpc-secret=token"),
OsString::from("magnet:?xt=urn:btih:abc"),
])
.expect("cli should parse");
assert!(parsed.profile.rpc.enabled);
assert_eq!(parsed.profile.rpc.listen_host, "0.0.0.0");
assert_eq!(parsed.profile.rpc.listen_port, 16_800);
assert_eq!(parsed.profile.rpc.secret.as_deref(), Some("token"));
}
#[test]
fn parse_cli_routes_short_continue_into_cli_overrides() {
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("-c"),
OsString::from("-R"),
OsString::from("--dir"),
OsString::from("downloads"),
OsString::from("http://example.com/file.bin"),
])
.expect("cli should parse");
match parsed.invocation {
Invocation::Run { config_path, uris } => {
assert_eq!(config_path, None);
assert_eq!(uris, vec!["http://example.com/file.bin"]);
}
other => panic!("unexpected invocation: {other:?}"),
}
let cli_profile = parsed
.cli_profile
.as_ref()
.expect("cli overrides should produce a transient profile");
let options = profile_option_map(cli_profile);
assert_eq!(options.get("continue").map(String::as_str), Some("true"));
assert_eq!(options.get("remote-time").map(String::as_str), Some("true"));
assert_eq!(options.get("dir").map(String::as_str), Some("downloads"));
}
#[test]
fn parse_cli_canonicalizes_long_compat_aliases_and_live_options() {
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--http-want-digest=false"),
OsString::from("--check-certificate"),
OsString::from("false"),
OsString::from("--retry-on-403"),
OsString::from("--all-proxy"),
OsString::from("http://127.0.0.1:8080"),
OsString::from("--max-overall-download-limit=12M"),
OsString::from("http://example.com/file.bin"),
])
.expect("cli should parse compat aliases and live options");
match parsed.invocation {
Invocation::Run { config_path, uris } => {
assert_eq!(config_path, None);
assert_eq!(uris, vec!["http://example.com/file.bin"]);
}
other => panic!("unexpected invocation: {other:?}"),
}
let cli_profile = parsed
.cli_profile
.as_ref()
.expect("cli overrides should produce a transient profile");
let options = profile_option_map(cli_profile);
assert_eq!(
options.get("no-want-digest-header").map(String::as_str),
Some("false")
);
assert_eq!(
options.get("check-certificate").map(String::as_str),
Some("false")
);
assert_eq!(
options.get("retry-on-403").map(String::as_str),
Some("true")
);
assert_eq!(
options.get("all-proxy").map(String::as_str),
Some("http://127.0.0.1:8080")
);
assert_eq!(
options
.get("max-overall-download-limit")
.map(String::as_str),
Some("12M")
);
}
#[test]
fn execute_runtime_with_parsed_cli_overrides_wins_over_config_file() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://example.com/override.bin", b"override-body");
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-inline-override-test");
let _ = fs::create_dir_all(&temp_dir);
let config_target_dir = temp_dir.join("config-downloads");
let cli_target_dir = temp_dir.join("cli-downloads");
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
[
format!("dir={}", config_target_dir.display()),
"out=config.bin".to_owned(),
"split=2".to_owned(),
"max-connection-per-server=4".to_owned(),
"all-proxy=http://user:pass@127.0.0.1:9000".to_owned(),
"check-certificate=true".to_owned(),
"save-session=config.session".to_owned(),
]
.join("\n"),
)
.expect("config should write");
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--conf-path"),
config_path.as_os_str().to_os_string(),
OsString::from("--dir"),
cli_target_dir.as_os_str().to_os_string(),
OsString::from("--out"),
OsString::from("cli.bin"),
OsString::from("--split"),
OsString::from("5"),
OsString::from("--max-connection-per-server"),
OsString::from("7"),
OsString::from("--all-proxy"),
OsString::from("http://user:pass@127.0.0.1:8080"),
OsString::from("--check-certificate"),
OsString::from("false"),
OsString::from("--save-session"),
OsString::from("cli.session"),
OsString::from("http://example.com/override.bin"),
])
.expect("cli should parse");
let report = execute_runtime_with_context(
parsed.invocation,
&parsed.profile,
parsed.cli_profile.as_ref(),
Some(&parsed.cli_transfer_sources),
&downloader,
)
.expect("runtime should honor parsed cli overrides");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_connections, Some(5));
assert_eq!(report.derived_runtime.split, 5);
assert_eq!(report.derived_runtime.max_connections_per_server, 7);
assert_eq!(
report.derived_runtime.session_path.as_deref(),
Some(std::path::Path::new("cli.session"))
);
assert_eq!(
report.http_session.proxy.as_ref().map(|proxy| proxy.port),
Some(8080)
);
assert_eq!(
report.http_session.tls.as_ref().map(|tls| tls.verify_peer),
Some(false)
);
assert_eq!(
fs::read(cli_target_dir.join("cli.bin")).expect("cli target should persist payload"),
b"override-body"
);
assert!(
!config_target_dir.join("config.bin").exists(),
"config target should not win over CLI output overrides"
);
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn derive_http_session_layers_protocol_specific_proxy_auth_over_selected_endpoint() {
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-proxy-auth-layering");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
[
"http-proxy=http://127.0.0.1:8080".to_owned(),
"all-proxy-user=global-user".to_owned(),
"all-proxy-passwd=global-pass".to_owned(),
"http-proxy-user=http-user".to_owned(),
"http-proxy-passwd=http-pass".to_owned(),
String::new(),
]
.join("\n"),
)
.expect("config should be writable");
let report = load_config_report(&config_path, true).expect("config should load");
let session = derive_http_session(Some(&report.profile), &StartupProfile::default());
let proxy = session.proxy.expect("http proxy should be derived");
assert_eq!(proxy.host, "127.0.0.1");
assert_eq!(proxy.port, 8080);
assert_eq!(proxy.username.as_deref(), Some("http-user"));
assert_eq!(proxy.password.as_deref(), Some("http-pass"));
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn derive_http_session_falls_back_to_all_proxy_auth_when_scheme_specific_auth_is_missing() {
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-proxy-auth-fallback");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
[
"https-proxy=http://127.0.0.1:8443".to_owned(),
"all-proxy-user=global-user".to_owned(),
"all-proxy-passwd=global-pass".to_owned(),
String::new(),
]
.join("\n"),
)
.expect("config should be writable");
let report = load_config_report(&config_path, true).expect("config should load");
let session = derive_http_session(Some(&report.profile), &StartupProfile::default());
let proxy = session.proxy.expect("https proxy should be derived");
assert_eq!(proxy.host, "127.0.0.1");
assert_eq!(proxy.port, 8443);
assert_eq!(proxy.username.as_deref(), Some("global-user"));
assert_eq!(proxy.password.as_deref(), Some("global-pass"));
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn build_ftp_transfer_parts_prefers_ftp_proxy_and_honors_ftp_pasv_setting() {
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-ftp-proxy-derivation");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
[
"all-proxy=http://127.0.0.1:9000".to_owned(),
"all-proxy-user=global-user".to_owned(),
"all-proxy-passwd=global-pass".to_owned(),
"ftp-proxy=http://127.0.0.1:2121".to_owned(),
"ftp-proxy-user=ftp-user".to_owned(),
"ftp-proxy-passwd=ftp-pass".to_owned(),
"ftp-pasv=false".to_owned(),
String::new(),
]
.join("\n"),
)
.expect("config should be writable");
let report = load_config_report(&config_path, true).expect("config should load");
let profile = Some(&report.profile);
let session = derive_http_session(profile, &StartupProfile::default());
let (config, request) =
build_ftp_transfer_parts("FTP://download.example.org/file.bin", profile, &session)
.expect("ftp transfer parts should derive");
assert_eq!(config.host, "download.example.org");
assert_eq!(config.port, 21);
assert_eq!(config.mode, aria2_rust_pro_protocol::FtpMode::Active);
let proxy = config.proxy.expect("ftp proxy should be derived");
assert_eq!(proxy.host, "127.0.0.1");
assert_eq!(proxy.port, 2121);
assert_eq!(proxy.username.as_deref(), Some("ftp-user"));
assert_eq!(proxy.password.as_deref(), Some("ftp-pass"));
assert_eq!(request.path.as_deref(), Some("/file.bin"));
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn cli_overrides_win_for_ftp_proxy_auth_and_pasv_mode() {
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-ftp-override-precedence");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
[
"ftp-proxy=http://127.0.0.1:2121".to_owned(),
"ftp-proxy-user=config-user".to_owned(),
"ftp-proxy-passwd=config-pass".to_owned(),
"ftp-pasv=true".to_owned(),
String::new(),
]
.join("\n"),
)
.expect("config should be writable");
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--conf-path"),
config_path.as_os_str().to_os_string(),
OsString::from("--ftp-proxy-user"),
OsString::from("cli-user"),
OsString::from("--ftp-proxy-passwd"),
OsString::from("cli-pass"),
OsString::from("--ftp-pasv"),
OsString::from("false"),
OsString::from("ftp://download.example.org/file.bin"),
])
.expect("cli should parse");
let report = load_config_report(&config_path, true).expect("config should load");
let effective_profile = merged_profile(Some(&report.profile), parsed.cli_profile.as_ref());
let profile = effective_profile.as_ref();
let session = derive_http_session(profile, &parsed.profile);
let (config, _) =
build_ftp_transfer_parts("ftp://download.example.org/file.bin", profile, &session)
.expect("ftp transfer parts should derive");
assert_eq!(config.mode, aria2_rust_pro_protocol::FtpMode::Active);
let proxy = config.proxy.expect("ftp proxy should be derived");
assert_eq!(proxy.username.as_deref(), Some("cli-user"));
assert_eq!(proxy.password.as_deref(), Some("cli-pass"));
let _ = fs::remove_dir_all(temp_dir);
}
@@ -0,0 +1,141 @@
use super::*;
#[test]
fn load_config_report_derives_runtime_and_http_session_semantics() {
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-config-semantics");
fs::create_dir_all(&temp_dir).expect("temp dir should be creatable");
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
[
"rpc-listen-all=true",
"rpc-listen-port=16801",
"listen-port=16901",
"dht-listen-port=16902",
"split=6",
"max-concurrent-downloads=8",
"max-connection-per-server=32",
"max-overall-download-limit=12M",
"max-download-limit=3M",
"max-overall-upload-limit=4M",
"max-upload-limit=2M",
"disk-cache=32M",
"min-split-size=4M",
"piece-length=2M",
"save-session=session.dat",
"save-session-interval=45",
"disable-ipv6=false",
"user-agent=aria2-rust-pro-test",
"header=Accept: */*,X-Test: yes",
"all-proxy=http://user:pass@127.0.0.1:8080",
"no-proxy=localhost,127.0.0.1",
"check-certificate=false",
"retry-wait=5",
"max-tries=7",
"retry-on-403=true",
"",
]
.join("\n"),
)
.expect("config should be writable");
let report = load_config_report(&config_path, true).expect("report should load");
let startup = StartupProfile::default();
let runtime = derive_runtime_config(Some(&report.profile), &startup);
let rpc_listen_host = derive_rpc_listen_host(Some(&report.profile), &startup);
let session = derive_http_session(Some(&report.profile), &startup);
assert_eq!(rpc_listen_host, "0.0.0.0");
assert_eq!(runtime.rpc_port, 16_801);
assert_eq!(runtime.listen_port, 16_902);
assert_eq!(runtime.split, 6);
assert_eq!(runtime.max_active_downloads, 8);
assert_eq!(runtime.max_connections_per_server, 32);
assert_eq!(runtime.max_overall_download_limit, Some(12 * 1024 * 1024));
assert_eq!(runtime.max_download_limit, Some(3 * 1024 * 1024));
assert_eq!(runtime.max_overall_upload_limit, Some(4 * 1024 * 1024));
assert_eq!(runtime.max_upload_limit, Some(2 * 1024 * 1024));
assert_eq!(runtime.disk_cache_bytes, 32 * 1024 * 1024);
assert_eq!(runtime.min_split_size, 4 * 1024 * 1024);
assert_eq!(runtime.piece_length, 2 * 1024 * 1024);
assert_eq!(runtime.save_session_interval_secs, 45);
assert!(runtime.enable_ipv6);
assert_eq!(
runtime.session_path.as_deref(),
Some(std::path::Path::new("session.dat"))
);
assert_eq!(session.user_agent.as_deref(), Some("aria2-rust-pro-test"));
assert_eq!(session.default_headers.len(), 2);
assert_eq!(session.proxy.as_ref().map(|proxy| proxy.port), Some(8080));
assert_eq!(
session
.proxy
.as_ref()
.map(|proxy| proxy.bypass_hosts.clone()),
Some(vec!["localhost".to_owned(), "127.0.0.1".to_owned()])
);
assert_eq!(session.tls.as_ref().map(|tls| tls.verify_peer), Some(false));
assert_eq!(session.retry.policy.max_attempts, 7);
assert_eq!(session.retry.policy.initial_backoff_ms, 5000);
assert!(session.retry.policy.retry_on_4xx);
}
#[test]
fn classify_transfer_and_protocol_cover_bt_and_magnet_surface() {
let cases = vec![
(
"magnet:?xt=urn:btih:00112233445566778899aabbccddeeff00112233",
TransferSelection::Magnet,
Some(Protocol::Magnet),
),
(
"https://cdn.example.org/image.torrent",
TransferSelection::Torrent,
Some(Protocol::Https),
),
(
"http://cdn.example.org/doc.meta4",
TransferSelection::Metalink,
Some(Protocol::Http),
),
(
"magnet:?xt=urn:btih:00112233445566778899aabbccddeeff00112233&tr=udp%3A%2F%2Ftracker.example.org%3A6969&x.pe=198.51.100.9%3A51413",
TransferSelection::Magnet,
Some(Protocol::Magnet),
),
(
"sftp://mirror.example.org/archive.iso",
TransferSelection::Uri,
Some(Protocol::Sftp),
),
(
"HTTPS://cdn.example.org/MIXED.TORRENT",
TransferSelection::Torrent,
Some(Protocol::Https),
),
(
"MAGNET:?xt=urn:btih:00112233445566778899aabbccddeeff00112233",
TransferSelection::Magnet,
Some(Protocol::Magnet),
),
(
"udp://tracker.example.org:6969",
TransferSelection::Uri,
None,
),
];
for (input, expected_kind, expected_protocol) in cases {
assert_eq!(
classify_transfer(input),
expected_kind,
"transfer kind mismatch"
);
assert_eq!(
parse_protocol(input),
expected_protocol,
"protocol parse mismatch"
);
}
}
@@ -0,0 +1,281 @@
use super::*;
#[test]
fn execute_runtime_parallel_live_http_respects_base_profile_checksum_hook() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let handle = thread::spawn(move || {
for _ in 0..2 {
let (mut stream, _) = listener.accept().expect("client should connect");
let mut request = [0_u8; 2048];
let _ = stream.read(&mut request);
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nabc")
.expect("response should write");
}
});
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-parallel-checksum-base-profile");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
format!(
"dir={}\nsplit=1\nchecksum=sha-1=0000000000000000000000000000000000000000\n",
temp_dir.display()
),
)
.expect("config should write");
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let downloader = aria2_rust_pro_protocol::downloader::ConnectorBackedDownloader::new(
connector.clone(),
connector,
);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path),
uris: vec![
format!("http://{addr}/parallel-a.bin"),
format!("http://{addr}/parallel-b.bin"),
],
},
&downloader,
)
.expect("runtime should execute parallel live transfers");
assert_eq!(report.accepted_uri_count, 2);
assert_eq!(report.tracked_download_count, 2);
assert_eq!(report.completed_download_count, 0);
assert_eq!(report.first_status.as_deref(), Some("active"));
handle.join().expect("server thread should join");
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn execute_http_transfer_with_retry_uses_streamed_observed_truth_for_terminal_checksum() {
let downloader = FixtureHttpDownloader::new();
downloader.register_streamed_ok_with_checksum(
"http://example.com/retry-streamed.bin",
b"abc",
"md5",
"900150983cd24fb0d6963f7d28e17f72",
);
let runtime = RuntimeConfig::default();
let session = derive_http_session(None, &StartupProfile::default());
let task = build_http_transfer_task(
"gid-streamed".to_owned(),
"http://example.com/retry-streamed.bin".to_owned(),
&session,
&runtime,
None,
);
let execution = execute_http_transfer_with_retry(&downloader, &task, &runtime);
let response = execution.response.expect("response should exist");
assert_eq!(response.status, 200);
assert!(execution.checksum_observed);
assert!(execution.checksum_complete);
assert_eq!(response.completed_length(), 3);
assert_eq!(response.total_length(), Some(3));
}
#[test]
fn execute_runtime_requires_terminal_success_for_streamed_checksum_completion() {
let downloader = SequencedHttpDownloader::new(vec![Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Streamed {
expected_len: Some(10),
observed_len: Some(5),
observed_digest: Some("900150983cd24fb0d6963f7d28e17f72".to_owned()),
temp_path: None,
},
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 0,
end_inclusive: 4,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: Some(aria2_rust_pro_protocol::ChecksumSpec {
algorithm: "md5".to_owned(),
expected_hex: "900150983cd24fb0d6963f7d28e17f72".to_owned(),
actual_hex: None,
}),
redirected_from: None,
})]);
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-streamed-partial-single-test");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(&config_path, "split=1\n").expect("config should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path.clone()),
uris: vec!["http://example.com/streamed-partial.bin".to_owned()],
},
&downloader,
)
.expect("runtime should execute");
assert_eq!(report.first_status.as_deref(), Some("active"));
assert_eq!(report.first_total_length, Some(10));
assert_eq!(report.first_completed_length, Some(5));
assert_eq!(report.completed_download_count, 0);
let _ = fs::remove_file(config_path);
}
#[test]
fn build_http_transfer_task_caps_connections_by_split_budget() {
let session = derive_http_session(None, &StartupProfile::default());
let runtime = RuntimeConfig {
split: 3,
max_connections_per_server: 8,
..RuntimeConfig::default()
};
let task = build_http_transfer_task(
"gid".to_owned(),
"http://example.com/file.bin".to_owned(),
&session,
&runtime,
None,
);
assert_eq!(task.max_connections, 3);
}
#[test]
fn build_http_transfer_task_derives_checksum_hook_from_profile() {
let session = derive_http_session(None, &StartupProfile::default());
let runtime = RuntimeConfig::default();
let profile = args::config_profile_from_directives(
"checksum-hook",
aria2_rust_pro_compat::ConfigSource::RuntimeOverride,
vec![aria2_rust_pro_compat::ConfigDirective {
name: "checksum".to_owned(),
value: Some("sha-256=abcdef".to_owned()),
}],
)
.expect("profile should exist");
let task = build_http_transfer_task(
"gid-checksum".to_owned(),
"http://example.com/file.bin".to_owned(),
&session,
&runtime,
Some(&profile),
);
assert_eq!(
task.checksum_hook.as_ref().map(|hook| (
hook.spec.algorithm.as_str(),
hook.spec.expected_hex.as_str()
)),
Some(("sha-256", "abcdef"))
);
}
#[test]
fn planned_segment_span_respects_split_and_size_floors() {
let mut runtime = RuntimeConfig {
split: 3,
min_split_size: 4,
piece_length: 4,
..RuntimeConfig::default()
};
assert_eq!(planned_segment_span(10, &runtime), Some(4));
runtime.min_split_size = 8;
runtime.piece_length = 4;
assert_eq!(planned_segment_span(18, &runtime), Some(8));
runtime.split = 1;
assert_eq!(planned_segment_span(18, &runtime), None);
}
#[test]
fn execute_runtime_reports_connection_budget_from_split_and_server_cap() {
let downloader = SequencedHttpDownloader::new(vec![Ok(HttpResponseModel {
status: 200,
reason: "OK".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
}],
},
body: ResponseBody::Inline(b"done".to_vec()),
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
})]);
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-split-test");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(&config_path, "split=3\nmax-connection-per-server=8\n").expect("config should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path.clone()),
uris: vec!["http://example.com/split.bin".to_owned()],
},
&downloader,
)
.expect("runtime should execute with split budget");
assert_eq!(report.first_connections, Some(3));
let recorded = downloader.recorded_requests();
let [request] = recorded.as_slice() else {
panic!("expected exactly one recorded request");
};
assert_eq!(request.max_connections, 3);
let _ = fs::remove_file(config_path);
}
#[test]
fn execute_runtime_persists_http_payload_to_configured_dir() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://example.com/payload.bin", b"fixture-file-body");
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-file-persist-test");
let _ = fs::create_dir_all(&temp_dir);
let target_dir = temp_dir.join("downloads");
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
format!("dir={}\nout=payload.bin\nsplit=1\n", target_dir.display()),
)
.expect("config should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path),
uris: vec!["http://example.com/payload.bin".to_owned()],
},
&downloader,
)
.expect("runtime should execute");
assert_eq!(report.completed_download_count, 1);
assert_eq!(
fs::read(target_dir.join("payload.bin")).expect("payload should persist"),
b"fixture-file-body"
);
let _ = fs::remove_dir_all(temp_dir);
}
@@ -0,0 +1,225 @@
use super::*;
#[test]
fn execute_runtime_with_input_file_cli_option_loads_multiple_downloads() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://example.com/input-a.bin", b"aaaa");
downloader.register("http://example.com/input-b.bin", b"bbbb");
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-input-file-test");
let _ = fs::create_dir_all(&temp_dir);
let input_path = temp_dir.join("downloads.txt");
fs::write(
&input_path,
"http://example.com/input-a.bin\nhttp://example.com/input-b.bin\n",
)
.expect("input-file should write");
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--input-file"),
input_path.as_os_str().to_os_string(),
])
.expect("cli should parse input-file");
let report = execute_runtime_with_context(
parsed.invocation,
&parsed.profile,
parsed.cli_profile.as_ref(),
Some(&parsed.cli_transfer_sources),
&downloader,
)
.expect("runtime should expand input-file downloads");
assert_eq!(report.accepted_uri_count, 2);
assert_eq!(report.tracked_download_count, 2);
assert_eq!(report.completed_download_count, 2);
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn execute_runtime_with_input_file_groups_tab_separated_mirrors_as_one_entity() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://example.com/mirror-a.bin", b"mirror");
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-input-file-mirror-test");
let _ = fs::create_dir_all(&temp_dir);
let input_path = temp_dir.join("downloads.txt");
fs::write(
&input_path,
"http://example.com/mirror-a.bin\thttp://mirror.example.com/mirror-a.bin\n",
)
.expect("input-file should write");
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--input-file"),
input_path.as_os_str().to_os_string(),
])
.expect("cli should parse input-file");
let report = execute_runtime_with_context(
parsed.invocation,
&parsed.profile,
parsed.cli_profile.as_ref(),
Some(&parsed.cli_transfer_sources),
&downloader,
)
.expect("runtime should keep mirror rows as one logical entity");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.tracked_download_count, 1);
assert_eq!(report.completed_download_count, 1);
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn execute_runtime_with_input_file_entry_options_override_global_output_path() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("http://example.com/from-input.bin", b"from-input");
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-input-file-entry-option-test");
let _ = fs::create_dir_all(&temp_dir);
let config_dir = temp_dir.join("config-output");
let entry_dir = temp_dir.join("entry-output");
let config_path = temp_dir.join("aria2.conf");
let input_path = temp_dir.join("downloads.txt");
fs::write(
&config_path,
[
format!("dir={}", config_dir.display()),
"out=config.bin".to_owned(),
]
.join("\n"),
)
.expect("config should write");
fs::write(
&input_path,
[
"http://example.com/from-input.bin".to_owned(),
format!(" dir={}", entry_dir.display()),
" out=entry.bin".to_owned(),
]
.join("\n"),
)
.expect("input-file should write");
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--conf-path"),
config_path.as_os_str().to_os_string(),
OsString::from("--input-file"),
input_path.as_os_str().to_os_string(),
])
.expect("cli should parse input-file with config");
let report = execute_runtime_with_context(
parsed.invocation,
&parsed.profile,
parsed.cli_profile.as_ref(),
Some(&parsed.cli_transfer_sources),
&downloader,
)
.expect("runtime should apply per-entry input-file overrides");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.completed_download_count, 1);
assert_eq!(
fs::read(entry_dir.join("entry.bin")).expect("entry override target should exist"),
b"from-input"
);
assert!(
!config_dir.join("config.bin").exists(),
"global config output should not win over entry-specific input-file overrides"
);
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn parse_cli_preserves_mixed_uri_and_input_file_source_order() {
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("https://example.com/a.bin"),
OsString::from("--input-file"),
OsString::from("first.txt"),
OsString::from("http://example.com/c.bin"),
OsString::from("-i=second.txt"),
])
.expect("cli should parse mixed uri and input-file sources");
assert_eq!(
parsed.cli_transfer_sources,
vec![
CliTransferSource::Uri("https://example.com/a.bin".to_owned()),
CliTransferSource::InputFile("first.txt".to_owned()),
CliTransferSource::Uri("http://example.com/c.bin".to_owned()),
CliTransferSource::InputFile("second.txt".to_owned()),
]
);
}
#[test]
fn execute_runtime_preserves_cli_source_order_across_repeated_input_files() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("https://example.com/a.bin", b"a");
downloader.register_ftp(
"ftp://example.com:21/b.bin",
FtpResponseModel {
code: 226,
message: "transfer complete".to_owned(),
data: Some(b"b".to_vec()),
path: Some("/b.bin".to_owned()),
transferable: true,
},
);
downloader.register("http://example.com/c.bin", b"c");
downloader.register_sftp(
"sftp://example.com:22/d.txt",
SftpResponseModel {
ok: true,
message: "read ok".to_owned(),
payload: Some(b"d".to_vec()),
path: Some("/d.txt".to_owned()),
transferable: true,
},
);
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-input-file-order-test");
let _ = fs::create_dir_all(&temp_dir);
let first_input = temp_dir.join("first.txt");
let second_input = temp_dir.join("second.txt");
fs::write(&first_input, "ftp://example.com/b.bin\n").expect("first input-file should write");
fs::write(&second_input, "sftp://example.com/d.txt\n").expect("second input-file should write");
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("https://example.com/a.bin"),
OsString::from("--input-file"),
first_input.as_os_str().to_os_string(),
OsString::from("http://example.com/c.bin"),
OsString::from(format!("-i={}", second_input.display())),
])
.expect("cli should parse ordered transfer sources");
let report = execute_runtime_with_context(
parsed.invocation,
&parsed.profile,
parsed.cli_profile.as_ref(),
Some(&parsed.cli_transfer_sources),
&downloader,
)
.expect("runtime should preserve ordered transfer-source expansion");
assert_eq!(report.accepted_uri_count, 4);
assert_eq!(report.tracked_download_count, 4);
assert_eq!(report.completed_download_count, 4);
assert_eq!(
report.recognized_schemes,
vec!["https", "ftp", "http", "sftp"]
);
let _ = fs::remove_dir_all(temp_dir);
}
@@ -0,0 +1,222 @@
use super::*;
#[test]
fn synthetic_bt_like_pressure_keeps_rpc_tell_status_responsive() {
let runtime = RuntimeConfig {
allow_jsonrpc: true,
allow_xmlrpc: true,
..RuntimeConfig::default()
};
let mut dispatcher = InProcessRpcDispatcher::with_runtime(runtime);
// Keep this smoke lightweight: enough concurrency-like pressure to catch
// dispatcher starvation without depending on real BT sessions.
let synthetic_bt_inputs = (0..128)
.map(|index| {
format!(
"magnet:?xt=urn:btih:{:040x}&dn=synthetic-{index}&tr=http://127.0.0.1:6969/announce",
index + 1
)
})
.collect::<Vec<_>>();
let mut gids = Vec::with_capacity(synthetic_bt_inputs.len());
for magnet_uri in &synthetic_bt_inputs {
let add_response = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet_uri.clone())],
meta: aria2_rust_pro_rpc::RpcMeta::default(),
});
match add_response.result {
Some(RpcValue::String(gid)) => gids.push(gid),
_ => panic!("expected gid from addUri, got: {add_response:?}"),
}
}
assert_eq!(
dispatcher.tracked_download_count(),
synthetic_bt_inputs.len()
);
let probe_gid = gids
.first()
.cloned()
.expect("synthetic bt probe should register at least one gid");
let mut ok_rounds = 0_usize;
let probe_rounds = 256_usize;
let deadline = Instant::now() + Duration::from_secs(2);
for _ in 0..probe_rounds {
let status_response = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(probe_gid.clone())],
meta: aria2_rust_pro_rpc::RpcMeta::default(),
});
if matches!(status_response.result, Some(RpcValue::Object(_))) {
ok_rounds += 1;
} else {
panic!("tellStatus should stay responsive: {status_response:?}");
}
assert!(
Instant::now() < deadline,
"synthetic RPC probe exceeded responsiveness budget"
);
}
assert_eq!(ok_rounds, probe_rounds);
}
#[test]
fn command_surface_uses_validate_mode_for_dry_run() {
let parsed = parse_cli(vec![
OsString::from("aria2-rust-pro"),
OsString::from("--dry-run"),
OsString::from("--conf-path=aria2.conf"),
])
.expect("cli should parse");
match command_surface(&parsed) {
CommandSurface::ValidateConfig {
config_path,
strict,
} => {
assert_eq!(config_path, std::path::PathBuf::from("aria2.conf"));
assert!(strict);
}
other => panic!("unexpected command surface: {other:?}"),
}
}
#[test]
fn parse_cli_bt_batch_inputs_keep_order_and_rpc_daemon_surface() {
let bt_inputs = vec![
"magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=alpha&tr=http://tracker.example.org/a",
"https://tracker.example.org/files/tracker-a.torrent",
"magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&dn=beta&tr=http://tracker.example.org/b",
"http://cdn.example.org/tracker-b.torrent",
];
let mut args = vec![
OsString::from("aria2-rust-pro"),
OsString::from("--enable-rpc"),
OsString::from("--rpc-only"),
OsString::from("--rpc-listen-port=16999"),
OsString::from("--conf-path=bt-runtime.conf"),
];
args.extend(bt_inputs.iter().map(OsString::from));
let parsed = parse_cli(args).expect("bt batch args should parse");
let surface = command_surface(&parsed);
match surface {
CommandSurface::RpcDaemon {
config_path,
inputs,
} => {
assert_eq!(
config_path,
Some(std::path::PathBuf::from("bt-runtime.conf"))
);
assert_eq!(inputs, bt_inputs);
}
other => panic!("expected rpc-daemon command surface, got: {other:?}"),
}
assert_eq!(parsed.profile.mode, RuntimeMode::RpcOnly);
assert_eq!(parsed.profile.rpc.listen_port, 16_999);
}
#[test]
fn synthetic_bt_mixed_status_probe_keeps_runtime_fields_shape_stable() {
let runtime = RuntimeConfig {
allow_jsonrpc: true,
allow_xmlrpc: true,
..RuntimeConfig::default()
};
let mut dispatcher = InProcessRpcDispatcher::with_runtime(runtime);
let mut gids = Vec::new();
for index in 0..48 {
let uri = if index % 3 == 0 {
format!("https://assets.example.org/bt-{index}.torrent")
} else {
format!(
"magnet:?xt=urn:btih:{:040x}&dn=mix-{index}&tr=http://127.0.0.1:6969/announce&tr=udp://127.0.0.1:6969",
index + 100
)
};
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(uri)],
meta: aria2_rust_pro_rpc::RpcMeta::default(),
});
match add.result {
Some(RpcValue::String(gid)) => gids.push(gid),
other => panic!("unexpected addUri result: {other:?}"),
}
}
let mut bt_object_count = 0usize;
for gid in &gids {
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: aria2_rust_pro_rpc::RpcMeta::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert!(
payload.contains_key("status"),
"status key should remain visible under mixed bt load"
);
if payload.get("isBt") == Some(&RpcValue::Bool(true)) {
bt_object_count += 1;
match payload.get("seeder") {
Some(RpcValue::Bool(false)) => {}
Some(RpcValue::String(value)) if value == "false" => {}
other => panic!(
"bt payload should expose seeder as a visible scalar, got: {other:?}"
),
}
assert!(
payload.contains_key("numSeeders"),
"bt payload should expose numSeeders"
);
assert!(
payload.contains_key("shareRatio"),
"bt payload should expose shareRatio"
);
assert!(
payload.contains_key("shareRatioProgress"),
"bt payload should expose shareRatioProgress"
);
assert!(
payload.contains_key("shareRatioRemaining"),
"bt payload should expose shareRatioRemaining"
);
assert!(
payload.contains_key("shareTime"),
"bt payload should expose shareTime"
);
assert!(
payload.contains_key("metadataOnly"),
"bt payload should expose metadataOnly"
);
assert!(
payload.contains_key("announceList"),
"bt payload should expose announceList"
);
}
}
other => panic!("unexpected tellStatus result: {other:?}"),
}
}
assert_eq!(dispatcher.tracked_download_count(), 48);
assert!(
bt_object_count >= 32,
"magnet-heavy mix should be mostly BT"
);
}
@@ -0,0 +1,7 @@
pub(super) use super::*;
mod checksum_completion;
mod live_http_connector;
mod retry_and_partial;
mod segment_parallelism;
mod segment_planning;
@@ -0,0 +1,63 @@
use super::*;
#[test]
fn execute_runtime_marks_completion_when_checksum_seen_on_terminal_success() {
let downloader = SequencedHttpDownloader::new(vec![Ok(HttpResponseModel {
status: 200,
reason: "OK".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "3".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
}],
},
body: ResponseBody::Inline(b"abc".to_vec()),
content_range: None,
partial_content: false,
checksum: Some(aria2_rust_pro_protocol::ChecksumSpec {
algorithm: "sha-1".to_owned(),
expected_hex: "a9993e364706816aba3e25717850c26c9cd0d89d".to_owned(),
actual_hex: None,
}),
redirected_from: None,
})]);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec!["http://example.com/checksum.bin".to_owned()],
},
&downloader,
)
.expect("runtime should treat checksum terminal response as complete");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_completed_length, Some(3));
}
#[test]
fn execute_runtime_uses_streamed_observed_truth_for_checksum_completion() {
let downloader = FixtureHttpDownloader::new();
downloader.register_streamed_ok_with_checksum(
"http://example.com/streamed-checksum.bin",
b"abc",
"md5",
"900150983cd24fb0d6963f7d28e17f72",
);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec!["http://example.com/streamed-checksum.bin".to_owned()],
},
&downloader,
)
.expect("runtime should treat streamed observed checksum as complete");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_completed_length, Some(3));
}
@@ -0,0 +1,315 @@
use super::*;
#[test]
fn execute_runtime_with_live_http_connector_completes_local_response() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("client should connect");
let mut request = [0_u8; 2048];
let _ = stream.read(&mut request);
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabc")
.expect("response should write");
});
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let downloader = aria2_rust_pro_protocol::downloader::ConnectorBackedDownloader::new(
connector.clone(),
connector,
);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec![format!("http://{addr}/live")],
},
&downloader,
)
.expect("runtime should complete via live connector");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_completed_length, Some(3));
handle.join().expect("server thread should join");
}
#[test]
fn execute_runtime_with_live_http_connector_persists_streamed_payload_to_configured_dir() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("client should connect");
let mut request = [0_u8; 2048];
let _ = stream.read(&mut request);
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 7\r\n\r\npersist")
.expect("response should write");
});
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-live-persist-test");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let target_dir = temp_dir.join("downloads");
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
format!("dir={}\nout=payload.bin\nsplit=1\n", target_dir.display()),
)
.expect("config should write");
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let downloader = aria2_rust_pro_protocol::downloader::ConnectorBackedDownloader::new(
connector.clone(),
connector,
);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path),
uris: vec![format!("http://{addr}/payload.bin")],
},
&downloader,
)
.expect("runtime should persist the streamed live response");
assert_eq!(report.completed_download_count, 1);
assert_eq!(
fs::read(target_dir.join("payload.bin")).expect("payload should persist"),
b"persist"
);
handle.join().expect("server thread should join");
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn execute_runtime_with_live_http_connector_avoids_second_scale_delay_for_large_single_file() {
let payload = vec![b'a'; 8 * 1024 * 1024];
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("client should connect");
let mut request = [0_u8; 2048];
let _ = stream.read(&mut request);
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
payload.len()
);
stream
.write_all(headers.as_bytes())
.expect("response headers should write");
stream
.write_all(&payload)
.expect("response body should write");
});
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let downloader = aria2_rust_pro_protocol::downloader::ConnectorBackedDownloader::new(
connector.clone(),
connector,
);
let started = Instant::now();
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec![format!("http://{addr}/large-live.bin")],
},
&downloader,
)
.expect("runtime should complete large live connector response");
let elapsed = started.elapsed();
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_completed_length, Some(8 * 1024 * 1024));
assert!(
elapsed < Duration::from_millis(900),
"large single-file live connector execution should stay below a second-scale delay: {elapsed:?}"
);
handle.join().expect("server thread should join");
}
#[test]
fn execute_runtime_with_live_http_connector_completes_multiple_local_responses_in_one_runtime() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let handle = thread::spawn(move || {
let mut workers = Vec::new();
for _ in 0..2 {
let (mut stream, _) = listener.accept().expect("client should connect");
workers.push(thread::spawn(move || {
let mut request = [0_u8; 2048];
let _ = stream.read(&mut request);
thread::sleep(Duration::from_millis(80));
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabc")
.expect("response should write");
}));
}
for worker in workers {
worker.join().expect("http response worker should join");
}
});
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let downloader = aria2_rust_pro_protocol::downloader::ConnectorBackedDownloader::new(
connector.clone(),
connector,
);
let started = Instant::now();
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: None,
uris: vec![
format!("http://{addr}/live-a"),
format!("http://{addr}/live-b"),
],
},
&downloader,
)
.expect("runtime should complete multiple live connector responses");
let elapsed = started.elapsed();
assert_eq!(report.accepted_uri_count, 2);
assert_eq!(report.tracked_download_count, 2);
assert_eq!(report.completed_download_count, 2);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert!(
elapsed < Duration::from_millis(600),
"same-runtime live connector execution should overlap request latency: {elapsed:?}"
);
handle.join().expect("server thread should join");
}
#[expect(
clippy::too_many_lines,
reason = "live retry/resume fixture is clearer as one test"
)]
#[test]
fn execute_runtime_with_live_http_connector_retries_resumes_and_completes_checksum() {
fn read_http_request(stream: &mut TcpStream) -> String {
let mut buf = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk).expect("socket should read");
if read == 0 {
break;
}
let payload = chunk
.get(..read)
.expect("read count should stay within the temporary buffer");
buf.extend_from_slice(payload);
if buf.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
String::from_utf8_lossy(&buf).to_lowercase()
}
fn requested_range(request: &str) -> Option<(usize, Option<usize>)> {
let range_line = request
.lines()
.find(|line| line.trim_start().starts_with("range: bytes="))?;
let raw = range_line
.trim_start()
.strip_prefix("range: bytes=")?
.trim();
let (start, end) = raw.split_once('-')?;
Some((
start.parse().ok()?,
(!end.is_empty()).then(|| end.parse().ok()).flatten(),
))
}
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let handle = thread::spawn(move || {
let (mut retry, _) = listener.accept().expect("retry client should connect");
let retry_request = read_http_request(&mut retry);
assert!(retry_request.contains("get /resume-checksum.bin http/1.1"));
assert!(!retry_request.contains("range:"));
retry
.write_all(
b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.expect("retry response should write");
let (mut bootstrap, _) = listener.accept().expect("bootstrap client should connect");
let bootstrap_request = read_http_request(&mut bootstrap);
assert!(bootstrap_request.contains("get /resume-checksum.bin http/1.1"));
assert!(!bootstrap_request.contains("range:"));
bootstrap
.write_all(
b"HTTP/1.1 206 Partial Content\r\nContent-Length: 4\r\nContent-Range: bytes 0-3/10\r\nConnection: close\r\n\r\n1234",
)
.expect("bootstrap response should write");
let (mut resumed, _) = listener.accept().expect("resume client should connect");
let resumed_request = read_http_request(&mut resumed);
let payload = b"1234567890";
let (start, end) =
requested_range(&resumed_request).expect("resume request should include range");
let end = end.unwrap_or_else(|| payload.len().saturating_sub(1));
assert_eq!(start, 4);
assert_eq!(end, 9);
let body = payload
.get(start..=end)
.expect("resume request should stay within the scripted payload");
let response = format!(
"HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {start}-{end}/{}\r\nConnection: close\r\n\r\n",
body.len(),
payload.len()
);
resumed
.write_all(response.as_bytes())
.expect("resume response headers should write");
resumed
.write_all(body)
.expect("resume response body should write");
});
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-live-resume-checksum-test");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
format!(
"dir={}\nout=resume-checksum.bin\nsplit=1\nmax-tries=3\nchecksum=sha-1=01b307acba4f54f55aafc33bb06bbbf6ca803e9a\n",
temp_dir.display()
),
)
.expect("config should write");
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let downloader = aria2_rust_pro_protocol::downloader::ConnectorBackedDownloader::new(
connector.clone(),
connector,
);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path),
uris: vec![format!("http://{addr}/resume-checksum.bin")],
},
&downloader,
)
.expect("runtime should complete retried ranged live transfer with checksum");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(10));
assert_eq!(report.first_completed_length, Some(10));
assert_eq!(
fs::read(temp_dir.join("resume-checksum.bin")).expect("target should read"),
b"1234567890"
);
handle.join().expect("server thread should join");
let _ = fs::remove_dir_all(temp_dir);
}
@@ -0,0 +1,276 @@
use super::*;
#[test]
fn execute_runtime_retries_http_failure_then_completes() {
let downloader = SequencedHttpDownloader::new(vec![
Ok(HttpResponseModel {
status: 503,
reason: "Service Unavailable".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Empty,
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
}),
Ok(HttpResponseModel {
status: 200,
reason: "OK".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "8".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
}],
},
body: ResponseBody::Inline(b"aaaaaaaa".to_vec()),
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
}),
]);
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-retry-test");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(&config_path, "retry-wait=1\nmax-tries=2\n").expect("config should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path.clone()),
uris: vec!["http://example.com/retry.bin".to_owned()],
},
&downloader,
)
.expect("runtime should recover via retry");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_completed_length, Some(8));
assert_eq!(report.first_connections, Some(1));
let _ = fs::remove_file(config_path);
}
#[test]
fn execute_runtime_accepts_partial_content_as_complete_progress() {
let downloader = SequencedHttpDownloader::new(vec![Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "5".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 0-4/10".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"12345".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 0,
end_inclusive: 4,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
})]);
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-partial-single-test");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(&config_path, "split=1\n").expect("config should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path.clone()),
uris: vec!["http://example.com/partial.bin".to_owned()],
},
&downloader,
)
.expect("runtime should accept 206 transfer");
assert_eq!(report.completed_download_count, 0);
assert_eq!(report.first_status.as_deref(), Some("active"));
assert_eq!(report.first_total_length, Some(10));
assert_eq!(report.first_completed_length, Some(5));
assert_eq!(report.first_connections, Some(1));
let _ = fs::remove_file(config_path);
}
#[expect(
clippy::too_many_lines,
reason = "partial-range regression fixture keeps the response sequence and assertions together"
)]
#[test]
fn execute_runtime_advances_multi_step_partial_ranges_until_complete() {
let downloader = SequencedHttpDownloader::new(vec![
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 0-3/10".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"1234".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 0,
end_inclusive: 3,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "3".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 4-6/10".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"567".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 4,
end_inclusive: 6,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "2".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 8-9/10".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"90".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 8,
end_inclusive: 9,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
]);
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-multipart-test");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
"max-tries=3\nsplit=3\nmin-split-size=4\npiece-length=4\n",
)
.expect("config should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path.clone()),
uris: vec!["http://example.com/multipart.bin".to_owned()],
},
&downloader,
)
.expect("runtime should advance through multiple partial segments");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_total_length, Some(10));
assert_eq!(report.first_completed_length, Some(10));
assert_eq!(report.first_connections, Some(1));
let recorded = downloader.recorded_requests();
let [bootstrap, first_followup, second_followup] = recorded.as_slice() else {
panic!("expected exactly three recorded requests");
};
assert_eq!(bootstrap.request.range, None);
let mut followup_ranges = [first_followup, second_followup]
.iter()
.map(|task| task.request.range)
.collect::<Vec<_>>();
followup_ranges.sort_by_key(|range| range.as_ref().map(|range| range.start));
assert_eq!(
followup_ranges,
vec![
Some(aria2_rust_pro_protocol::RangeSpec {
start: 4,
end_inclusive: Some(9),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
}),
Some(aria2_rust_pro_protocol::RangeSpec {
start: 7,
end_inclusive: Some(9),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
}),
]
);
assert_eq!(bootstrap.retry_attempts.len(), 0);
assert_eq!(first_followup.retry_attempts.len(), 0);
assert_eq!(second_followup.retry_attempts.len(), 1);
assert!(
first_followup
.retry_attempts
.iter()
.all(|attempt| attempt.status == Some(206))
);
assert!(
second_followup
.retry_attempts
.iter()
.all(|attempt| attempt.status == Some(206))
);
let _ = fs::remove_file(config_path);
}
@@ -0,0 +1,582 @@
use super::*;
#[expect(
clippy::too_many_lines,
reason = "concurrency regression fixture keeps queued responses and observed range assertions together"
)]
#[test]
fn execute_segment_transfers_runs_planned_segments_concurrently() {
let downloader = ConcurrentProbeDownloader::new(
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 0-3/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"1234".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 0,
end_inclusive: 3,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
vec![
(
4,
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 4-7/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"5678".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 4,
end_inclusive: 7,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
),
(
8,
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "2".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 8-11/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"90ab".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 8,
end_inclusive: 11,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
),
(
12,
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 12-15/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"cdef".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 12,
end_inclusive: 15,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
),
],
Duration::from_millis(80),
);
let runtime = RuntimeConfig {
split: 4,
max_connections_per_server: 4,
max_connection_per_server: 4,
min_split_size: 4,
piece_length: 4,
..RuntimeConfig::default()
};
let session = derive_http_session(None, &StartupProfile::default());
let base_task = build_http_transfer_task(
"gid-concurrent".to_owned(),
"http://example.com/concurrent-segments.bin".to_owned(),
&session,
&runtime,
None,
);
let planned_tasks = vec![(4_u64, 7_u64), (8, 11), (12, 15)]
.into_iter()
.map(|(start, end_inclusive)| {
let mut task = base_task.clone();
task.request.range = Some(aria2_rust_pro_protocol::RangeSpec {
start,
end_inclusive: Some(end_inclusive),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
});
task.resume_state = Some(aria2_rust_pro_protocol::ResumeState {
requested_offset: start,
accepted_offset: None,
resumed: true,
});
task
})
.collect::<Vec<_>>();
let executions = execute_segment_transfers(&downloader, planned_tasks, &runtime);
assert_eq!(executions.len(), 3);
assert!(
downloader.max_concurrent_calls() >= 2,
"follow-up segment transfers should overlap in flight"
);
let recorded = downloader.recorded_requests();
assert_eq!(recorded.len(), 3);
let mut followup_ranges = recorded
.iter()
.map(|task| task.request.range)
.collect::<Vec<_>>();
followup_ranges.sort_by_key(|range| range.as_ref().map(|range| range.start));
assert_eq!(
followup_ranges,
vec![
Some(aria2_rust_pro_protocol::RangeSpec {
start: 4,
end_inclusive: Some(7),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
}),
Some(aria2_rust_pro_protocol::RangeSpec {
start: 8,
end_inclusive: Some(11),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
}),
Some(aria2_rust_pro_protocol::RangeSpec {
start: 12,
end_inclusive: Some(15),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
}),
]
);
}
#[expect(
clippy::too_many_lines,
reason = "throttling regression fixture keeps the speed-cap setup and concurrency assertions together"
)]
#[test]
fn execute_segment_transfers_throttles_parallelism_when_speed_cap_is_tight() {
let downloader = ConcurrentProbeDownloader::new(
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 0-3/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"1234".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 0,
end_inclusive: 3,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
vec![
(
4,
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 4-7/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"5678".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 4,
end_inclusive: 7,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
),
(
8,
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 8-11/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"90ab".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 8,
end_inclusive: 11,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
),
(
12,
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 12-15/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"cdef".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 12,
end_inclusive: 15,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
),
],
Duration::from_millis(80),
);
let runtime = RuntimeConfig {
split: 4,
max_connections_per_server: 4,
max_connection_per_server: 4,
min_split_size: 4,
piece_length: 4,
max_overall_download_limit: Some(4),
..RuntimeConfig::default()
};
let session = derive_http_session(None, &StartupProfile::default());
let base_task = build_http_transfer_task(
"gid-throttled".to_owned(),
"http://example.com/throttled-segments.bin".to_owned(),
&session,
&runtime,
None,
);
let planned_tasks = vec![(4_u64, 7_u64), (8, 11), (12, 15)]
.into_iter()
.map(|(start, end_inclusive)| {
let mut task = base_task.clone();
task.request.range = Some(aria2_rust_pro_protocol::RangeSpec {
start,
end_inclusive: Some(end_inclusive),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
});
task.resume_state = Some(aria2_rust_pro_protocol::ResumeState {
requested_offset: start,
accepted_offset: None,
resumed: true,
});
task
})
.collect::<Vec<_>>();
let executions = execute_segment_transfers(&downloader, planned_tasks, &runtime);
assert_eq!(executions.len(), 3);
assert_eq!(
downloader.max_concurrent_calls(),
3,
"global download caps should not collapse one download's follow-up segments into serial transfers"
);
}
#[expect(
clippy::too_many_lines,
reason = "per-download speed-cap regression keeps mirrored segment fixtures beside the serialism assertion"
)]
#[test]
fn execute_segment_transfers_respect_per_download_speed_cap() {
let downloader = ConcurrentProbeDownloader::new(
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 0-3/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"1234".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 0,
end_inclusive: 3,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
vec![
(
4,
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 4-7/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"5678".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 4,
end_inclusive: 7,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
),
(
8,
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 8-11/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"90ab".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 8,
end_inclusive: 11,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
),
(
12,
HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 12-15/16".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"cdef".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 12,
end_inclusive: 15,
total_size: Some(16),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
},
),
],
Duration::from_millis(80),
);
let runtime = RuntimeConfig {
split: 4,
max_connections_per_server: 4,
max_connection_per_server: 4,
min_split_size: 4,
piece_length: 4,
max_download_limit: Some(4),
..RuntimeConfig::default()
};
let session = derive_http_session(None, &StartupProfile::default());
let base_task = build_http_transfer_task(
"gid-throttled".to_owned(),
"http://example.com/throttled-segments.bin".to_owned(),
&session,
&runtime,
None,
);
let planned_tasks = vec![(4_u64, 7_u64), (8, 11), (12, 15)]
.into_iter()
.map(|(start, end_inclusive)| {
let mut task = base_task.clone();
task.request.range = Some(aria2_rust_pro_protocol::RangeSpec {
start,
end_inclusive: Some(end_inclusive),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
});
task.resume_state = Some(aria2_rust_pro_protocol::ResumeState {
requested_offset: start,
accepted_offset: None,
resumed: true,
});
task
})
.collect::<Vec<_>>();
let executions = execute_segment_transfers(&downloader, planned_tasks, &runtime);
assert_eq!(executions.len(), 3);
assert_eq!(
downloader.max_concurrent_calls(),
1,
"per-download speed caps should still force serial follow-up segments for one download"
);
}
@@ -0,0 +1,420 @@
use super::*;
#[expect(
clippy::too_many_lines,
reason = "segment-plan regression fixture keeps response bodies, ranges, and request assertions adjacent"
)]
#[test]
fn execute_runtime_uses_segment_plan_after_bootstrap_partial_response() {
let downloader = SequencedHttpDownloader::new(vec![
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 0-3/10".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"1234".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 0,
end_inclusive: 3,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 4-7/10".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"5678".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 4,
end_inclusive: 7,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "2".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 8-9/10".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"90".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 8,
end_inclusive: 9,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
]);
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-explicit-segments-test");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
"max-tries=3\nsplit=3\nmin-split-size=4\npiece-length=4\n",
)
.expect("config should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path.clone()),
uris: vec!["http://example.com/segment-plan.bin".to_owned()],
},
&downloader,
)
.expect("runtime should execute via explicit segments");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_completed_length, Some(10));
let recorded = downloader.recorded_requests();
let [bootstrap, first_followup, second_followup] = recorded.as_slice() else {
panic!("expected exactly three recorded requests");
};
assert_eq!(bootstrap.request.range, None);
let mut followup_ranges = [first_followup, second_followup]
.iter()
.map(|task| task.request.range)
.collect::<Vec<_>>();
followup_ranges.sort_by_key(|range| range.as_ref().map(|range| range.start));
assert_eq!(
followup_ranges,
vec![
Some(aria2_rust_pro_protocol::RangeSpec {
start: 4,
end_inclusive: Some(9),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
}),
Some(aria2_rust_pro_protocol::RangeSpec {
start: 8,
end_inclusive: Some(9),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
}),
]
);
let _ = fs::remove_file(config_path);
}
#[expect(
clippy::too_many_lines,
reason = "initial range-probe regression keeps bootstrap and follow-up range fixtures adjacent"
)]
#[test]
fn execute_runtime_uses_initial_range_probe_when_split_budget_allows_parallel_segments() {
let downloader = SequencedHttpDownloader::new(vec![
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 0-3/10".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"1234".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 0,
end_inclusive: 3,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 4-7/10".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"5678".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 4,
end_inclusive: 7,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "2".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 8-9/10".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"90".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 8,
end_inclusive: 9,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
]);
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-segment-probe-test");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
"split=3\nmax-connection-per-server=3\nmin-split-size=4\npiece-length=4\n",
)
.expect("config should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path.clone()),
uris: vec!["http://example.com/probe-plan.bin".to_owned()],
},
&downloader,
)
.expect("runtime should execute via initial range probe");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_completed_length, Some(10));
let recorded = downloader.recorded_requests();
let [probe, first_followup, second_followup] = recorded.as_slice() else {
panic!("expected exactly three recorded requests");
};
assert_eq!(
probe.request.range,
Some(aria2_rust_pro_protocol::RangeSpec {
start: 0,
end_inclusive: Some(3),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
})
);
let mut followup_ranges = [first_followup, second_followup]
.iter()
.map(|task| task.request.range)
.collect::<Vec<_>>();
followup_ranges.sort_by_key(|range| range.as_ref().map(|range| range.start));
assert_eq!(
followup_ranges,
vec![
Some(aria2_rust_pro_protocol::RangeSpec {
start: 4,
end_inclusive: Some(7),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
}),
Some(aria2_rust_pro_protocol::RangeSpec {
start: 8,
end_inclusive: Some(9),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
}),
]
);
let _ = fs::remove_file(config_path);
}
#[expect(
clippy::too_many_lines,
reason = "tiny-tail regression keeps the coalesced probe fixture next to the expected request ranges"
)]
#[test]
fn execute_runtime_coalesces_tiny_followup_tail_after_initial_probe() {
let downloader = SequencedHttpDownloader::new(vec![
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "32".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 0-31/36".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"1234567890abcdefghijklmnopqrstuv".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 0,
end_inclusive: 31,
total_size: Some(36),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 32-35/36".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(b"ghij".to_vec()),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 32,
end_inclusive: 35,
total_size: Some(36),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
}),
]);
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-tiny-tail-coalesce-test");
let _ = fs::create_dir_all(&temp_dir);
let config_path = temp_dir.join("aria2.conf");
fs::write(
&config_path,
"split=4\nmax-connection-per-server=4\nmin-split-size=4\npiece-length=4\n",
)
.expect("config should write");
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path.clone()),
uris: vec!["http://example.com/coalesced-tail.bin".to_owned()],
},
&downloader,
)
.expect("runtime should coalesce tiny follow-up tail after probe");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(report.first_completed_length, Some(36));
let recorded = downloader.recorded_requests();
let [probe, first_followup] = recorded.as_slice() else {
panic!("expected exactly two recorded requests");
};
assert_eq!(
probe.request.range,
Some(aria2_rust_pro_protocol::RangeSpec {
start: 0,
end_inclusive: Some(31),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
})
);
assert_eq!(
first_followup.request.range,
Some(aria2_rust_pro_protocol::RangeSpec {
start: 32,
end_inclusive: Some(35),
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
})
);
let _ = fs::remove_file(config_path);
}
@@ -0,0 +1,505 @@
#![doc(hidden)]
#![expect(
clippy::redundant_pub_crate,
reason = "this private transfer-resolution module shares parent-only helpers across the split CLI facade"
)]
use std::{fs, path::Path};
use aria2_rust_pro_compat::{ConfigParseError, ConfigProfile};
use aria2_rust_pro_core::RuntimeConfig;
use aria2_rust_pro_protocol::{
Downloader, FtpCommandModel, FtpConfigModel, FtpMode, FtpRequestModel, HttpResponseModel,
HttpSessionModel, MetalinkParserModel, Protocol, ResponseBody, SftpCommandModel,
SftpConfigModel, SftpRequestModel,
};
use aria2_rust_pro_rpc::{InProcessRpcDispatcher, JsonRpcRequest, RpcMeta, RpcMethod, RpcValue};
use super::{
CliError, TransferInputEntry, apply_proxy_auth_overrides, build_http_transfer_task,
classify_transfer, execute_http_transfer_with_retry, merged_profile,
metalink_entry_implied_profile, parse_bool_text, parse_csv_text, parse_protocol,
parse_proxy_text, profile_option_value, rpc_option_object,
};
/// Parsed authority and path components for FTP- and SFTP-style URIs.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct ParsedRemoteUri {
/// URI scheme such as `ftp` or `sftp`.
pub(super) scheme: String,
/// Remote hostname.
pub(super) host: String,
/// Remote service port.
pub(super) port: u16,
/// Optional username from URI user info.
pub(super) username: Option<String>,
/// Optional password from URI user info.
pub(super) password: Option<String>,
/// Canonical path component beginning with `/`.
pub(super) path: String,
}
/// Parses an FTP- or SFTP-style URI into its normalized components.
pub(super) fn parse_remote_uri(uri: &str) -> Option<ParsedRemoteUri> {
let (scheme, remainder) = uri.split_once("://")?;
let (authority, path_part) = match remainder.split_once('/') {
Some((authority, path)) => (authority, format!("/{path}")),
None => (remainder, "/".to_owned()),
};
let (user_info, host_port) = match authority.rsplit_once('@') {
Some((user_info, host_port)) => (Some(user_info), host_port),
None => (None, authority),
};
let (host, port) = match host_port.rsplit_once(':') {
Some((host, port_text)) => (host, port_text.parse().ok()?),
None if scheme.eq_ignore_ascii_case("ftp") => (host_port, 21),
None if scheme.eq_ignore_ascii_case("sftp") => (host_port, 22),
None => return None,
};
let (username, password) = match user_info.and_then(|value| value.split_once(':')) {
Some((username, password)) => (Some(username.to_owned()), Some(password.to_owned())),
None => (user_info.map(str::to_owned), None),
};
Some(ParsedRemoteUri {
scheme: scheme.to_owned(),
host: host.to_owned(),
port,
username,
password,
path: path_part,
})
}
/// Builds protocol-layer FTP config and request models for a URI.
pub(super) fn build_ftp_transfer_parts(
uri: &str,
profile: Option<&ConfigProfile>,
http_session: &HttpSessionModel,
) -> Option<(FtpConfigModel, FtpRequestModel)> {
let parsed = parse_remote_uri(uri)?;
if !parsed.scheme.eq_ignore_ascii_case("ftp") {
return None;
}
let ftp_passive = profile
.and_then(|profile| profile_option_value(profile, "ftp-pasv"))
.and_then(parse_bool_text)
.unwrap_or(true);
let ftp_mode = if ftp_passive {
FtpMode::Passive
} else {
FtpMode::Active
};
let ftp_proxy = profile.and_then(|profile| {
let bypass_hosts =
profile_option_value(profile, "no-proxy").map_or_else(Vec::new, parse_csv_text);
if let Some(proxy_text) = profile_option_value(profile, "ftp-proxy") {
let mut proxy = parse_proxy_text(proxy_text, bypass_hosts)?;
apply_proxy_auth_overrides(&mut proxy, profile, "ftp-proxy-user", "ftp-proxy-passwd");
Some(proxy)
} else if let Some(proxy_text) = profile_option_value(profile, "all-proxy") {
let mut proxy = parse_proxy_text(proxy_text, bypass_hosts)?;
apply_proxy_auth_overrides(&mut proxy, profile, "all-proxy-user", "all-proxy-passwd");
Some(proxy)
} else {
None
}
});
Some((
FtpConfigModel {
host: parsed.host,
port: parsed.port,
username: parsed.username.or_else(|| {
profile
.and_then(|profile| profile_option_value(profile, "ftp-user"))
.map(ToOwned::to_owned)
}),
password: parsed.password.or_else(|| {
profile
.and_then(|profile| profile_option_value(profile, "ftp-passwd"))
.map(ToOwned::to_owned)
}),
secure: false,
mode: ftp_mode,
initial_cwd: None,
proxy: ftp_proxy,
tls: None,
retry: http_session.retry,
},
FtpRequestModel {
command: FtpCommandModel::Retr(parsed.path.clone()),
path: Some(parsed.path),
headers: Vec::new(),
},
))
}
/// Builds protocol-layer SFTP config and request models for a URI.
pub(super) fn build_sftp_transfer_parts(
uri: &str,
http_session: &HttpSessionModel,
) -> Option<(SftpConfigModel, SftpRequestModel)> {
let parsed = parse_remote_uri(uri)?;
if !parsed.scheme.eq_ignore_ascii_case("sftp") {
return None;
}
Some((
SftpConfigModel {
host: parsed.host,
port: parsed.port,
username: parsed.username,
password: parsed.password,
private_key_path: None,
known_hosts_path: None,
strict_host_key_checking: true,
proxy: http_session.proxy.clone(),
tls: None,
retry: http_session.retry,
},
SftpRequestModel {
command: SftpCommandModel::Read {
path: parsed.path.clone(),
offset: 0,
length: u64::MAX,
},
path: Some(parsed.path),
headers: Vec::new(),
},
))
}
/// Loads a Metalink file from disk and returns executable transfer entries.
pub(super) fn parse_metalink_transfer_entries(
path: &Path,
) -> Result<Vec<TransferInputEntry>, CliError> {
let text = fs::read_to_string(path).map_err(|error| CliError::Io(error.to_string()))?;
parse_metalink_transfer_entries_from_text(&text)
}
/// Parses Metalink XML text and returns executable transfer entries.
pub(super) fn parse_metalink_transfer_entries_from_text(
text: &str,
) -> Result<Vec<TransferInputEntry>, CliError> {
let result = MetalinkParserModel::new(true, false).parse(text);
let document = result.document.ok_or_else(|| {
CliError::Io(
result
.parser
.last_error
.unwrap_or_else(|| "metalink parse failed".to_owned()),
)
})?;
let entries = aria2_rust_pro_protocol::metalink_download_plan(&document)
.into_iter()
.map(|entry| TransferInputEntry {
uris: entry.uris,
implied_profile: metalink_entry_implied_profile(
&entry.file_name,
entry.checksum.as_ref(),
),
profile: None,
})
.collect::<Vec<_>>();
if entries.is_empty() {
return Err(CliError::Io(
"metalink document contains no usable resource url".to_owned(),
));
}
Ok(entries)
}
/// Reads a streamed or inline HTTP response body into owned bytes.
pub(super) fn http_response_body_bytes(
response: &HttpResponseModel,
label: &str,
) -> Result<Vec<u8>, CliError> {
match &response.body {
ResponseBody::Empty => Ok(Vec::new()),
ResponseBody::Inline(bytes) => Ok(bytes.clone()),
ResponseBody::Streamed { temp_path, .. } => {
let Some(temp_path) = temp_path else {
return Err(CliError::Io(format!(
"{label} response missing streamed temp file"
)));
};
let bytes = fs::read(temp_path).map_err(|error| CliError::Io(error.to_string()))?;
let _ = fs::remove_file(temp_path);
Ok(bytes)
}
}
}
/// Decodes an HTTP response body into text for Metalink parsing.
pub(super) fn metalink_response_text(response: &HttpResponseModel) -> Result<String, CliError> {
let bytes = http_response_body_bytes(response, "metalink document")?;
String::from_utf8(bytes)
.map_err(|error| CliError::Io(format!("metalink document is not valid utf-8: {error}")))
}
/// Resolves one logical transfer entity, dereferencing Metalink inputs when needed.
pub(super) fn resolve_transfer_entry_with_downloader<D: Downloader + Sync>(
entry: &TransferInputEntry,
downloader: &D,
session: &HttpSessionModel,
runtime: &RuntimeConfig,
) -> Result<Vec<TransferInputEntry>, CliError> {
let Some(primary_uri) = entry.uris.first() else {
return Err(CliError::Config(ConfigParseError::InvalidDirective(
"input-file entry missing URI".to_owned(),
)));
};
let shared_profile = entry.profile.clone();
let with_shared_profile = |entries: Vec<TransferInputEntry>| {
entries
.into_iter()
.map(|resolved| TransferInputEntry {
uris: resolved.uris,
implied_profile: resolved.implied_profile,
profile: shared_profile.clone(),
})
.collect::<Vec<_>>()
};
if classify_transfer(primary_uri) != super::TransferSelection::Metalink {
return Ok(vec![entry.clone()]);
}
match parse_protocol(primary_uri) {
Some(Protocol::Http | Protocol::Https) => {
let task = build_http_transfer_task(
"metalink-bootstrap".to_owned(),
primary_uri.clone(),
session,
runtime,
None,
);
let execution = execute_http_transfer_with_retry(downloader, &task, runtime);
let Some(response) = execution.response else {
return Err(CliError::Io(format!(
"failed to fetch metalink document: {primary_uri}"
)));
};
if !(200..=299).contains(&response.status) {
return Err(CliError::Io(format!(
"failed to fetch metalink document {primary_uri}: HTTP {}",
response.status
)));
}
let text = metalink_response_text(&response)?;
parse_metalink_transfer_entries_from_text(&text).map(with_shared_profile)
}
_ => parse_metalink_transfer_entries(Path::new(primary_uri)).map(with_shared_profile),
}
}
/// Loads torrent payload bytes from a local path or supported remote transport.
pub(super) fn load_torrent_payload_bytes<D: Downloader + Sync>(
downloader: &D,
uri: &str,
profile: Option<&ConfigProfile>,
http_session: &HttpSessionModel,
derived_runtime: &RuntimeConfig,
) -> Result<Vec<u8>, CliError> {
match parse_protocol(uri) {
Some(Protocol::Http | Protocol::Https) => {
let task = build_http_transfer_task(
"torrent-bootstrap".to_owned(),
uri.to_owned(),
http_session,
derived_runtime,
profile,
);
let execution = execute_http_transfer_with_retry(downloader, &task, derived_runtime);
let Some(response) = execution.response else {
return Err(CliError::Io(format!(
"failed to fetch torrent metadata: {uri}"
)));
};
if !(200..=299).contains(&response.status) {
return Err(CliError::Io(format!(
"failed to fetch torrent metadata {uri}: HTTP {}",
response.status
)));
}
http_response_body_bytes(&response, "torrent metadata")
}
Some(Protocol::Ftp) => {
let Some((config, request)) = build_ftp_transfer_parts(uri, profile, http_session)
else {
return Err(CliError::Io(format!("failed to parse ftp uri: {uri}")));
};
let response = downloader
.start_ftp_transfer(&config, &request)
.map_err(|error| CliError::Io(error.to_string()))?;
response.data.ok_or_else(|| {
CliError::Io(format!(
"torrent metadata transfer returned no ftp payload: {uri}"
))
})
}
Some(Protocol::Sftp) => {
let Some((config, request)) = build_sftp_transfer_parts(uri, http_session) else {
return Err(CliError::Io(format!("failed to parse sftp uri: {uri}")));
};
let response = downloader
.start_sftp_transfer(&config, &request)
.map_err(|error| CliError::Io(error.to_string()))?;
response.payload.ok_or_else(|| {
CliError::Io(format!(
"torrent metadata transfer returned no sftp payload: {uri}"
))
})
}
_ => fs::read(uri)
.map_err(|error| CliError::Io(format!("failed to read torrent file {uri}: {error}"))),
}
}
/// Encodes bytes as standard base64 without pulling extra crate ownership into the CLI.
pub(super) fn encode_base64(bytes: &[u8]) -> String {
let mut encoded = String::with_capacity(bytes.len().div_ceil(3).saturating_mul(4));
for chunk in bytes.chunks(3) {
match chunk {
[b0, b1, b2] => {
let combined = (u32::from(*b0) << 16) | (u32::from(*b1) << 8) | u32::from(*b2);
encoded.push(base64_alphabet_char((combined >> 18) & 0x3f));
encoded.push(base64_alphabet_char((combined >> 12) & 0x3f));
encoded.push(base64_alphabet_char((combined >> 6) & 0x3f));
encoded.push(base64_alphabet_char(combined & 0x3f));
}
[b0, b1] => {
let combined = (u32::from(*b0) << 16) | (u32::from(*b1) << 8);
encoded.push(base64_alphabet_char((combined >> 18) & 0x3f));
encoded.push(base64_alphabet_char((combined >> 12) & 0x3f));
encoded.push(base64_alphabet_char((combined >> 6) & 0x3f));
encoded.push('=');
}
[b0] => {
let combined = u32::from(*b0) << 16;
encoded.push(base64_alphabet_char((combined >> 18) & 0x3f));
encoded.push(base64_alphabet_char((combined >> 12) & 0x3f));
encoded.push('=');
encoded.push('=');
}
[] => {}
_ => unreachable!("chunks(3) never yields slices longer than 3"),
}
}
encoded
}
/// Maps one 6-bit base64 alphabet index into its ASCII output character.
fn base64_alphabet_char(index: u32) -> char {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let normalized = u8::try_from(index).expect("base64 alphabet indices fit into u8");
ALPHABET
.get(usize::from(normalized))
.copied()
.map(char::from)
.expect("base64 alphabet index must remain within range")
}
/// Internal registration surface used when seeding dispatcher state.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum DispatcherRegistrationKind {
/// Register the entry through `aria2.addUri`.
Uri,
/// Register the entry through `aria2.addTorrent`.
Torrent,
}
/// Builds the dispatcher registration path for a resolved entry.
pub(super) fn register_resolved_entry_with_dispatcher<D: Downloader + Sync>(
dispatcher: &mut InProcessRpcDispatcher,
downloader: &D,
entry: &TransferInputEntry,
resolved_uri: &str,
profile: Option<&ConfigProfile>,
http_session: &HttpSessionModel,
derived_runtime: &RuntimeConfig,
) -> Result<(String, DispatcherRegistrationKind), CliError> {
let rpc_profile = merged_profile(entry.implied_profile.as_ref(), entry.profile.as_ref());
let registration_kind = match classify_transfer(resolved_uri) {
super::TransferSelection::Torrent => DispatcherRegistrationKind::Torrent,
_ => DispatcherRegistrationKind::Uri,
};
let gid = match registration_kind {
DispatcherRegistrationKind::Uri => {
let uris = entry
.uris
.iter()
.enumerate()
.map(|(index, uri)| {
if index == 0 {
resolved_uri.to_owned()
} else {
uri.clone()
}
})
.collect::<Vec<_>>();
let options = rpc_profile
.as_ref()
.map_or_else(Vec::new, profile_string_options);
dispatcher
.add_uri_direct_string_options(uris, options)
.map_err(|error| CliError::Rpc(error.message))?
}
DispatcherRegistrationKind::Torrent => {
let payload = load_torrent_payload_bytes(
downloader,
resolved_uri,
profile,
http_session,
derived_runtime,
)?;
let mut params = vec![RpcValue::String(encode_base64(&payload))];
if let Some(options) = rpc_option_object(rpc_profile.as_ref()) {
params.push(RpcValue::Array(Vec::new()));
params.push(options);
}
dispatcher
.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddTorrent.as_str().to_owned(),
params,
meta: RpcMeta::default(),
})
.result
.and_then(|value| match value {
RpcValue::String(gid) => Some(gid),
_ => None,
})
.ok_or_else(|| {
CliError::Rpc("dispatcher registration did not return a gid".to_owned())
})?
}
};
Ok((gid, registration_kind))
}
/// Projects profile directives into the string option shape consumed by direct URI registration.
pub(super) fn profile_string_options(profile: &ConfigProfile) -> Vec<(String, String)> {
profile
.document
.directives
.iter()
.filter_map(|directive| {
directive
.value
.as_ref()
.map(|value| (directive.name.clone(), value.clone()))
})
.collect()
}
@@ -0,0 +1,391 @@
#![doc(hidden)]
#![expect(
clippy::redundant_pub_crate,
reason = "this private transfer-runtime module shares parent-only helpers across the split CLI facade"
)]
use std::{env, time::Instant};
use aria2_rust_pro_compat::ConfigProfile;
use aria2_rust_pro_core::{DownloadStatus, RuntimeConfig};
use aria2_rust_pro_protocol::{
Downloader, HttpResponseModel, HttpSessionModel, Protocol, ReqwestTrackerTransport,
StdDhtTransport, StdTcpPeerWireTransportConnector,
};
use aria2_rust_pro_rpc::{
InProcessRpcDispatcher, JsonRpcRequest, RpcMeta, RpcMethod, RpcStatusSummary, RpcValue,
};
use super::{
CliError, build_ftp_transfer_parts, build_http_transfer_task_with_target,
build_initial_http_execution_plan, build_segment_transfer_tasks, build_sftp_transfer_parts,
http_execution_completed_via_checksum, lossless_u64_from_usize, parse_protocol,
persist_http_response_body_to_target, prepare_http_target_path, rpc_bool, rpc_u64,
};
/// Minimal BT execution plan that keeps current tracker support and future hooks aligned.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub(super) struct BtRuntimeExecutionPlan {
/// Whether the runtime exposes BitTorrent-specific state for the download.
pub(super) is_bt: bool,
/// Whether an HTTP(S) tracker is immediately runnable via the live reqwest transport.
pub(super) has_live_http_tracker: bool,
/// Whether the request still lacks metadata, which blocks later peer-wire execution.
pub(super) metadata_only: bool,
}
/// Maximum BT coordinator iterations attempted for one foreground execution pass.
const MAX_BT_RUNTIME_ROUNDS: usize = 32;
/// Maximum consecutive idle coordinator rounds tolerated before returning.
const MAX_BT_IDLE_ROUNDS: usize = 6;
/// Reads one `aria2.tellStatus` object from the dispatcher.
pub(super) fn dispatcher_status_for_gid(
dispatcher: &mut InProcessRpcDispatcher,
gid: &str,
) -> Result<std::collections::BTreeMap<String, RpcValue>, CliError> {
let response = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid.to_owned())],
meta: RpcMeta::default(),
});
let Some(RpcValue::Object(status)) = response.result else {
let message = response.error.map_or_else(
|| "tellStatus did not return a status".to_owned(),
|error| error.message,
);
return Err(CliError::Rpc(message));
};
Ok(status)
}
/// Reads the lightweight internal status summary for one tracked download.
pub(super) fn dispatcher_status_summary_for_gid(
dispatcher: &InProcessRpcDispatcher,
gid: &str,
) -> Result<RpcStatusSummary, CliError> {
dispatcher
.status_summary_for_gid(gid)
.map_err(|error| CliError::Rpc(error.message))
}
/// Converts a core download status into the canonical aria2 RPC status text.
pub(super) fn rpc_status_text(status: DownloadStatus) -> String {
status.as_rpc_status().to_owned()
}
/// Returns whether one HTTP response represents an observed terminal success.
pub(super) fn http_response_is_terminal_success(response: &HttpResponseModel) -> bool {
(200..=299).contains(&response.status)
&& response
.total_length()
.is_none_or(|total_length| response.completed_length() >= total_length)
}
/// Derives the currently runnable BT phases from a tellStatus payload.
pub(super) fn bt_runtime_execution_plan(
status: &std::collections::BTreeMap<String, RpcValue>,
) -> BtRuntimeExecutionPlan {
let has_live_http_tracker = status
.get("announceList")
.and_then(|value| match value {
RpcValue::Array(tiers) => Some(tiers.iter().any(|tier| match tier {
RpcValue::Array(trackers) => trackers.iter().any(|tracker| match tracker {
RpcValue::String(uri) => {
matches!(parse_protocol(uri), Some(Protocol::Http | Protocol::Https))
}
_ => false,
}),
_ => false,
})),
_ => None,
})
.unwrap_or(false);
BtRuntimeExecutionPlan {
is_bt: rpc_bool(status.get("isBt")).unwrap_or(false),
has_live_http_tracker,
metadata_only: rpc_bool(status.get("metadataOnly")).unwrap_or(false),
}
}
/// Compact BT progress snapshot used to detect observable runtime movement.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(super) struct BtRuntimeProgressSnapshot {
/// Downloaded payload bytes reported by `aria2.tellStatus`.
pub(super) completed_length: u64,
/// Uploaded payload bytes reported by `aria2.tellStatus`.
pub(super) upload_length: u64,
/// Active peer connection count reported by `aria2.tellStatus`.
pub(super) connections: u64,
/// Whether the session still operates in metadata-only magnet mode.
pub(super) metadata_only: bool,
/// Whether the runtime already reported terminal completion.
pub(super) complete: bool,
}
/// Extracts the BT progress counters relevant for foreground progress detection.
pub(super) fn bt_runtime_progress_snapshot(
status: &std::collections::BTreeMap<String, RpcValue>,
) -> BtRuntimeProgressSnapshot {
BtRuntimeProgressSnapshot {
completed_length: rpc_u64(status.get("completedLength")).unwrap_or(0),
upload_length: rpc_u64(status.get("uploadLength")).unwrap_or(0),
connections: rpc_u64(status.get("connections")).unwrap_or(0),
metadata_only: rpc_bool(status.get("metadataOnly")).unwrap_or(false),
complete: matches!(status.get("status"), Some(RpcValue::String(value)) if value == "complete"),
}
}
/// Returns whether a later BT snapshot shows visible progress versus the earlier one.
pub(super) const fn bt_runtime_has_progress(
before: BtRuntimeProgressSnapshot,
after: BtRuntimeProgressSnapshot,
) -> bool {
after.complete
|| after.completed_length > before.completed_length
|| after.upload_length > before.upload_length
|| after.connections > before.connections
|| after.metadata_only != before.metadata_only
}
/// Executes the currently available BT runtime phases for one registered download.
pub(super) fn execute_bt_runtime_for_gid(
dispatcher: &mut InProcessRpcDispatcher,
gid: &str,
) -> Result<(), CliError> {
let tracker_transport = ReqwestTrackerTransport::new().ok();
let dht_transport = StdDhtTransport::default();
let peer_wire_transport = StdTcpPeerWireTransportConnector::default();
let mut idle_rounds = 0_usize;
for _ in 0..MAX_BT_RUNTIME_ROUNDS {
let status = dispatcher_status_for_gid(dispatcher, gid)?;
let plan = bt_runtime_execution_plan(&status);
if !plan.is_bt {
return Ok(());
}
let before = bt_runtime_progress_snapshot(&status);
if before.complete {
break;
}
let tracker_transport_ref = tracker_transport
.as_ref()
.filter(|_| plan.has_live_http_tracker)
.map(|transport| -> &dyn aria2_rust_pro_protocol::TrackerTransport { transport });
let _ = dispatcher.drive_bt_runtime_once(
gid,
tracker_transport_ref,
Some(&dht_transport),
Some(&peer_wire_transport),
None,
);
let after_status = dispatcher_status_for_gid(dispatcher, gid)?;
let after = bt_runtime_progress_snapshot(&after_status);
if after.complete {
break;
}
if bt_runtime_has_progress(before, after) {
idle_rounds = 0;
} else {
idle_rounds = idle_rounds.saturating_add(1);
if idle_rounds >= MAX_BT_IDLE_ROUNDS {
break;
}
}
}
Ok(())
}
#[expect(
clippy::too_many_lines,
reason = "one URI execution keeps protocol dispatch, telemetry, and completion accounting in one observable flow"
)]
/// Executes one transfer URI through the in-process runtime surface.
pub(super) fn execute_transfer_for_uri<D: Downloader + Sync>(
dispatcher: &mut InProcessRpcDispatcher,
downloader: &D,
uri: &str,
gid: &str,
profile: Option<&ConfigProfile>,
http_session: &HttpSessionModel,
derived_runtime: &RuntimeConfig,
) -> Result<bool, CliError> {
match parse_protocol(uri) {
Some(Protocol::Http | Protocol::Https) => {
let timing_probe = env::var_os("ARIA2_RUST_PRO_HTTP_TIMING").is_some();
let overall_started = timing_probe.then(Instant::now);
let target_path = prepare_http_target_path(profile, uri)?;
let task = build_http_transfer_task_with_target(
gid.to_owned(),
uri.to_owned(),
http_session,
derived_runtime,
profile,
Some(target_path.clone()),
);
let mut cumulative_retry_count = 0_u32;
let mut uri_marked_complete = false;
let initial_plan_started = timing_probe.then(Instant::now);
let initial_plan =
build_initial_http_execution_plan(downloader, &task, derived_runtime);
let initial_plan_elapsed_ms = initial_plan_started
.as_ref()
.map(|started| started.elapsed().as_millis());
let bootstrap_task = initial_plan.task;
let bootstrap_execution = initial_plan.execution;
let mut planned_segment_tasks = initial_plan.planned_segments;
let _telemetry_trace = (
bootstrap_execution.retry_attempts.len(),
bootstrap_execution.planned_ranges.len(),
);
if let Some(ref response) = bootstrap_execution.response {
cumulative_retry_count =
cumulative_retry_count.saturating_add(bootstrap_execution.retry_count);
let persist_started = timing_probe.then(Instant::now);
persist_http_response_body_to_target(&target_path, &bootstrap_task, response)?;
let persist_elapsed_ms = persist_started
.as_ref()
.map(|started| started.elapsed().as_millis());
let completed_via_checksum = http_execution_completed_via_checksum(
&bootstrap_execution,
response,
profile,
uri,
&bootstrap_task,
);
let record_started = timing_probe.then(Instant::now);
dispatcher
.record_http_transfer_result(
gid,
response,
bootstrap_task.max_connections,
cumulative_retry_count,
!bootstrap_execution.checksum_observed || completed_via_checksum,
)
.map_err(|error| CliError::Rpc(error.message))?;
let record_elapsed_ms = record_started
.as_ref()
.map(|started| started.elapsed().as_millis());
if http_response_is_terminal_success(response)
&& (!bootstrap_execution.checksum_observed || completed_via_checksum)
{
uri_marked_complete = true;
}
if let Some(total_started) = overall_started.as_ref() {
eprintln!(
"http timing uri={uri} status={} initial_plan_ms={} persist_ms={} record_ms={} total_ms={} partial={} completed={} total_length={:?}",
response.status,
initial_plan_elapsed_ms.unwrap_or_default(),
persist_elapsed_ms.unwrap_or_default(),
record_elapsed_ms.unwrap_or_default(),
total_started.elapsed().as_millis(),
response.partial_content,
response.completed_length(),
response.total_length(),
);
}
let needs_segment_followups = response.partial_content
&& response
.total_length()
.is_some_and(|total| response.completed_length() < total);
if needs_segment_followups {
if planned_segment_tasks.is_empty() {
let group = dispatcher
.prepare_http_download(gid)
.map_err(|error| CliError::Rpc(error.message))?;
planned_segment_tasks = build_segment_transfer_tasks(&task, &group);
}
let segment_executions = super::execute_segment_transfers(
downloader,
planned_segment_tasks,
derived_runtime,
);
for (planned_task, execution) in segment_executions {
if let Some(ref response) = execution.response {
cumulative_retry_count =
cumulative_retry_count.saturating_add(execution.retry_count);
persist_http_response_body_to_target(
&target_path,
&planned_task,
response,
)?;
let completed_via_checksum = http_execution_completed_via_checksum(
&execution,
response,
profile,
uri,
&planned_task,
);
dispatcher
.record_http_transfer_result(
gid,
response,
bootstrap_task.max_connections,
cumulative_retry_count,
!execution.checksum_observed || completed_via_checksum,
)
.map_err(|error| CliError::Rpc(error.message))?;
if http_response_is_terminal_success(response)
&& (!execution.checksum_observed || completed_via_checksum)
{
uri_marked_complete = true;
}
}
}
}
}
Ok(uri_marked_complete)
}
Some(Protocol::Ftp) => {
let Some((config, request)) = build_ftp_transfer_parts(uri, profile, http_session)
else {
return Err(CliError::Io(format!("failed to parse ftp uri: {uri}")));
};
let response = downloader
.start_ftp_transfer(&config, &request)
.map_err(|error| CliError::Io(error.to_string()))?;
let payload_len = response
.data
.as_ref()
.map_or(0_u64, |data| lossless_u64_from_usize(data.len()));
dispatcher
.record_transfer_result(gid, payload_len, payload_len, 1, response.transferable, 0)
.map_err(|error| CliError::Rpc(error.message))?;
Ok(response.transferable)
}
Some(Protocol::Sftp) => {
let Some((config, request)) = build_sftp_transfer_parts(uri, http_session) else {
return Err(CliError::Io(format!("failed to parse sftp uri: {uri}")));
};
let response = downloader
.start_sftp_transfer(&config, &request)
.map_err(|error| CliError::Io(error.to_string()))?;
let payload_len = response
.payload
.as_ref()
.map_or(0_u64, |payload| lossless_u64_from_usize(payload.len()));
dispatcher
.record_transfer_result(
gid,
payload_len,
payload_len,
1,
response.transferable && response.ok,
0,
)
.map_err(|error| CliError::Rpc(error.message))?;
Ok(response.transferable && response.ok)
}
_ => Ok(false),
}
}
+277
View File
@@ -0,0 +1,277 @@
#![expect(
clippy::redundant_pub_crate,
reason = "this private CLI type hub intentionally reuses the split crate surface and keeps parent-visible types local to the crate API façade"
)]
use super::{ConfigParseError, ConfigProfile, HttpSessionModel, PathBuf, RuntimeConfig};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
/// Selects the top-level process mode implied by parsed CLI options.
pub enum RuntimeMode {
/// Execute downloads in the foreground process.
Foreground,
/// Start the RPC daemon after applying daemon-specific flags.
Daemon,
/// Start only the RPC daemon surface without foreground transfer output.
RpcOnly,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
/// Classifies a user-supplied transfer input by its visible surface.
pub enum TransferSelection {
/// A plain URI-style transfer input.
Uri,
/// A `.torrent` file or URL.
Torrent,
/// A Metalink document or file path.
Metalink,
/// A `BitTorrent` magnet URI.
Magnet,
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// Captures RPC daemon launch settings derived from startup flags.
pub struct RpcLaunchConfig {
/// Whether the RPC server should be enabled.
pub enabled: bool,
/// Host/IP the RPC listener should bind to.
pub listen_host: String,
/// TCP port the RPC listener should bind to.
pub listen_port: u16,
/// Optional shared-secret token accepted by the RPC surface.
pub secret: Option<String>,
/// HTTP path exposed by the RPC listener.
pub path: String,
}
impl Default for RpcLaunchConfig {
fn default() -> Self {
Self {
enabled: false,
listen_host: "127.0.0.1".to_owned(),
listen_port: 6800,
secret: None,
path: "/jsonrpc".to_owned(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// Normalized startup flags that shape execution after argument parsing.
pub struct StartupProfile {
/// Requested top-level runtime mode.
pub mode: RuntimeMode,
/// RPC launch overrides collected from CLI flags.
pub rpc: RpcLaunchConfig,
/// Whether daemonization was explicitly requested.
pub daemonize: bool,
/// Whether the invocation should validate config only.
pub dry_run: bool,
}
impl Default for StartupProfile {
fn default() -> Self {
Self {
mode: RuntimeMode::Foreground,
rpc: RpcLaunchConfig::default(),
daemonize: false,
dry_run: false,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// Full CLI parse result, including the selected invocation and startup flags.
pub struct ParsedArguments {
/// High-level action requested by the user.
pub invocation: Invocation,
/// Startup modifiers that further shape execution.
pub profile: StartupProfile,
/// CLI-originated compat directives that should override config-file values.
pub cli_profile: Option<ConfigProfile>,
/// Ordered CLI transfer sources so mixed positional URIs and `--input-file`
/// entries can preserve argv order.
pub(crate) cli_transfer_sources: Vec<CliTransferSource>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// One transfer source observed directly on the command line.
pub(crate) enum CliTransferSource {
/// A positional URI-like input.
Uri(String),
/// An input-file reference that should expand into one or more entities.
InputFile(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// Decides which high-level entrypoint should handle a parsed invocation.
pub enum CommandSurface {
/// Execute the invocation in the foreground CLI flow.
Foreground(Invocation),
/// Launch the RPC daemon with optional pre-seeded inputs.
RpcDaemon {
/// Optional config path to load before serving RPC.
config_path: Option<PathBuf>,
/// Inputs that should be registered before the daemon starts serving.
inputs: Vec<String>,
},
/// Print the version banner.
PrintVersion,
/// Print help text, optionally filtered by a query term.
PrintHelp {
/// Optional help topic or filter string.
query: Option<String>,
},
/// Validate the selected config file without starting transfers.
ValidateConfig {
/// Config file to validate.
config_path: PathBuf,
/// Whether strict parsing should be used.
strict: bool,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// High-level CLI invocations supported by the binary.
pub enum Invocation {
/// Print version information and exit.
Version,
/// Print help output and exit.
Help {
/// Optional help query for filtered help output.
query: Option<String>,
},
/// Execute or stage one or more transfer inputs.
Run {
/// Optional config file path supplied on the command line.
config_path: Option<PathBuf>,
/// Ordered transfer inputs supplied on the command line.
uris: Vec<String>,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// Errors surfaced by CLI parsing and execution.
pub enum CliError {
/// An unsupported flag or malformed option was provided.
UnknownOption(String),
/// A flag requiring a value was not followed by one.
MissingValue(String),
/// An OS argument could not be converted to UTF-8.
InvalidUtf8Argument,
/// Config parsing failed.
Config(ConfigParseError),
/// Local I/O failed.
Io(String),
/// The in-process RPC/runtime layer returned an application error.
Rpc(String),
}
impl std::fmt::Display for CliError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownOption(option) => write!(f, "unknown option: {option}"),
Self::MissingValue(option) => write!(f, "missing value for {option}"),
Self::InvalidUtf8Argument => f.write_str("invalid utf-8 in command-line argument"),
Self::Config(error) => write!(f, "{error}"),
Self::Io(error) | Self::Rpc(error) => write!(f, "{error}"),
}
}
}
impl std::error::Error for CliError {}
#[derive(Clone, Debug, Eq, PartialEq)]
/// Summarizes a parsed config file that was loaded by the CLI.
pub struct ConfigLoadReport {
/// Source path of the loaded config file.
pub path: PathBuf,
/// Number of directives accepted by the selected parser mode.
pub directive_count: usize,
/// Whether strict parsing was enabled.
pub strict: bool,
/// Normalized config profile projected for downstream runtime use.
pub profile: ConfigProfile,
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// Small compatibility snapshot exposed for documentation and smoke checks.
pub struct CompatibilitySnapshot {
/// Version text rendered by the CLI surface.
pub version_banner: String,
/// Number of help sections currently exposed.
pub help_sections: usize,
/// Number of tracked protocol entries in the compatibility ledger.
pub tracked_protocol_count: usize,
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// Structured execution summary returned by runtime-oriented entrypoints.
pub struct RuntimeReport {
/// Number of URI-like inputs accepted by the invocation.
pub accepted_uri_count: usize,
/// Number of downloads registered in the dispatcher/runtime.
pub tracked_download_count: usize,
/// Number of downloads observed as complete during execution.
pub completed_download_count: usize,
/// First registered GID, when one exists.
pub first_gid: Option<String>,
/// First visible status from `aria2.tellStatus`, when available.
pub first_status: Option<String>,
/// First visible total length from `aria2.tellStatus`, when available.
pub first_total_length: Option<u64>,
/// First visible completed length from `aria2.tellStatus`, when available.
pub first_completed_length: Option<u64>,
/// First visible connection count from `aria2.tellStatus`, when available.
pub first_connections: Option<u32>,
/// Recognized transfer schemes extracted from the provided inputs.
pub recognized_schemes: Vec<String>,
/// High-level transfer classifications derived from the inputs.
pub transfer_kinds: Vec<TransferSelection>,
/// Loaded config summary, when a config file participated in execution.
pub config_report: Option<ConfigLoadReport>,
/// Runtime config projected from the startup profile and config.
pub derived_runtime: RuntimeConfig,
/// HTTP session projected from the startup profile and config.
pub http_session: HttpSessionModel,
/// Major version of the control-file format surfaced by storage.
pub control_file_version_major: u16,
/// First visible `BitTorrent` status projection, when available.
pub first_bt_status: Option<BtStatusReport>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// One logical transfer entity after expanding CLI arguments and input files.
pub(crate) struct TransferInputEntry {
/// Ordered URI candidates for the entity. Multiple entries represent mirrors.
pub(crate) uris: Vec<String>,
/// Implied per-download defaults synthesized from the source document.
pub(crate) implied_profile: Option<ConfigProfile>,
/// Optional per-entry overrides originating from input-file indentation blocks.
pub(crate) profile: Option<ConfigProfile>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
/// CLI-facing projection of BitTorrent-specific tellStatus fields.
pub struct BtStatusReport {
/// Whether the download is BitTorrent-backed.
pub is_bt: Option<bool>,
/// Whether the download still represents metadata-only state.
pub metadata_only: Option<bool>,
/// Canonical magnet URI, when available.
pub magnet_uri: Option<String>,
/// Number of announce-list tiers surfaced by the runtime.
pub announce_list_tier_count: Option<usize>,
/// Whether the download is currently seeding.
pub seeder: Option<bool>,
/// Current visible seeder count.
pub num_seeders: Option<u64>,
/// Current share ratio string.
pub share_ratio: Option<String>,
/// In-progress share ratio projection.
pub share_ratio_progress: Option<String>,
/// Remaining share ratio projection.
pub share_ratio_remaining: Option<String>,
/// Current share time in seconds.
pub share_time: Option<u64>,
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "aria2-rust-pro-compat"
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
[lib]
name = "aria2_rust_pro_compat"
path = "src/lib.rs"
[lints]
workspace = true
+137
View File
@@ -0,0 +1,137 @@
//! Compatibility inventory and baseline ledger helpers.
use crate::options::{OPTION_SPECS, OptionFamily, OptionSource, OptionStatus};
/// Protocols that the compat layer treats as part of the required surface.
pub const REQUIRED_PROTOCOLS: &[&str] = &[
"cli",
"config",
"json-rpc",
"xml-rpc",
"http",
"https",
"ftp",
"sftp",
"metalink",
"bittorrent",
"magnet",
"docker",
];
/// Compatibility depth for a surfaced feature.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CompatLevel {
/// The feature exists at the API or metadata layer.
Surface,
/// The feature is expected to match legacy behavior.
Behavioral,
/// The feature is expected to be effectively identical to the baseline.
StrictEquivalent,
}
/// Top-level feature areas tracked by the compat ledger.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FeatureSurface {
/// Command-line and config option handling.
Option,
/// Config-file loading and normalization.
Config,
/// RPC method and field compatibility.
Rpc,
/// BitTorrent-related surface area.
Bt,
/// Metalink-related surface area.
Metalink,
/// Session import or export semantics.
Session,
/// Input-file parsing and expansion behavior.
InputFile,
/// Error reporting and mapping behavior.
ErrorMap,
/// Help text and user-facing documentation outputs.
HelpText,
}
/// One row in the compatibility inventory.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CompatLedgerEntry {
/// Canonical item name.
pub name: &'static str,
/// Feature area where the item belongs.
pub surface: FeatureSurface,
/// Expected depth of compatibility for the item.
pub level: CompatLevel,
/// Current implementation status for the item.
pub status: OptionStatus,
/// Short human-readable note describing the item.
pub note: &'static str,
}
/// Free-form compatibility note attached to the ledger.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CompatNote {
/// Stable identifier for the note.
pub id: &'static str,
/// User-facing note text.
pub text: &'static str,
}
/// Full compatibility ledger snapshot.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CompatLedger {
/// Itemized compatibility entries.
pub entries: Vec<CompatLedgerEntry>,
/// Additional notes that summarize current caveats.
pub notes: Vec<CompatNote>,
}
/// Returns the static compatibility notes for the current rewrite snapshot.
#[must_use]
pub fn compatibility_notes() -> Vec<CompatNote> {
vec![
CompatNote {
id: "rapid-rewrite",
text: "Public compatibility surfaces are scaffolded for follow-up behavioral parity.",
},
CompatNote {
id: "bt-options",
text: "BT option family exists with metadata and parser stubs.",
},
CompatNote {
id: "metalink-options",
text: "Metalink option family exists with metadata and parser stubs.",
},
]
}
/// Builds the current compatibility ledger from the option registry.
#[must_use]
pub fn compat_ledger() -> CompatLedger {
let mut entries = Vec::new();
for spec in OPTION_SPECS.iter() {
let surface = match spec.metadata.family {
OptionFamily::Bt => FeatureSurface::Bt,
OptionFamily::Metalink => FeatureSurface::Metalink,
OptionFamily::Rpc => FeatureSurface::Rpc,
OptionFamily::Session => FeatureSurface::Session,
OptionFamily::Input => FeatureSurface::InputFile,
_ => FeatureSurface::Option,
};
let level = match spec.source {
OptionSource::Original => CompatLevel::Behavioral,
OptionSource::Pro
| OptionSource::CompatibilityAlias
| OptionSource::RpcAlias
| OptionSource::Experimental => CompatLevel::Surface,
};
entries.push(CompatLedgerEntry {
name: spec.name,
surface,
level,
status: spec.status,
note: spec.metadata.compatibility_note,
});
}
CompatLedger {
entries,
notes: compatibility_notes(),
}
}
+396
View File
@@ -0,0 +1,396 @@
//! Config parsing and normalization helpers for aria2-style option files.
use crate::options::{OptionScope, option_spec, reserved_option_names};
/// Where a compat config document originated.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConfigLocationKind {
/// The document was loaded from a file on disk.
File,
/// The document was provided inline as raw text.
Inline,
/// The document originated from an RPC payload.
Rpc,
/// The document originated from environment variables.
Env,
/// The document originated from CLI arguments.
Cli,
}
/// Scope inferred for a config document or directive set.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConfigScope {
/// Only global options are expected.
Global,
/// Only per-download options are expected.
PerDownload,
/// The document can contain both global and per-download options.
Mixed,
}
/// Source category for parsed config data.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConfigSource {
/// User-authored config file such as `aria2.conf`.
UserConfig,
/// Persisted session file content.
SessionFile,
/// Input-file content that expands downloads.
InputFile,
/// Runtime overrides originating from transient inputs.
RuntimeOverride,
/// Named profile content.
Profile,
}
/// A single `name=value` config directive.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfigDirective {
/// Directive name after canonicalization.
pub name: String,
/// Optional directive value.
pub value: Option<String>,
}
/// Parsed config directives with source metadata.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfigAst {
/// Ordered directives found in the source input.
pub directives: Vec<ConfigDirective>,
/// Origin category for the parsed directives.
pub source: ConfigSource,
}
/// Parsed config document with location metadata.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfigDocument {
/// Ordered directives found in the document.
pub directives: Vec<ConfigDirective>,
/// Where the document was loaded from.
pub location: ConfigLocationKind,
/// Scope classification for the document.
pub scope: ConfigScope,
}
/// Session save/load compatibility metadata.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SessionFileModel {
/// Path where the session file is stored.
pub session_path: String,
/// Optional input file associated with the session.
pub input_path: Option<String>,
/// Autosave interval for session persistence.
pub autosave_interval_secs: u64,
}
/// Named profile loaded through compat config handling.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfigProfile {
/// Profile name.
pub name: String,
/// Source category for the profile.
pub source: ConfigSource,
/// Parsed profile document.
pub document: ConfigDocument,
}
/// Errors produced while parsing compat config input.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ConfigParseError {
/// A line could not be parsed as a valid directive.
InvalidDirective(String),
/// A directive that requires a value omitted one.
MissingValue(String),
/// A directive named an unknown option.
UnknownOption(String),
/// A directive used an option name reserved for internal use.
ReservedOption(String),
}
impl std::fmt::Display for ConfigParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidDirective(line) => write!(f, "invalid config directive: {line}"),
Self::MissingValue(name) => write!(f, "missing value for option: {name}"),
Self::UnknownOption(name) => write!(f, "unknown option: {name}"),
Self::ReservedOption(name) => write!(f, "reserved option name: {name}"),
}
}
}
impl std::error::Error for ConfigParseError {}
/// Canonicalizes a config option name through the option registry.
fn canonicalize_option_name(name: &str) -> String {
let name = normalize_option_token(name);
option_spec(name).map_or_else(|| name.to_owned(), |spec| spec.name.to_owned())
}
/// Removes a UTF-8 byte-order mark when one prefixes a text fragment.
fn strip_utf8_bom(text: &str) -> &str {
text.strip_prefix('\u{feff}').unwrap_or(text)
}
/// Normalizes a CLI- or config-style option token to its raw name form.
fn normalize_option_token(name: &str) -> &str {
let name = strip_utf8_bom(name.trim());
name.strip_prefix("--").unwrap_or_else(|| {
name.strip_prefix('-')
.filter(|value| !value.is_empty())
.map_or(name, |name| name)
})
}
/// Removes trailing comments that are safely separated from a value.
fn strip_safe_trailing_comment(value: &str) -> &str {
for (index, ch) in value.char_indices() {
let Some(prefix) = value.get(..index) else {
continue;
};
if (ch == '#' || ch == ';') && prefix.chars().next_back().is_some_and(char::is_whitespace) {
return prefix.trim_end();
}
}
value.trim()
}
/// Parses a single aria2-style config line, ignoring blank lines and comments.
///
/// # Errors
///
/// Returns [`ConfigParseError`] when the line is malformed or omits a required value.
pub fn parse_config_line(line: &str) -> Result<Option<ConfigDirective>, ConfigParseError> {
let trimmed = strip_utf8_bom(line).trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
return Ok(None);
}
let (name, value) = trimmed
.split_once('=')
.ok_or_else(|| ConfigParseError::InvalidDirective(trimmed.to_owned()))?;
let name = normalize_option_token(name);
if name.is_empty() {
return Err(ConfigParseError::InvalidDirective(trimmed.to_owned()));
}
let value = strip_safe_trailing_comment(value);
if value.is_empty() {
return Err(ConfigParseError::MissingValue(name.to_owned()));
}
Ok(Some(ConfigDirective {
name: canonicalize_option_name(name),
value: Some(value.to_owned()),
}))
}
/// Parses a single config line and enforces the known-option / non-reserved subset.
///
/// # Errors
///
/// Returns [`ConfigParseError`] when the line is malformed, reserved, or names an unknown option.
pub fn parse_config_line_strict(line: &str) -> Result<Option<ConfigDirective>, ConfigParseError> {
let directive = parse_config_line(line)?;
if let Some(ref d) = directive {
if reserved_option_names().iter().any(|r| r.name == d.name) {
return Err(ConfigParseError::ReservedOption(d.name.clone()));
}
if option_spec(d.name.as_str()).is_none() {
return Err(ConfigParseError::UnknownOption(d.name.clone()));
}
}
Ok(directive)
}
/// Parses a full config document using strict option validation.
///
/// # Errors
///
/// Returns [`ConfigParseError`] when any non-comment line is malformed, reserved, or unknown.
pub fn parse_config(text: &str) -> Result<Vec<ConfigDirective>, ConfigParseError> {
let mut directives = Vec::new();
for line in text.lines() {
if let Some(d) = parse_config_line_strict(line)? {
directives.push(d);
}
}
Ok(directives)
}
/// Parses a full config document while tolerating unknown options.
///
/// # Errors
///
/// Returns [`ConfigParseError`] when any non-comment line is malformed or omits a required value.
pub fn parse_config_lenient(text: &str) -> Result<Vec<ConfigDirective>, ConfigParseError> {
let mut directives = Vec::new();
for line in text.lines() {
if let Some(d) = parse_config_line(line)? {
directives.push(d);
}
}
Ok(directives)
}
/// Infers config scope for a named option when the option is known.
#[must_use]
pub fn infer_scope(name: &str) -> Option<ConfigScope> {
option_spec(name).map(|s| match s.metadata.scope {
OptionScope::Global => ConfigScope::Global,
OptionScope::PerDownload => ConfigScope::PerDownload,
OptionScope::Both => ConfigScope::Mixed,
})
}
/// Returns a document whose directive names have been canonicalized.
#[must_use]
pub fn normalize_document(mut document: ConfigDocument) -> ConfigDocument {
for directive in &mut document.directives {
directive.name = canonicalize_option_name(&directive.name);
}
document
}
/// Loads an inline named profile using lenient parsing semantics.
///
/// # Errors
///
/// Returns [`ConfigParseError`] when the profile text contains malformed directives.
pub fn load_profile(name: &str, text: &str) -> Result<ConfigProfile, ConfigParseError> {
let directives = parse_config_lenient(text)?;
Ok(ConfigProfile {
name: name.to_owned(),
source: ConfigSource::Profile,
document: ConfigDocument {
directives,
location: ConfigLocationKind::Inline,
scope: ConfigScope::Mixed,
},
})
}
#[cfg(test)]
mod tests {
use super::{
ConfigDirective, ConfigDocument, ConfigLocationKind, ConfigParseError, ConfigScope,
ConfigSource, load_profile, normalize_document, parse_config_line,
parse_config_line_strict,
};
#[test]
fn parse_config_line_strict_canonicalizes_known_alias_names() {
let directive = parse_config_line_strict("http-want-digest=true")
.expect("strict alias parse should succeed")
.expect("directive should be present");
assert_eq!(directive.name, "no-want-digest-header");
assert_eq!(directive.value.as_deref(), Some("true"));
}
#[test]
fn parse_config_line_lenient_canonicalizes_known_alias_names() {
let directive = parse_config_line("http-want-digest=true")
.expect("lenient alias parse should succeed")
.expect("directive should be present");
assert_eq!(directive.name, "no-want-digest-header");
assert_eq!(directive.value.as_deref(), Some("true"));
}
#[test]
fn load_profile_keeps_profile_metadata_while_canonicalizing_known_aliases() {
let profile = load_profile(
"demo",
"# comment\nhttp-want-digest=true\nrpc-listen-all=true\n",
)
.expect("profile should load");
assert_eq!(profile.name, "demo");
assert_eq!(profile.source, ConfigSource::Profile);
assert_eq!(profile.document.location, ConfigLocationKind::Inline);
assert_eq!(profile.document.scope, ConfigScope::Mixed);
let [first, second] = profile.document.directives.as_slice() else {
panic!("profile should contain exactly two directives");
};
assert_eq!(first.name, "no-want-digest-header");
assert_eq!(second.name, "rpc-listen-all");
}
#[test]
fn parse_config_line_strict_still_rejects_unknown_options() {
let error = parse_config_line_strict("not-a-real-option=true")
.expect_err("unknown option should still be rejected");
assert_eq!(
error,
ConfigParseError::UnknownOption("not-a-real-option".to_owned())
);
}
#[test]
fn parse_config_line_accepts_bom_and_cli_style_option_spellings() {
let parameterized = parse_config_line_strict("\u{feff}--parameterized-uri=true")
.expect("long CLI spelling should parse")
.expect("directive should be present");
assert_eq!(parameterized.name, "parameterized-uri");
assert_eq!(parameterized.value.as_deref(), Some("true"));
let remote_time = parse_config_line_strict("-R=true")
.expect("short CLI alias should parse")
.expect("directive should be present");
assert_eq!(remote_time.name, "remote-time");
assert_eq!(remote_time.value.as_deref(), Some("true"));
}
#[test]
fn parse_config_line_strips_safe_trailing_comments_but_preserves_uri_fragments() {
let referer = parse_config_line_strict(
"referer=http://example.invalid/download#frag # copied note",
)
.expect("referer with fragment should parse")
.expect("directive should be present");
assert_eq!(referer.name, "referer");
assert_eq!(
referer.value.as_deref(),
Some("http://example.invalid/download#frag")
);
let tracker = parse_config_line("bt-tracker=udp://tracker.invalid:80/announce ; mirror")
.expect("tracker line should parse")
.expect("directive should be present");
assert_eq!(tracker.name, "bt-tracker");
assert_eq!(
tracker.value.as_deref(),
Some("udp://tracker.invalid:80/announce")
);
}
#[test]
fn load_profile_canonicalizes_extended_cli_spellings_and_comments() {
let profile = load_profile(
"compat",
"\u{feff}--select-file=1-3,5 # keep files\n-R=true\n",
)
.expect("profile should load");
let [first, second] = profile.document.directives.as_slice() else {
panic!("profile should contain exactly two directives");
};
assert_eq!(first.name, "select-file");
assert_eq!(first.value.as_deref(), Some("1-3,5"));
assert_eq!(second.name, "remote-time");
assert_eq!(second.value.as_deref(), Some("true"));
}
#[test]
fn normalize_document_canonicalizes_cli_style_and_bom_prefixed_names() {
let normalized = normalize_document(ConfigDocument {
directives: vec![
ConfigDirective {
name: "\u{feff}--parameterized-uri".to_owned(),
value: Some("true".to_owned()),
},
ConfigDirective {
name: "-R".to_owned(),
value: Some("true".to_owned()),
},
],
location: ConfigLocationKind::Inline,
scope: ConfigScope::Mixed,
});
let [first, second] = normalized.directives.as_slice() else {
panic!("normalized document should contain exactly two directives");
};
assert_eq!(first.name, "parameterized-uri");
assert_eq!(second.name, "remote-time");
}
}
+427
View File
@@ -0,0 +1,427 @@
//! Help-text and version-banner rendering for the compat surface.
use std::fmt::Write as _;
use crate::{
BASELINE_COMMIT, PRODUCT_NAME, VERSION,
options::{OptionKind, live_option_specs},
version_line,
};
/// Usage block shown at the top of compat help output.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UsageSection {
/// Section title or identifier.
pub title: &'static str,
/// Ordered lines rendered in the section.
pub lines: Vec<String>,
}
/// Named help section containing pre-rendered blocks.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HelpSection {
/// Section heading.
pub name: &'static str,
/// Pre-rendered blocks within the section.
pub body: Vec<String>,
}
/// Full help document returned by compat helpers.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HelpDocument {
/// Usage sections rendered before option details.
pub usage: Vec<UsageSection>,
/// Detail sections rendered after usage.
pub sections: Vec<HelpSection>,
}
/// Query selectors accepted by the compat help surface.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum HelpQuery {
/// Request all help entries.
All,
/// Request entries matching a help tag.
Tag(String),
/// Request entries matching a keyword search.
Keyword(String),
}
/// Internal rendered representation of a help option entry.
#[derive(Clone, Debug, Eq, PartialEq)]
struct HelpOptionEntry {
/// Combined CLI switch spellings.
switches: String,
/// User-facing description line.
description: String,
/// Optional human-readable value hints.
possible_values: Option<String>,
/// Optional default value summary.
default_value: Option<String>,
/// Help tags associated with the entry.
tags: Vec<&'static str>,
}
/// Help tags recognized by the compat help renderer.
const ALL_HELP_TAGS: &[&str] = &[
"#basic",
"#advanced",
"#http",
"#https",
"#ftp",
"#metalink",
"#bittorrent",
"#cookie",
"#hook",
"#file",
"#rpc",
"#checksum",
"#experimental",
"#deprecated",
"#help",
"#all",
];
/// Returns the top-level usage section rendered by compat help.
#[must_use]
pub fn usage_sections() -> Vec<UsageSection> {
vec![UsageSection {
title: "main",
lines: vec!["aria2c [OPTIONS] [URI | MAGNET | TORRENT_FILE | METALINK_FILE]...".to_owned()],
}]
}
/// Returns the default help sections for the compat help output.
#[must_use]
pub fn help_sections() -> Vec<HelpSection> {
vec![HelpSection {
name: "Options",
body: render_option_entries(&option_entries()),
}]
}
/// Returns manpage metadata fields exposed by the compat help layer.
#[must_use]
pub fn manpage_metadata() -> Vec<(String, String)> {
vec![
("NAME".to_owned(), PRODUCT_NAME.to_owned()),
("VERSION".to_owned(), version_line()),
("BASELINE_COMMIT".to_owned(), BASELINE_COMMIT.to_owned()),
(
"IMPLEMENTED_OPTION_COUNT".to_owned(),
live_option_specs().len().to_string(),
),
]
}
/// Returns the aria2-style version banner for the compat CLI.
#[must_use]
pub fn cli_version_text() -> String {
let lines = vec![
"aria2 version 1.37.0".to_owned(),
format!("Rust rewrite package: {PRODUCT_NAME} {VERSION}"),
format!("Compatibility baseline: {BASELINE_COMMIT}"),
String::new(),
"** Configuration **".to_owned(),
"Enabled Features: Async DNS, BitTorrent, GZip, HTTPS, Message Digest, Metalink, XML-RPC, SFTP".to_owned(),
"Hash Algorithms: sha-1, sha-224, sha-256, sha-384, sha-512, md5, adler32".to_owned(),
"Libraries: tokio, reqwest, rustls, quick-xml".to_owned(),
format!(
"System: {} ({})",
std::env::consts::OS,
std::env::consts::ARCH
),
String::new(),
"Report bugs to https://github.com/aria2/aria2/issues".to_owned(),
"Visit https://aria2.github.io/".to_owned(),
];
lines.join("\n")
}
/// Returns the default compatibility help text.
#[must_use]
pub fn compatibility_help_text() -> String {
help_text()
}
/// Returns the default rendered help text.
#[must_use]
pub fn help_text() -> String {
help_text_for_query(None)
}
/// Returns rendered help text filtered by an optional query.
#[must_use]
pub fn help_text_for_query(query: Option<&str>) -> String {
render_help(
&HelpDocument {
usage: usage_sections(),
sections: help_sections_for_query(query),
},
query.and_then(parse_help_query),
)
}
/// Builds the internal help-entry list from built-ins and option specs.
fn option_entries() -> Vec<HelpOptionEntry> {
let mut entries = vec![
HelpOptionEntry {
switches: "-v, --version".to_owned(),
description: "Print the version number and exit.".to_owned(),
possible_values: None,
default_value: None,
tags: vec!["#basic"],
},
HelpOptionEntry {
switches: "-h, --help[=TAG|KEYWORD]".to_owned(),
description: "Print usage and exit.".to_owned(),
possible_values: Some(ALL_HELP_TAGS.join(", ")),
default_value: Some("#basic".to_owned()),
tags: vec!["#basic", "#help"],
},
];
entries.extend(live_option_specs().into_iter().map(|spec| HelpOptionEntry {
switches: spec.help_synopsis(),
description: spec.metadata.compatibility_note.to_owned(),
possible_values: possible_values(spec.kind, spec.value_hint(), spec.metadata.validator),
default_value: (!spec.default_value.is_empty()).then(|| spec.default_value.to_owned()),
tags: help_tags_for_option(spec.name, spec.metadata.family, spec.metadata.tags),
}));
entries
}
/// Builds help sections filtered by a parsed query.
fn help_sections_for_query(query: Option<&str>) -> Vec<HelpSection> {
let entries = option_entries();
let filtered = match query.and_then(parse_help_query) {
None | Some(HelpQuery::All) => entries,
Some(HelpQuery::Tag(tag)) => entries
.into_iter()
.filter(|entry| entry.tags.iter().any(|value| *value == tag))
.collect(),
Some(HelpQuery::Keyword(keyword)) => {
let needle = keyword.to_ascii_lowercase();
entries
.into_iter()
.filter(|entry| {
entry.switches.to_ascii_lowercase().contains(&needle)
|| entry.description.to_ascii_lowercase().contains(&needle)
})
.collect()
}
};
vec![HelpSection {
name: "Options",
body: render_option_entries(&filtered),
}]
}
/// Parses a user-facing help query string.
fn parse_help_query(query: &str) -> Option<HelpQuery> {
let trimmed = query.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.eq_ignore_ascii_case("#all") {
return Some(HelpQuery::All);
}
if trimmed.starts_with('#') {
return Some(HelpQuery::Tag(trimmed.to_ascii_lowercase()));
}
Some(HelpQuery::Keyword(trimmed.to_ascii_lowercase()))
}
/// Computes human-readable possible values for a help entry.
fn possible_values(kind: OptionKind, value_hint: &str, validator: &str) -> Option<String> {
match kind {
OptionKind::Bool => Some("true, false".to_owned()),
OptionKind::Path | OptionKind::Text if validator == "any" => None,
OptionKind::Path if validator == "path" || validator == "non-empty path" => {
Some("/path/to/file".to_owned())
}
OptionKind::Text if matches!(value_hint, "FILE" | "COMMAND" | "URI" | "HEADER") => {
Some(value_hint.to_owned())
}
OptionKind::List if value_hint == "URI,..." => Some("URI,...".to_owned()),
OptionKind::List if value_hint == "HOST,..." => Some("HOST,...".to_owned()),
_ if validator.is_empty() || validator == "any" => None,
_ if value_hint == "VALUE" => Some(validator.to_owned()),
_ => Some(value_hint.to_owned()),
}
}
/// Maps option metadata to help tags used by filtered help output.
fn help_tags_for_option(
name: &str,
family: crate::options::OptionFamily,
extra_tags: &[crate::options::OptionTag],
) -> Vec<&'static str> {
let mut tags = vec!["#basic"];
match family {
crate::options::OptionFamily::Core
| crate::options::OptionFamily::Input
| crate::options::OptionFamily::Session => {}
crate::options::OptionFamily::Http => tags.push("#http"),
crate::options::OptionFamily::Ftp | crate::options::OptionFamily::Sftp => tags.push("#ftp"),
crate::options::OptionFamily::Rpc => tags.push("#rpc"),
crate::options::OptionFamily::Bt => tags.push("#bittorrent"),
crate::options::OptionFamily::Metalink => tags.push("#metalink"),
crate::options::OptionFamily::Checksum => tags.push("#checksum"),
crate::options::OptionFamily::Proxy => {
tags.push("#http");
tags.push("#ftp");
}
crate::options::OptionFamily::Security => tags.push("#https"),
crate::options::OptionFamily::Performance => {
if matches!(
name,
"max-overall-download-limit"
| "max-download-limit"
| "max-overall-upload-limit"
| "max-upload-limit"
) {
tags.extend(["#http", "#ftp", "#bittorrent"]);
} else {
tags.extend(["#http", "#ftp"]);
}
}
}
for tag in extra_tags {
match tag {
crate::options::OptionTag::Deprecated => tags.push("#deprecated"),
crate::options::OptionTag::Bt => tags.push("#bittorrent"),
crate::options::OptionTag::Metalink => tags.push("#metalink"),
crate::options::OptionTag::Rpc => tags.push("#rpc"),
crate::options::OptionTag::GlobalOnly | crate::options::OptionTag::PerDownloadOnly => {
tags.push("#advanced");
}
crate::options::OptionTag::InputFile => tags.push("#basic"),
crate::options::OptionTag::SessionFile => tags.push("#advanced"),
crate::options::OptionTag::Alias => tags.push("#experimental"),
crate::options::OptionTag::HighRisk | crate::options::OptionTag::RequiredCompat => {}
}
}
tags.sort_unstable();
tags.dedup();
tags
}
/// Renders each help entry into a block of text.
fn render_option_entries(entries: &[HelpOptionEntry]) -> Vec<String> {
entries.iter().map(format_option_entry).collect()
}
/// Formats one help entry block in an aria2-style layout.
fn format_option_entry(entry: &HelpOptionEntry) -> String {
let mut block = Vec::new();
if entry.switches.len() >= 34 {
block.push(format!(" {}", entry.switches));
block.push(format!(
" {}",
entry.description
));
} else {
block.push(format!(" {:<34}{}", entry.switches, entry.description));
}
if let Some(values) = &entry.possible_values {
block.push(String::new());
block.push(format!(
" Possible Values: {values}"
));
}
if let Some(default_value) = &entry.default_value {
block.push(format!(
" Default: {default_value}"
));
}
if !entry.tags.is_empty() {
block.push(format!(
" Tags: {}",
entry.tags.join(", ")
));
}
block.push(String::new());
block.join("\n")
}
/// Renders a full help document to user-facing text.
fn render_help(document: &HelpDocument, query: Option<HelpQuery>) -> String {
let mut text = String::new();
if let Some(usage_line) = document
.usage
.first()
.and_then(|section| section.lines.first())
{
text.push_str("Usage: ");
text.push_str(usage_line);
text.push('\n');
}
match query {
None | Some(HelpQuery::All) => text.push_str("Printing all options.\n"),
Some(HelpQuery::Tag(tag)) => {
let _ = writeln!(text, "Printing options tagged with \"{tag}\".");
}
Some(HelpQuery::Keyword(keyword)) => {
let _ = writeln!(text, "Printing options whose name includes \"{keyword}\".");
}
}
for section in &document.sections {
text.push_str(section.name);
text.push_str(":\n");
for block in &section.body {
text.push_str(block);
if !block.ends_with('\n') {
text.push('\n');
}
}
}
text.push_str("Refer to man page for more information.\n");
text
}
#[cfg(test)]
mod tests {
use super::{cli_version_text, help_text, help_text_for_query};
#[test]
fn version_text_uses_aria2_style_banner() {
let version = cli_version_text();
assert!(version.contains("aria2 version 1.37.0"));
assert!(version.contains("Rust rewrite package: aria2-rust-pro"));
assert!(version.contains("Enabled Features:"));
}
#[test]
fn help_text_uses_aria2_style_usage_and_options() {
let help = help_text();
assert!(help.starts_with("Usage: aria2c [OPTIONS]"));
assert!(help.contains("Printing all options."));
assert!(help.contains("Options:"));
assert!(help.contains("-h, --help[=TAG|KEYWORD]"));
assert!(help.contains("--retry-on-400[=true|false]"));
assert!(help.contains("Refer to man page for more information."));
}
#[test]
fn help_text_for_tag_filters_to_matching_entries() {
let help = help_text_for_query(Some("#http"));
assert!(help.contains("Printing options tagged with \"#http\"."));
assert!(help.contains("--user-agent=VALUE, -U"));
assert!(help.contains("--retry-on-400[=true|false]"));
assert!(!help.contains("--bt-save-metadata[=true|false]"));
}
#[test]
fn help_text_for_keyword_filters_to_matching_entries() {
let help = help_text_for_query(Some("rpc"));
assert!(help.contains("Printing options whose name includes \"rpc\"."));
assert!(help.contains("--rpc-listen-port=PORT"));
assert!(help.contains("--rpc-secret=VALUE"));
assert!(!help.contains("--bt-tracker=URI,..."));
}
}
+133
View File
@@ -0,0 +1,133 @@
//! Compatibility-facing metadata and helpers for `aria2-rust-pro`.
//!
//! This crate centralizes the option registry, config parsing surface, help
//! text scaffolding, and compatibility bookkeeping that mirror the historical
//! `aria2` user-facing contract.
#![forbid(unsafe_code)]
/// Compatibility ledger types and baseline coverage metadata.
pub mod compat;
/// Config parsing and normalization helpers for aria2-style inputs.
pub mod config;
/// Help-text rendering and CLI version banner helpers.
pub mod help;
/// Canonical option metadata used by the compat surface.
pub mod options;
pub use compat::{
CompatLedger, CompatLedgerEntry, CompatLevel, CompatNote, FeatureSurface, REQUIRED_PROTOCOLS,
compatibility_notes,
};
pub use config::{
ConfigAst, ConfigDirective, ConfigDocument, ConfigLocationKind, ConfigParseError,
ConfigProfile, ConfigScope, ConfigSource, SessionFileModel, parse_config, parse_config_lenient,
parse_config_line, parse_config_line_strict,
};
pub use help::{
HelpDocument, HelpQuery, HelpSection, UsageSection, cli_version_text, compatibility_help_text,
help_text, help_text_for_query, manpage_metadata,
};
pub use options::{
OPTION_SPECS, OptionFamily, OptionKind, OptionMetadata, OptionParser, OptionScope,
OptionSource, OptionSpec, OptionStatus, OptionTag, ReservedOptionName, env_var_for_option,
global_option_specs, is_required_pro_option, option_spec, per_download_option_specs,
reserved_option_names, rpc_option_name,
};
/// Commit hash for the upstream baseline used by the compat layer.
pub const BASELINE_COMMIT: &str = "1f1323128cae942f5440c035cb5f42788b3de33f";
/// Product name shown by compat-oriented outputs.
pub const PRODUCT_NAME: &str = "aria2-rust-pro";
/// Crate version exposed by compatibility banners.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Errors produced while translating older compatibility inputs.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CompatError {
/// The requested option name is not part of the compat registry.
UnknownOption(String),
/// The requested protocol name is not part of the required surface.
UnknownProtocol(String),
/// A config line could not be interpreted as a valid directive.
InvalidConfigDirective(String),
/// A config option that requires a value was provided without one.
MissingConfigValue(String),
/// A named option received an invalid value.
InvalidValue {
/// Canonical option name that failed validation.
option: String,
/// Raw value that failed validation.
value: String,
/// Human-readable validation failure detail.
reason: String,
},
/// A compat-only alias or deprecated switch was encountered.
DeprecatedOption {
/// Deprecated option spelling.
option: String,
/// Replacement spelling when one exists.
replacement: Option<String>,
},
}
impl std::fmt::Display for CompatError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownOption(name) => write!(f, "unknown option: {name}"),
Self::UnknownProtocol(name) => write!(f, "unknown protocol: {name}"),
Self::InvalidConfigDirective(line) => write!(f, "invalid config directive: {line}"),
Self::MissingConfigValue(name) => write!(f, "missing value for option: {name}"),
Self::InvalidValue {
option,
value,
reason,
} => write!(f, "invalid value for {option}: {value} ({reason})"),
Self::DeprecatedOption {
option,
replacement,
} => {
if let Some(replacement) = replacement {
write!(f, "deprecated option: {option} (use {replacement})")
} else {
write!(f, "deprecated option: {option}")
}
}
}
}
}
impl std::error::Error for CompatError {}
/// Returns the standard version banner for the compat surface.
#[must_use]
pub fn version_line() -> String {
format!("{PRODUCT_NAME} {VERSION} (compat baseline {BASELINE_COMMIT})")
}
/// Returns whether a protocol belongs to the required compatibility baseline.
#[must_use]
pub fn is_required_protocol(protocol: &str) -> bool {
REQUIRED_PROTOCOLS.contains(&protocol)
}
/// Maps a config or CLI option spelling to its canonical option name.
#[must_use]
pub fn normalize_option_name(name: &str) -> Option<&'static str> {
option_spec(name).map(|spec| spec.name)
}
/// Maps any known option spelling to its canonical RPC field name.
#[must_use]
pub fn normalize_rpc_option_name(name: &str) -> Option<&'static str> {
rpc_option_name(name)
}
/// Rewrites directive names in-place to their canonical registry spellings.
pub fn normalize_config_document(document: &mut ConfigDocument) {
for directive in &mut document.directives {
if let Some(canonical) = normalize_option_name(&directive.name) {
directive.name = canonical.to_owned();
}
}
}
@@ -0,0 +1,26 @@
//! Canonical option registry for the compat surface.
/// Shared option data model used by the compat registry and query helpers.
mod model;
/// Lookup and filtering helpers for canonical, scoped, and RPC option spellings.
mod query;
/// Static aria2-compatible option specifications exposed by the compat layer.
mod registry;
/// Internal option names reserved for compat-layer metadata.
mod reserved;
pub use self::model::{
OptionFamily, OptionKind, OptionMetadata, OptionParser, OptionScope, OptionSource, OptionSpec,
OptionStatus, OptionTag,
};
pub use self::query::{
env_var_for_option, global_option_specs, is_live_option_status, is_required_pro_option,
live_option_specs, option_spec, per_download_option_specs, reserved_option_names,
rpc_option_name,
};
pub use self::registry::OPTION_SPECS;
pub use self::reserved::{RESERVED_OPTION_NAMES, ReservedOptionName};
#[cfg(test)]
/// Regression coverage for compat option registry behavior.
mod tests;
@@ -0,0 +1,316 @@
//! Canonical option registry for the compat surface.
use std::fmt;
/// High-level value kind for an option.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OptionKind {
/// Boolean on/off value.
Bool,
/// Signed or unsigned integral value.
Integer,
/// Byte-size value such as `1M`.
Size,
/// Duration value such as seconds.
Duration,
/// Free-form text value.
Text,
/// Floating-point numeric value.
Float,
/// Filesystem path value.
Path,
/// Closed set of named values.
Enum,
/// Comma-separated list-like value.
List,
}
impl fmt::Display for OptionKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Bool => f.write_str("bool"),
Self::Integer => f.write_str("integer"),
Self::Size => f.write_str("size"),
Self::Duration => f.write_str("duration"),
Self::Text => f.write_str("text"),
Self::Float => f.write_str("float"),
Self::Path => f.write_str("path"),
Self::Enum => f.write_str("enum"),
Self::List => f.write_str("list"),
}
}
}
/// Provenance for an option specification.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OptionSource {
/// Native upstream aria2 option.
Original,
/// Option introduced by `aria2-rust-pro`.
Pro,
/// Long-form alias kept for compatibility.
CompatibilityAlias,
/// RPC-facing alias kept for compatibility.
RpcAlias,
/// Experimental surface that may still change.
Experimental,
}
/// Implementation status for an option surface.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OptionStatus {
/// Option is planned but not yet implemented.
Planned,
/// Option is implemented but not fully verified.
Implemented,
/// Option is implemented and verified.
Verified,
/// Option remains available but is deprecated.
Deprecated,
/// Option was intentionally removed from the live surface.
Removed,
}
/// Placement scope for an option.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OptionScope {
/// Option is only valid globally.
Global,
/// Option is only valid per download.
PerDownload,
/// Option is valid in both scopes.
Both,
}
/// Functional family used for grouping and help tagging.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OptionFamily {
/// Core download behavior.
Core,
/// HTTP-specific behavior.
Http,
/// FTP-specific behavior.
Ftp,
/// SFTP-specific behavior.
Sftp,
/// RPC server behavior.
Rpc,
/// `BitTorrent` behavior.
Bt,
/// Metalink behavior.
Metalink,
/// Session import or save behavior.
Session,
/// Input-file behavior.
Input,
/// Checksum-related behavior.
Checksum,
/// Proxy-related behavior.
Proxy,
/// TLS and certificate behavior.
Security,
/// Performance and throughput behavior.
Performance,
}
/// Fine-grained compatibility tags attached to option metadata.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OptionTag {
/// Required to cover the targeted compatibility baseline.
RequiredCompat,
/// High-risk option that benefits from extra care.
HighRisk,
/// Option spelling is deprecated.
Deprecated,
/// Option acts as a compatibility alias.
Alias,
/// Option participates in the RPC surface.
Rpc,
/// Option participates in the `BitTorrent` surface.
Bt,
/// Option participates in the Metalink surface.
Metalink,
/// Option belongs in session files.
SessionFile,
/// Option belongs in input files.
InputFile,
/// Option is only valid globally.
GlobalOnly,
/// Option is only valid per download.
PerDownloadOnly,
}
/// Parser shape used for user-facing validation hints.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OptionParser {
/// Boolean parser.
Boolean,
/// Integer parser.
Integer,
/// Size parser.
Size,
/// Duration parser.
Duration,
/// Free-form text parser.
Text,
/// Comma-separated list parser.
Csv,
/// Enum parser.
Enum,
/// URI-list parser.
UriList,
/// Filesystem path parser.
Path,
/// Repeated header parser.
Headers,
/// `key=value` parser.
KeyValue,
}
/// Supplemental metadata attached to an option specification.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct OptionMetadata {
/// Scope where the option is valid.
pub scope: OptionScope,
/// Functional family for grouping and help tagging.
pub family: OptionFamily,
/// Parser shape used for validation and help text.
pub parser: OptionParser,
/// Extra compatibility tags attached to the option.
pub tags: &'static [OptionTag],
/// Human-readable validator summary.
pub validator: &'static str,
/// Short source description for the option origin.
pub source_text: &'static str,
/// Brief compat-oriented description for help and ledger output.
pub compatibility_note: &'static str,
}
/// Canonical registry entry for a compat option.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct OptionSpec {
/// Canonical option name.
pub name: &'static str,
/// High-level option value kind.
pub kind: OptionKind,
/// Default value shown by help and metadata.
pub default_value: &'static str,
/// Provenance for the option surface.
pub source: OptionSource,
/// Current implementation status.
pub status: OptionStatus,
/// CLI aliases accepted for the option.
pub aliases: &'static [&'static str],
/// RPC names accepted for the option.
pub rpc_names: &'static [&'static str],
/// Supplemental metadata for the option.
pub metadata: OptionMetadata,
}
impl OptionSpec {
/// Returns whether the option carries a given compatibility tag.
#[must_use]
pub fn has_tag(&self, tag: OptionTag) -> bool {
self.metadata.tags.contains(&tag)
}
/// Returns all accepted CLI spellings for the option.
#[must_use]
pub fn cli_spellings(&self) -> Vec<String> {
let mut spellings = vec![format!("--{}", self.name)];
for alias in self.aliases {
if alias.len() == 1 {
spellings.push(format!("-{alias}"));
} else {
spellings.push(format!("--{alias}"));
}
}
spellings
}
/// Returns config-file spellings for the option, excluding short aliases.
#[must_use]
pub fn config_spellings(&self) -> Vec<&'static str> {
let mut spellings = vec![self.name];
for alias in self.aliases {
if alias.len() > 1 && !spellings.contains(alias) {
spellings.push(alias);
}
}
spellings
}
/// Returns all accepted lookup spellings across CLI aliases and RPC names.
#[must_use]
pub fn lookup_spellings(&self) -> Vec<&'static str> {
let mut spellings = vec![self.name];
for alias in self.aliases {
if !spellings.contains(alias) {
spellings.push(alias);
}
}
for rpc_name in self.rpc_names {
if !spellings.contains(rpc_name) {
spellings.push(rpc_name);
}
}
spellings
}
/// Returns the human-readable value hint used by help rendering.
#[must_use]
pub fn value_hint(&self) -> &'static str {
if self.metadata.validator.contains('|') {
return self.metadata.validator;
}
match self.metadata.parser {
OptionParser::Boolean => "true|false",
OptionParser::Integer => {
if self.name.ends_with("-port") {
"PORT"
} else {
"NUM"
}
}
OptionParser::Size => "SIZE",
OptionParser::Duration => "SEC",
OptionParser::Text => match self.metadata.validator {
"command string" => "COMMAND",
"proxy url" => "URI",
"filename-safe" => "FILE",
_ => "VALUE",
},
OptionParser::Csv => match self.name {
"bt-tracker" => "URI,...",
"no-proxy" => "HOST,...",
"select-file" => "INDEX,...",
_ => "VALUE,...",
},
OptionParser::Enum => self.metadata.validator,
OptionParser::UriList => "URI,...",
OptionParser::Path => "PATH",
OptionParser::Headers => "HEADER",
OptionParser::KeyValue => "KEY=VALUE",
}
}
/// Returns the help synopsis rendered for the option.
#[must_use]
pub fn help_synopsis(&self) -> String {
let mut synopsis = format!("--{}", self.name);
let value_hint = self.value_hint();
if self.kind == OptionKind::Bool {
synopsis.push_str("[=");
synopsis.push_str(value_hint);
synopsis.push(']');
} else {
synopsis.push('=');
synopsis.push_str(value_hint);
}
let aliases = self.cli_spellings();
if let Some((_, extra_aliases)) = aliases.split_first()
&& !extra_aliases.is_empty()
{
synopsis.push_str(", ");
synopsis.push_str(&extra_aliases.join(", "));
}
synopsis
}
}
@@ -0,0 +1,77 @@
use super::registry::OPTION_SPECS;
use super::reserved::{RESERVED_OPTION_NAMES, ReservedOptionName};
use super::{OptionScope, OptionSource, OptionSpec, OptionStatus};
/// Returns the canonical option specification for a known spelling.
#[must_use]
pub fn option_spec(name: &str) -> Option<&'static OptionSpec> {
OPTION_SPECS
.iter()
.find(|s| s.name == name)
.or_else(|| OPTION_SPECS.iter().find(|s| s.aliases.contains(&name)))
}
/// Returns whether an option is required by the `aria2-rust-pro` surface.
#[must_use]
pub fn is_required_pro_option(option: &str) -> bool {
OPTION_SPECS.iter().any(|s| {
s.name == option
&& (s.source == OptionSource::Pro || s.source == OptionSource::CompatibilityAlias)
})
}
/// Returns whether a status should be exposed in live option listings.
#[must_use]
pub const fn is_live_option_status(status: OptionStatus) -> bool {
matches!(
status,
OptionStatus::Implemented | OptionStatus::Verified | OptionStatus::Deprecated
)
}
/// Filters the registry while preserving canonical-name uniqueness.
fn unique_option_specs(predicate: impl Fn(&OptionSpec) -> bool) -> Vec<&'static OptionSpec> {
let mut seen = std::collections::BTreeSet::new();
OPTION_SPECS
.iter()
.filter(|spec| predicate(spec) && seen.insert(spec.name))
.collect()
}
/// Returns all live option specs without duplicate canonical names.
#[must_use]
pub fn live_option_specs() -> Vec<&'static OptionSpec> {
unique_option_specs(|spec| is_live_option_status(spec.status))
}
/// Returns all options available in the global scope.
#[must_use]
pub fn global_option_specs() -> Vec<&'static OptionSpec> {
unique_option_specs(|spec| {
matches!(spec.metadata.scope, OptionScope::Global | OptionScope::Both)
})
}
/// Returns all options available in the per-download scope.
#[must_use]
pub fn per_download_option_specs() -> Vec<&'static OptionSpec> {
unique_option_specs(|spec| {
matches!(
spec.metadata.scope,
OptionScope::PerDownload | OptionScope::Both
)
})
}
/// Returns the reserved option-name table.
#[must_use]
pub const fn reserved_option_names() -> &'static [ReservedOptionName] {
RESERVED_OPTION_NAMES
}
/// Returns the canonical RPC option name for a known spelling.
#[must_use]
pub fn rpc_option_name(name: &str) -> Option<&'static str> {
option_spec(name).and_then(|s| s.rpc_names.first().copied())
}
/// Returns the environment-variable name associated with an option.
#[must_use]
pub fn env_var_for_option(name: &str) -> Option<String> {
option_spec(name).map(|s| format!("ARIA2_{}", s.name.replace('-', "_").to_ascii_uppercase()))
}
@@ -0,0 +1,53 @@
use std::sync::LazyLock;
use super::{
OptionFamily, OptionKind, OptionMetadata, OptionParser, OptionScope, OptionSource, OptionSpec,
OptionStatus, OptionTag,
};
/// Shared tag slice for per-download-only options.
const TAG_PER: &[OptionTag] = &[OptionTag::PerDownloadOnly];
/// Shared tag slice for RPC options.
const TAG_RPC: &[OptionTag] = &[OptionTag::Rpc];
/// Shared tag slice for `BitTorrent` options.
const TAG_BT: &[OptionTag] = &[OptionTag::Bt];
/// Shared tag slice for Metalink options.
const TAG_METALINK: &[OptionTag] = &[OptionTag::Metalink];
/// Shared tag slice for compatibility aliases.
const TAG_ALIAS: &[OptionTag] = &[OptionTag::Alias];
/// Shared tag slice for global-only options.
const TAG_GLOBAL: &[OptionTag] = &[OptionTag::GlobalOnly];
/// Option entries that extend the baseline registry with compatibility extras.
mod compatibility_extension_entries;
/// Foundational core, RPC, and BT registry entries used across the compat layer.
mod foundational_entries;
/// Hook, proxy, TLS, and RPC-adjacent registry entries.
mod hooks_and_rpc_entries;
/// Transfer-tuning and performance-oriented registry entries.
mod transfer_tuning_entries;
use self::{
compatibility_extension_entries::COMPATIBILITY_EXTENSION_ENTRIES,
foundational_entries::FOUNDATIONAL_ENTRIES, hooks_and_rpc_entries::HOOKS_AND_RPC_ENTRIES,
transfer_tuning_entries::TRANSFER_TUNING_ENTRIES,
};
/// Builds the flattened compat registry once from the semantic entry groups.
fn build_option_specs() -> Box<[OptionSpec]> {
let total_len = FOUNDATIONAL_ENTRIES
.len()
.checked_add(HOOKS_AND_RPC_ENTRIES.len())
.and_then(|value| value.checked_add(TRANSFER_TUNING_ENTRIES.len()))
.and_then(|value| value.checked_add(COMPATIBILITY_EXTENSION_ENTRIES.len()))
.expect("compat option registry entry count should fit in usize");
let mut flattened = Vec::with_capacity(total_len);
flattened.extend_from_slice(FOUNDATIONAL_ENTRIES);
flattened.extend_from_slice(HOOKS_AND_RPC_ENTRIES);
flattened.extend_from_slice(TRANSFER_TUNING_ENTRIES);
flattened.extend_from_slice(COMPATIBILITY_EXTENSION_ENTRIES);
flattened.into_boxed_slice()
}
/// Canonical compat option registry.
pub static OPTION_SPECS: LazyLock<Box<[OptionSpec]>> = LazyLock::new(build_option_specs);
@@ -0,0 +1,368 @@
use super::{
OptionFamily, OptionKind, OptionMetadata, OptionParser, OptionScope, OptionSource, OptionSpec,
OptionStatus, OptionTag, TAG_ALIAS, TAG_GLOBAL, TAG_PER,
};
/// Compatibility-extension entries layered onto the canonical compat registry.
pub(super) const COMPATIBILITY_EXTENSION_ENTRIES: &[OptionSpec] = &[
OptionSpec {
name: "pause",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["pause"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Core,
parser: OptionParser::Boolean,
tags: TAG_PER,
validator: "bool",
source_text: "aria2 option",
compatibility_note: "start in paused state after registration",
},
},
OptionSpec {
name: "dry-run",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["dry-run"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Core,
parser: OptionParser::Boolean,
tags: TAG_PER,
validator: "bool",
source_text: "aria2 option",
compatibility_note: "validate target reachability without committing download data",
},
},
OptionSpec {
name: "on-download-pause",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["on-download-pause"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Core,
parser: OptionParser::Text,
tags: TAG_GLOBAL,
validator: "command string",
source_text: "aria2 option",
compatibility_note: "pause hook command",
},
},
OptionSpec {
name: "checksum",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["checksum"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Checksum,
parser: OptionParser::KeyValue,
tags: TAG_PER,
validator: "TYPE=DIGEST",
source_text: "aria2 option",
compatibility_note: "expected file digest supplied at add time",
},
},
OptionSpec {
name: "select-file",
kind: OptionKind::List,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["select-file"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Bt,
parser: OptionParser::Csv,
tags: &[OptionTag::Bt, OptionTag::PerDownloadOnly],
validator: "torrent file indexes",
source_text: "aria2 option",
compatibility_note: "choose torrent file indexes or ranges to download",
},
},
OptionSpec {
name: "bt-remove-unselected-file",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["bt-remove-unselected-file"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Bt,
parser: OptionParser::Boolean,
tags: &[OptionTag::Bt, OptionTag::PerDownloadOnly],
validator: "bool",
source_text: "aria2 option",
compatibility_note: "delete skipped torrent files when selection is active",
},
},
OptionSpec {
name: "metalink-base-uri",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["metalink-base-uri"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Metalink,
parser: OptionParser::Text,
tags: &[OptionTag::Metalink, OptionTag::PerDownloadOnly],
validator: "any",
source_text: "aria2 option",
compatibility_note: "base URI used to resolve relative Metalink resources",
},
},
OptionSpec {
name: "remote-time",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &["R"],
rpc_names: &["remote-time"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Http,
parser: OptionParser::Boolean,
tags: &[],
validator: "bool",
source_text: "aria2 option",
compatibility_note: "persist remote timestamp on the output file",
},
},
OptionSpec {
name: "rpc-user",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["rpc-user"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Rpc,
parser: OptionParser::Text,
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
validator: "any",
source_text: "aria2 option",
compatibility_note: "legacy basic-auth rpc username",
},
},
OptionSpec {
name: "rpc-passwd",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["rpc-passwd"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Rpc,
parser: OptionParser::Text,
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
validator: "any",
source_text: "aria2 option",
compatibility_note: "legacy basic-auth rpc password",
},
},
OptionSpec {
name: "rpc-max-request-size",
kind: OptionKind::Size,
default_value: "2M",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["rpc-max-request-size"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Rpc,
parser: OptionParser::Size,
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
validator: ">=0",
source_text: "aria2 option",
compatibility_note: "maximum accepted rpc request payload size",
},
},
OptionSpec {
name: "rpc-allow-origin-all",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["rpc-allow-origin-all"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Rpc,
parser: OptionParser::Boolean,
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
validator: "bool",
source_text: "aria2 option",
compatibility_note: "allow any origin for browser rpc access",
},
},
OptionSpec {
name: "rpc-certificate",
kind: OptionKind::Path,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["rpc-certificate"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Rpc,
parser: OptionParser::Path,
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
validator: "path",
source_text: "aria2 option",
compatibility_note: "tls certificate for secure rpc mode",
},
},
OptionSpec {
name: "rpc-private-key",
kind: OptionKind::Path,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["rpc-private-key"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Rpc,
parser: OptionParser::Path,
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
validator: "path",
source_text: "aria2 option",
compatibility_note: "tls private key for secure rpc mode",
},
},
OptionSpec {
name: "rpc-secure",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["rpc-secure"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Rpc,
parser: OptionParser::Boolean,
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
validator: "bool",
source_text: "aria2 option",
compatibility_note: "serve rpc over tls",
},
},
OptionSpec {
name: "rpc-save-upload-metadata",
kind: OptionKind::Bool,
default_value: "true",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["rpc-save-upload-metadata"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Rpc,
parser: OptionParser::Boolean,
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
validator: "bool",
source_text: "aria2 option",
compatibility_note: "persist uploaded torrent or metalink metadata",
},
},
OptionSpec {
name: "input-file",
kind: OptionKind::Path,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &["i"],
rpc_names: &["input-file"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Input,
parser: OptionParser::Path,
tags: &[OptionTag::InputFile, OptionTag::GlobalOnly],
validator: "existing path",
source_text: "aria2 option",
compatibility_note: "load uri list from file",
},
},
OptionSpec {
name: "save-session",
kind: OptionKind::Path,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["save-session"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Session,
parser: OptionParser::Path,
tags: &[OptionTag::SessionFile, OptionTag::GlobalOnly],
validator: "writable path",
source_text: "aria2 option",
compatibility_note: "save active tasks on exit",
},
},
OptionSpec {
name: "save-session-interval",
kind: OptionKind::Duration,
default_value: "0",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["save-session-interval"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Session,
parser: OptionParser::Duration,
tags: &[OptionTag::SessionFile, OptionTag::GlobalOnly],
validator: ">=0",
source_text: "aria2 option",
compatibility_note: "periodic session flush interval",
},
},
OptionSpec {
name: "no-want-digest-header",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Pro,
status: OptionStatus::Implemented,
aliases: &["http-want-digest"],
rpc_names: &["no-want-digest-header", "http-want-digest"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Http,
parser: OptionParser::Boolean,
tags: TAG_ALIAS,
validator: "bool",
source_text: "aria2-rust-pro",
compatibility_note: "compat switch for digest header behavior",
},
},
];
@@ -0,0 +1,422 @@
use super::{
OptionFamily, OptionKind, OptionMetadata, OptionParser, OptionScope, OptionSource, OptionSpec,
OptionStatus, OptionTag, TAG_BT, TAG_GLOBAL, TAG_PER, TAG_RPC,
};
/// Foundational option entries that anchor the compat registry surface.
pub(super) const FOUNDATIONAL_ENTRIES: &[OptionSpec] = &[
OptionSpec {
name: "dir",
kind: OptionKind::Path,
default_value: ".",
source: OptionSource::Original,
status: OptionStatus::Verified,
aliases: &[],
rpc_names: &["dir"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Core,
parser: OptionParser::Path,
tags: TAG_PER,
validator: "non-empty path",
source_text: "aria2 option",
compatibility_note: "download target directory",
},
},
OptionSpec {
name: "out",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Verified,
aliases: &[],
rpc_names: &["out"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Core,
parser: OptionParser::Text,
tags: TAG_PER,
validator: "filename-safe",
source_text: "aria2 option",
compatibility_note: "output file name override",
},
},
OptionSpec {
name: "split",
kind: OptionKind::Integer,
default_value: "5",
source: OptionSource::Original,
status: OptionStatus::Verified,
aliases: &[],
rpc_names: &["split"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Performance,
parser: OptionParser::Integer,
tags: TAG_PER,
validator: ">=1",
source_text: "aria2 option",
compatibility_note: "piece split count",
},
},
OptionSpec {
name: "max-concurrent-downloads",
kind: OptionKind::Integer,
default_value: "5",
source: OptionSource::Original,
status: OptionStatus::Verified,
aliases: &["j"],
rpc_names: &["max-concurrent-downloads"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Performance,
parser: OptionParser::Integer,
tags: TAG_GLOBAL,
validator: ">=1",
source_text: "aria2 option",
compatibility_note: "maximum number of active downloads",
},
},
OptionSpec {
name: "continue",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Original,
status: OptionStatus::Verified,
aliases: &["c"],
rpc_names: &["continue"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Core,
parser: OptionParser::Boolean,
tags: TAG_PER,
validator: "bool",
source_text: "aria2 option",
compatibility_note: "resume partial download",
},
},
OptionSpec {
name: "pause",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["pause"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Core,
parser: OptionParser::Boolean,
tags: TAG_PER,
validator: "bool",
source_text: "aria2 option",
compatibility_note: "add download in paused state",
},
},
OptionSpec {
name: "allow-overwrite",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["allow-overwrite"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Core,
parser: OptionParser::Boolean,
tags: TAG_PER,
validator: "bool",
source_text: "aria2 option",
compatibility_note: "allow overwriting existing destination files",
},
},
OptionSpec {
name: "checksum",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["checksum"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Checksum,
parser: OptionParser::KeyValue,
tags: TAG_PER,
validator: "algorithm=value",
source_text: "aria2 option",
compatibility_note: "expected checksum for content verification",
},
},
OptionSpec {
name: "parameterized-uri",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &["P"],
rpc_names: &["parameterized-uri"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Input,
parser: OptionParser::Boolean,
tags: &[OptionTag::InputFile, OptionTag::PerDownloadOnly],
validator: "bool",
source_text: "aria2 option",
compatibility_note: "treat input URIs as parameterized templates",
},
},
OptionSpec {
name: "remote-time",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &["R"],
rpc_names: &["remote-time"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Http,
parser: OptionParser::Boolean,
tags: TAG_PER,
validator: "bool",
source_text: "aria2 option",
compatibility_note: "preserve remote Last-Modified timestamp",
},
},
OptionSpec {
name: "referer",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["referer"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Http,
parser: OptionParser::Text,
tags: TAG_PER,
validator: "URI",
source_text: "aria2 option",
compatibility_note: "HTTP referer header override",
},
},
OptionSpec {
name: "enable-rpc",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Original,
status: OptionStatus::Verified,
aliases: &[],
rpc_names: &["enable-rpc"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Rpc,
parser: OptionParser::Boolean,
tags: TAG_RPC,
validator: "bool",
source_text: "aria2 option",
compatibility_note: "enable rpc server",
},
},
OptionSpec {
name: "rpc-listen-port",
kind: OptionKind::Integer,
default_value: "6800",
source: OptionSource::Original,
status: OptionStatus::Verified,
aliases: &[],
rpc_names: &["rpc-listen-port"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Rpc,
parser: OptionParser::Integer,
tags: TAG_RPC,
validator: "1..65535",
source_text: "aria2 option",
compatibility_note: "rpc tcp port",
},
},
OptionSpec {
name: "rpc-listen-all",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["rpc-listen-all"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Rpc,
parser: OptionParser::Boolean,
tags: TAG_RPC,
validator: "bool",
source_text: "aria2 option",
compatibility_note: "bind rpc server to all interfaces",
},
},
OptionSpec {
name: "rpc-allow-origin-all",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["rpc-allow-origin-all"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Rpc,
parser: OptionParser::Boolean,
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
validator: "bool",
source_text: "aria2 option",
compatibility_note: "allow any Origin header on RPC responses",
},
},
OptionSpec {
name: "rpc-secure",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["rpc-secure"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Rpc,
parser: OptionParser::Boolean,
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
validator: "bool",
source_text: "aria2 option",
compatibility_note: "enable TLS on the RPC server",
},
},
OptionSpec {
name: "rpc-secret",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["rpc-secret", "token"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Rpc,
parser: OptionParser::Text,
tags: TAG_RPC,
validator: "any",
source_text: "aria2 option",
compatibility_note: "rpc auth token",
},
},
OptionSpec {
name: "rpc-save-upload-metadata",
kind: OptionKind::Bool,
default_value: "true",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["rpc-save-upload-metadata"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Rpc,
parser: OptionParser::Boolean,
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
validator: "bool",
source_text: "aria2 option",
compatibility_note: "persist uploaded torrent or metalink metadata through RPC",
},
},
OptionSpec {
name: "listen-port",
kind: OptionKind::Integer,
default_value: "6881",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["listen-port"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Bt,
parser: OptionParser::Integer,
tags: TAG_BT,
validator: "1..65535",
source_text: "aria2 option",
compatibility_note: "bt tcp/udp listen port",
},
},
OptionSpec {
name: "dht-listen-port",
kind: OptionKind::Integer,
default_value: "6881",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["dht-listen-port"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Bt,
parser: OptionParser::Integer,
tags: TAG_BT,
validator: "1..65535",
source_text: "aria2 option",
compatibility_note: "dht udp listen port",
},
},
OptionSpec {
name: "select-file",
kind: OptionKind::List,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["select-file"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Bt,
parser: OptionParser::Csv,
tags: &[OptionTag::Bt, OptionTag::PerDownloadOnly],
validator: "comma-separated file indexes",
source_text: "aria2 option",
compatibility_note: "select BT or Metalink files to download",
},
},
OptionSpec {
name: "bt-tracker",
kind: OptionKind::List,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["bt-tracker"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Bt,
parser: OptionParser::Csv,
tags: TAG_BT,
validator: "comma-separated tracker list",
source_text: "aria2 option",
compatibility_note: "bt tracker announce list",
},
},
OptionSpec {
name: "ftp-user",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["ftp-user"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Ftp,
parser: OptionParser::Text,
tags: &[OptionTag::GlobalOnly],
validator: "VALUE",
source_text: "aria2 option",
compatibility_note: "default FTP username",
},
},
];
@@ -0,0 +1,512 @@
use super::{
OptionFamily, OptionKind, OptionMetadata, OptionParser, OptionScope, OptionSource, OptionSpec,
OptionStatus, OptionTag, TAG_BT, TAG_METALINK,
};
/// Hook, proxy, TLS, and RPC-adjacent compat registry entries.
pub(super) const HOOKS_AND_RPC_ENTRIES: &[OptionSpec] = &[
OptionSpec {
name: "on-download-complete",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["on-download-complete"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Core,
parser: OptionParser::Text,
tags: &[OptionTag::GlobalOnly],
validator: "command string",
source_text: "aria2 option",
compatibility_note: "completion hook command",
},
},
OptionSpec {
name: "on-download-stop",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["on-download-stop"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Core,
parser: OptionParser::Text,
tags: &[OptionTag::GlobalOnly],
validator: "command string",
source_text: "aria2 option",
compatibility_note: "stop hook command",
},
},
OptionSpec {
name: "save-cookies",
kind: OptionKind::Path,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["save-cookies"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Http,
parser: OptionParser::Path,
tags: &[OptionTag::GlobalOnly],
validator: "writable path",
source_text: "aria2 option",
compatibility_note: "persist the cookie jar to disk",
},
},
OptionSpec {
name: "disable-ipv6",
kind: OptionKind::Bool,
default_value: "true",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["disable-ipv6"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Core,
parser: OptionParser::Boolean,
tags: &[],
validator: "bool",
source_text: "aria2 option",
compatibility_note: "disable ipv6 sockets and resolution",
},
},
OptionSpec {
name: "user-agent",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &["U"],
rpc_names: &["user-agent"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Http,
parser: OptionParser::Text,
tags: &[],
validator: "any",
source_text: "aria2 option",
compatibility_note: "request user agent header",
},
},
OptionSpec {
name: "header",
kind: OptionKind::List,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["header"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Http,
parser: OptionParser::Headers,
tags: &[],
validator: "header lines",
source_text: "aria2 option",
compatibility_note: "custom request headers",
},
},
OptionSpec {
name: "all-proxy",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["all-proxy"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Proxy,
parser: OptionParser::Text,
tags: &[],
validator: "proxy url",
source_text: "aria2 option",
compatibility_note: "generic proxy endpoint",
},
},
OptionSpec {
name: "http-proxy",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["http-proxy"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Proxy,
parser: OptionParser::Text,
tags: &[],
validator: "proxy url",
source_text: "aria2 option",
compatibility_note: "http proxy endpoint",
},
},
OptionSpec {
name: "https-proxy",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["https-proxy"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Proxy,
parser: OptionParser::Text,
tags: &[],
validator: "proxy url",
source_text: "aria2 option",
compatibility_note: "https proxy endpoint",
},
},
OptionSpec {
name: "no-proxy",
kind: OptionKind::List,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["no-proxy"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Proxy,
parser: OptionParser::Csv,
tags: &[],
validator: "csv host list",
source_text: "aria2 option",
compatibility_note: "proxy bypass host list",
},
},
OptionSpec {
name: "ftp-proxy",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["ftp-proxy"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Proxy,
parser: OptionParser::Text,
tags: &[],
validator: "proxy url",
source_text: "aria2 option",
compatibility_note: "ftp proxy endpoint",
},
},
OptionSpec {
name: "http-proxy-user",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["http-proxy-user"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Proxy,
parser: OptionParser::Text,
tags: &[],
validator: "any",
source_text: "aria2 option",
compatibility_note: "username override for http proxy endpoint",
},
},
OptionSpec {
name: "http-proxy-passwd",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["http-proxy-passwd"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Proxy,
parser: OptionParser::Text,
tags: &[],
validator: "any",
source_text: "aria2 option",
compatibility_note: "password override for http proxy endpoint",
},
},
OptionSpec {
name: "https-proxy-user",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["https-proxy-user"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Proxy,
parser: OptionParser::Text,
tags: &[],
validator: "any",
source_text: "aria2 option",
compatibility_note: "username override for https proxy endpoint",
},
},
OptionSpec {
name: "https-proxy-passwd",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["https-proxy-passwd"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Proxy,
parser: OptionParser::Text,
tags: &[],
validator: "any",
source_text: "aria2 option",
compatibility_note: "password override for https proxy endpoint",
},
},
OptionSpec {
name: "ftp-proxy-user",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["ftp-proxy-user"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Proxy,
parser: OptionParser::Text,
tags: &[],
validator: "any",
source_text: "aria2 option",
compatibility_note: "username override for ftp proxy endpoint",
},
},
OptionSpec {
name: "ftp-proxy-passwd",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["ftp-proxy-passwd"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Proxy,
parser: OptionParser::Text,
tags: &[],
validator: "any",
source_text: "aria2 option",
compatibility_note: "password override for ftp proxy endpoint",
},
},
OptionSpec {
name: "all-proxy-user",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["all-proxy-user"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Proxy,
parser: OptionParser::Text,
tags: &[],
validator: "any",
source_text: "aria2 option",
compatibility_note: "username override for generic proxy endpoint",
},
},
OptionSpec {
name: "all-proxy-passwd",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["all-proxy-passwd"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Proxy,
parser: OptionParser::Text,
tags: &[],
validator: "any",
source_text: "aria2 option",
compatibility_note: "password override for generic proxy endpoint",
},
},
OptionSpec {
name: "check-certificate",
kind: OptionKind::Bool,
default_value: "true",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["check-certificate"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Security,
parser: OptionParser::Boolean,
tags: &[],
validator: "bool",
source_text: "aria2 option",
compatibility_note: "verify peer and host certificates",
},
},
OptionSpec {
name: "ca-certificate",
kind: OptionKind::Path,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["ca-certificate"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Security,
parser: OptionParser::Path,
tags: &[],
validator: "path",
source_text: "aria2 option",
compatibility_note: "ca certificate file",
},
},
OptionSpec {
name: "certificate",
kind: OptionKind::Path,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["certificate"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Security,
parser: OptionParser::Path,
tags: &[],
validator: "path",
source_text: "aria2 option",
compatibility_note: "client certificate file",
},
},
OptionSpec {
name: "private-key",
kind: OptionKind::Path,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["private-key"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Security,
parser: OptionParser::Path,
tags: &[],
validator: "path",
source_text: "aria2 option",
compatibility_note: "client private key file",
},
},
OptionSpec {
name: "retry-wait",
kind: OptionKind::Duration,
default_value: "0",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["retry-wait"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Http,
parser: OptionParser::Duration,
tags: &[],
validator: ">=0",
source_text: "aria2 option",
compatibility_note: "retry delay in seconds",
},
},
OptionSpec {
name: "max-tries",
kind: OptionKind::Integer,
default_value: "5",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["max-tries"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Http,
parser: OptionParser::Integer,
tags: &[],
validator: ">=0",
source_text: "aria2 option",
compatibility_note: "maximum retry attempts",
},
},
OptionSpec {
name: "bt-save-metadata",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Original,
status: OptionStatus::Verified,
aliases: &[],
rpc_names: &["bt-save-metadata"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Bt,
parser: OptionParser::Boolean,
tags: TAG_BT,
validator: "bool",
source_text: "aria2 option",
compatibility_note: "save metadata file",
},
},
OptionSpec {
name: "follow-torrent",
kind: OptionKind::Text,
default_value: "true",
source: OptionSource::Original,
status: OptionStatus::Verified,
aliases: &[],
rpc_names: &["follow-torrent"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Bt,
parser: OptionParser::Enum,
tags: TAG_BT,
validator: "true|false|mem",
source_text: "aria2 option",
compatibility_note: "torrent follow behavior",
},
},
OptionSpec {
name: "metalink-enable-unique-protocol",
kind: OptionKind::Bool,
default_value: "true",
source: OptionSource::Original,
status: OptionStatus::Verified,
aliases: &[],
rpc_names: &["metalink-enable-unique-protocol"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Metalink,
parser: OptionParser::Boolean,
tags: TAG_METALINK,
validator: "bool",
source_text: "aria2 option",
compatibility_note: "metalink dedupe by protocol",
},
},
];
@@ -0,0 +1,566 @@
use super::{
OptionFamily, OptionKind, OptionMetadata, OptionParser, OptionScope, OptionSource, OptionSpec,
OptionStatus, OptionTag, TAG_GLOBAL, TAG_PER, TAG_RPC,
};
/// Transfer-tuning and performance-oriented compat registry entries.
pub(super) const TRANSFER_TUNING_ENTRIES: &[OptionSpec] = &[
OptionSpec {
name: "max-overall-download-limit",
kind: OptionKind::Size,
default_value: "0",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["max-overall-download-limit"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Performance,
parser: OptionParser::Size,
tags: TAG_PER,
validator: ">=0",
source_text: "aria2 option",
compatibility_note: "global aggregate download-speed cap",
},
},
OptionSpec {
name: "max-download-limit",
kind: OptionKind::Size,
default_value: "0",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["max-download-limit"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Performance,
parser: OptionParser::Size,
tags: TAG_PER,
validator: ">=0",
source_text: "aria2 option",
compatibility_note: "per-download download-speed cap",
},
},
OptionSpec {
name: "max-overall-upload-limit",
kind: OptionKind::Size,
default_value: "0",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["max-overall-upload-limit"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Performance,
parser: OptionParser::Size,
tags: TAG_PER,
validator: ">=0",
source_text: "aria2 option",
compatibility_note: "global aggregate upload-speed cap",
},
},
OptionSpec {
name: "max-upload-limit",
kind: OptionKind::Size,
default_value: "0",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["max-upload-limit"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Performance,
parser: OptionParser::Size,
tags: TAG_PER,
validator: ">=0",
source_text: "aria2 option",
compatibility_note: "per-download upload-speed cap",
},
},
OptionSpec {
name: "disk-cache",
kind: OptionKind::Size,
default_value: "16M",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["disk-cache"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Performance,
parser: OptionParser::Size,
tags: TAG_PER,
validator: ">=0",
source_text: "aria2 option",
compatibility_note: "configured disk cache budget",
},
},
OptionSpec {
name: "max-connection-per-server",
kind: OptionKind::Integer,
default_value: "1",
source: OptionSource::Pro,
status: OptionStatus::Implemented,
aliases: &["x"],
rpc_names: &["max-connection-per-server"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Performance,
parser: OptionParser::Integer,
tags: TAG_PER,
validator: ">=1",
source_text: "aria2-rust-pro",
compatibility_note: "legacy speed tuning",
},
},
OptionSpec {
name: "min-split-size",
kind: OptionKind::Size,
default_value: "20M",
source: OptionSource::Pro,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["min-split-size"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Performance,
parser: OptionParser::Size,
tags: TAG_PER,
validator: ">=1024",
source_text: "aria2-rust-pro",
compatibility_note: "pro lower split-size floor target",
},
},
OptionSpec {
name: "piece-length",
kind: OptionKind::Size,
default_value: "1M",
source: OptionSource::Pro,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["piece-length"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Performance,
parser: OptionParser::Size,
tags: TAG_PER,
validator: ">=1024",
source_text: "aria2-rust-pro",
compatibility_note: "pro lower piece-length floor target",
},
},
OptionSpec {
name: "retry-on-400",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Pro,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["retry-on-400"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Http,
parser: OptionParser::Boolean,
tags: TAG_RPC,
validator: "bool",
source_text: "aria2-rust-pro",
compatibility_note: "retry HTTP 400 when explicitly enabled",
},
},
OptionSpec {
name: "retry-on-403",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Pro,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["retry-on-403"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Http,
parser: OptionParser::Boolean,
tags: TAG_RPC,
validator: "bool",
source_text: "aria2-rust-pro",
compatibility_note: "retry HTTP 403 when explicitly enabled",
},
},
OptionSpec {
name: "retry-on-406",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Pro,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["retry-on-406"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Http,
parser: OptionParser::Boolean,
tags: TAG_RPC,
validator: "bool",
source_text: "aria2-rust-pro",
compatibility_note: "retry HTTP 406 when explicitly enabled",
},
},
OptionSpec {
name: "retry-on-unknown",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Pro,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["retry-on-unknown"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Http,
parser: OptionParser::Boolean,
tags: TAG_RPC,
validator: "bool",
source_text: "aria2-rust-pro",
compatibility_note: "retry unknown HTTP failure when explicitly enabled",
},
},
OptionSpec {
name: "referer",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["referer"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Http,
parser: OptionParser::Text,
tags: &[],
validator: "any",
source_text: "aria2 option",
compatibility_note: "http referer header override",
},
},
OptionSpec {
name: "lowest-speed-limit",
kind: OptionKind::Size,
default_value: "0",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["lowest-speed-limit"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Performance,
parser: OptionParser::Size,
tags: &[],
validator: ">=0",
source_text: "aria2 option",
compatibility_note: "abort slow connections below this transfer rate",
},
},
OptionSpec {
name: "allow-overwrite",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["allow-overwrite"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Core,
parser: OptionParser::Boolean,
tags: TAG_PER,
validator: "bool",
source_text: "aria2 option",
compatibility_note: "overwrite existing target file instead of refusing",
},
},
OptionSpec {
name: "auto-file-renaming",
kind: OptionKind::Bool,
default_value: "true",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["auto-file-renaming"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Core,
parser: OptionParser::Boolean,
tags: TAG_PER,
validator: "bool",
source_text: "aria2 option",
compatibility_note: "rename colliding output file automatically",
},
},
OptionSpec {
name: "parameterized-uri",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &["P"],
rpc_names: &["parameterized-uri"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Input,
parser: OptionParser::Boolean,
tags: &[OptionTag::InputFile, OptionTag::PerDownloadOnly],
validator: "bool",
source_text: "aria2 option",
compatibility_note: "expand numbered or ranged URI templates",
},
},
OptionSpec {
name: "realtime-chunk-checksum",
kind: OptionKind::Bool,
default_value: "true",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["realtime-chunk-checksum"],
metadata: OptionMetadata {
scope: OptionScope::PerDownload,
family: OptionFamily::Checksum,
parser: OptionParser::Boolean,
tags: TAG_PER,
validator: "bool",
source_text: "aria2 option",
compatibility_note: "verify chunk checksums while downloading when available",
},
},
OptionSpec {
name: "load-cookies",
kind: OptionKind::Path,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["load-cookies"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Http,
parser: OptionParser::Path,
tags: &[],
validator: "existing path",
source_text: "aria2 option",
compatibility_note: "load Mozilla-format cookies from disk",
},
},
OptionSpec {
name: "save-cookies",
kind: OptionKind::Path,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["save-cookies"],
metadata: OptionMetadata {
scope: OptionScope::Global,
family: OptionFamily::Http,
parser: OptionParser::Path,
tags: TAG_GLOBAL,
validator: "writable path",
source_text: "aria2 option",
compatibility_note: "save Mozilla-format cookies on exit",
},
},
OptionSpec {
name: "ftp-user",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["ftp-user"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Ftp,
parser: OptionParser::Text,
tags: &[],
validator: "any",
source_text: "aria2 option",
compatibility_note: "ftp username applied to matching transfers",
},
},
OptionSpec {
name: "ftp-passwd",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["ftp-passwd"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Ftp,
parser: OptionParser::Text,
tags: &[],
validator: "any",
source_text: "aria2 option",
compatibility_note: "ftp password applied to matching transfers",
},
},
OptionSpec {
name: "ftp-type",
kind: OptionKind::Enum,
default_value: "binary",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["ftp-type"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Ftp,
parser: OptionParser::Enum,
tags: &[],
validator: "binary|ascii",
source_text: "aria2 option",
compatibility_note: "ftp transfer type preference",
},
},
OptionSpec {
name: "ftp-pasv",
kind: OptionKind::Bool,
default_value: "true",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &["p"],
rpc_names: &["ftp-pasv"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Ftp,
parser: OptionParser::Boolean,
tags: &[],
validator: "bool",
source_text: "aria2 option",
compatibility_note: "ftp passive mode toggle",
},
},
OptionSpec {
name: "ftp-reuse-connection",
kind: OptionKind::Bool,
default_value: "true",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["ftp-reuse-connection"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Ftp,
parser: OptionParser::Boolean,
tags: &[],
validator: "bool",
source_text: "aria2 option",
compatibility_note: "ftp connection reuse preference",
},
},
OptionSpec {
name: "http-user",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["http-user"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Http,
parser: OptionParser::Text,
tags: &[],
validator: "any",
source_text: "aria2 option",
compatibility_note: "http auth username applied to matching transfers",
},
},
OptionSpec {
name: "http-passwd",
kind: OptionKind::Text,
default_value: "",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["http-passwd"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Http,
parser: OptionParser::Text,
tags: &[],
validator: "any",
source_text: "aria2 option",
compatibility_note: "http auth password applied to matching transfers",
},
},
OptionSpec {
name: "use-head",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["use-head"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Http,
parser: OptionParser::Boolean,
tags: &[],
validator: "bool",
source_text: "aria2 option",
compatibility_note: "probe with HEAD before the first GET when supported",
},
},
OptionSpec {
name: "always-resume",
kind: OptionKind::Bool,
default_value: "true",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["always-resume"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Session,
parser: OptionParser::Boolean,
tags: &[],
validator: "bool",
source_text: "aria2 option",
compatibility_note: "refuse to restart from scratch when resume is possible",
},
},
OptionSpec {
name: "max-resume-failure-tries",
kind: OptionKind::Integer,
default_value: "0",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["max-resume-failure-tries"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Session,
parser: OptionParser::Integer,
tags: &[],
validator: ">=0",
source_text: "aria2 option",
compatibility_note: "allow limited resume mismatches before restarting",
},
},
OptionSpec {
name: "conditional-get",
kind: OptionKind::Bool,
default_value: "false",
source: OptionSource::Original,
status: OptionStatus::Implemented,
aliases: &[],
rpc_names: &["conditional-get"],
metadata: OptionMetadata {
scope: OptionScope::Both,
family: OptionFamily::Http,
parser: OptionParser::Boolean,
tags: &[],
validator: "bool",
source_text: "aria2 option",
compatibility_note: "skip download when local file is already current",
},
},
];
@@ -0,0 +1,24 @@
/// Reserved option name that cannot be used by external inputs.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ReservedOptionName {
/// Reserved option spelling.
pub name: &'static str,
/// Explanation for the reservation.
pub reason: &'static str,
}
/// Reserved option spellings used internally by the compat layer.
pub const RESERVED_OPTION_NAMES: &[ReservedOptionName] = &[
ReservedOptionName {
name: "_profile",
reason: "internal profile selector",
},
ReservedOptionName {
name: "_compat-mode",
reason: "internal compatibility switch",
},
ReservedOptionName {
name: "_source",
reason: "config source marker",
},
];
@@ -0,0 +1,176 @@
use super::{
OptionFamily, OptionScope, OptionStatus, OptionTag, global_option_specs, live_option_specs,
option_spec, per_download_option_specs,
};
#[test]
fn cli_spellings_keep_short_and_long_alias_forms() {
let continue_spec = option_spec("continue").expect("continue should exist");
assert_eq!(
continue_spec.cli_spellings(),
vec!["--continue".to_owned(), "-c".to_owned()]
);
let digest_spec =
option_spec("no-want-digest-header").expect("compat digest option should exist");
assert_eq!(
digest_spec.cli_spellings(),
vec![
"--no-want-digest-header".to_owned(),
"--http-want-digest".to_owned()
]
);
}
#[test]
fn config_spellings_exclude_short_cli_aliases_but_keep_long_compat_aliases() {
let continue_spec = option_spec("continue").expect("continue should exist");
assert_eq!(continue_spec.config_spellings(), vec!["continue"]);
let digest_spec =
option_spec("no-want-digest-header").expect("compat digest option should exist");
assert_eq!(
digest_spec.config_spellings(),
vec!["no-want-digest-header", "http-want-digest"]
);
}
#[test]
fn lookup_spellings_merge_canonical_alias_and_rpc_names_without_duplicates() {
let rpc_secret = option_spec("rpc-secret").expect("rpc-secret should exist");
assert_eq!(rpc_secret.lookup_spellings(), vec!["rpc-secret", "token"]);
}
#[test]
fn value_hint_and_help_synopsis_are_help_ready() {
let continue_spec = option_spec("continue").expect("continue should exist");
assert_eq!(continue_spec.value_hint(), "true|false");
assert_eq!(continue_spec.help_synopsis(), "--continue[=true|false], -c");
let port_spec = option_spec("rpc-listen-port").expect("rpc-listen-port should exist");
assert_eq!(port_spec.value_hint(), "PORT");
assert_eq!(port_spec.help_synopsis(), "--rpc-listen-port=PORT");
let follow_torrent = option_spec("follow-torrent").expect("follow-torrent should exist");
assert_eq!(follow_torrent.value_hint(), "true|false|mem");
let ftp_type = option_spec("ftp-type").expect("ftp-type should exist");
assert_eq!(ftp_type.value_hint(), "binary|ascii");
let ftp_pasv = option_spec("ftp-pasv").expect("ftp-pasv should exist");
assert_eq!(ftp_pasv.help_synopsis(), "--ftp-pasv[=true|false], -p");
}
#[test]
fn live_option_specs_cover_only_non_planned_surface() {
let live_specs = live_option_specs();
assert!(live_specs.iter().all(|spec| {
spec.status != OptionStatus::Planned && spec.status != OptionStatus::Removed
}));
assert!(live_specs.iter().any(|spec| spec.name == "bt-tracker"));
assert!(
live_specs
.iter()
.any(|spec| spec.name == "on-download-complete")
);
}
#[test]
fn extended_registry_surface_exposes_aliases_families_tags_and_value_hints() {
let parameterized = option_spec("parameterized-uri")
.expect("parameterized-uri compatibility option should exist");
assert_eq!(option_spec("P"), Some(parameterized));
assert_eq!(
parameterized.cli_spellings(),
vec!["--parameterized-uri".to_owned(), "-P".to_owned()]
);
assert_eq!(parameterized.metadata.family, OptionFamily::Input);
assert_eq!(parameterized.metadata.scope, OptionScope::PerDownload);
assert!(parameterized.has_tag(OptionTag::InputFile));
let remote_time = option_spec("R").expect("remote-time short alias should resolve");
assert_eq!(remote_time.name, "remote-time");
assert_eq!(remote_time.metadata.family, OptionFamily::Http);
let checksum = option_spec("checksum").expect("checksum option should exist");
assert_eq!(checksum.metadata.family, OptionFamily::Checksum);
assert_eq!(checksum.value_hint(), "KEY=VALUE");
assert!(checksum.has_tag(OptionTag::PerDownloadOnly));
let select_file = option_spec("select-file").expect("select-file should exist");
assert_eq!(select_file.value_hint(), "INDEX,...");
assert_eq!(select_file.metadata.family, OptionFamily::Bt);
assert!(select_file.has_tag(OptionTag::Bt));
let rpc_origin =
option_spec("rpc-allow-origin-all").expect("rpc-allow-origin-all should exist");
assert_eq!(rpc_origin.metadata.family, OptionFamily::Rpc);
assert_eq!(rpc_origin.metadata.scope, OptionScope::Global);
assert!(rpc_origin.has_tag(OptionTag::Rpc));
let ftp_proxy = option_spec("ftp-proxy").expect("ftp-proxy should exist");
assert_eq!(ftp_proxy.metadata.family, OptionFamily::Proxy);
assert_eq!(ftp_proxy.metadata.scope, OptionScope::Both);
let all_proxy_user = option_spec("all-proxy-user").expect("all-proxy-user should exist");
assert_eq!(all_proxy_user.metadata.family, OptionFamily::Proxy);
assert_eq!(all_proxy_user.value_hint(), "VALUE");
}
#[test]
fn extended_registry_surface_flows_into_global_and_per_download_views() {
let global_names = global_option_specs()
.into_iter()
.map(|spec| spec.name)
.collect::<Vec<_>>();
let per_names = per_download_option_specs()
.into_iter()
.map(|spec| spec.name)
.collect::<Vec<_>>();
assert!(global_names.contains(&"rpc-secure"));
assert!(global_names.contains(&"save-cookies"));
assert!(global_names.contains(&"rpc-save-upload-metadata"));
assert!(global_names.contains(&"ftp-user"));
assert!(global_names.contains(&"ftp-proxy-user"));
assert!(global_names.contains(&"ftp-pasv"));
assert!(per_names.contains(&"select-file"));
assert!(per_names.contains(&"pause"));
assert!(per_names.contains(&"checksum"));
assert!(per_names.contains(&"allow-overwrite"));
assert!(per_names.contains(&"ftp-proxy"));
assert!(per_names.contains(&"ftp-reuse-connection"));
}
#[test]
fn option_registry_views_do_not_emit_duplicate_canonical_names() {
let live_names = live_option_specs()
.into_iter()
.map(|spec| spec.name)
.collect::<Vec<_>>();
let global_names = global_option_specs()
.into_iter()
.map(|spec| spec.name)
.collect::<Vec<_>>();
let per_names = per_download_option_specs()
.into_iter()
.map(|spec| spec.name)
.collect::<Vec<_>>();
for (label, names) in [
("live", live_names),
("global", global_names),
("per-download", per_names),
] {
let unique = names
.iter()
.copied()
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
unique.len(),
names.len(),
"{label} option projection should not contain duplicate canonical names: {names:?}"
);
}
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "aria2-rust-pro-core"
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
[lib]
name = "aria2_rust_pro_core"
path = "src/lib.rs"
[dependencies]
aria2-rust-pro-storage.workspace = true
[lints]
workspace = true
+679
View File
@@ -0,0 +1,679 @@
//! Download-engine orchestration, queue management, and session persistence glue.
#![expect(
clippy::arithmetic_side_effects,
reason = "engine counters and scheduler math are guarded by domain tests rather than checked arithmetic at every step"
)]
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use aria2_rust_pro_storage::{
ControlFileVersion, ControlMetadata, DownloadFile as StoredDownloadFile,
PieceIndex as StoredPieceIndex, PieceState as StoredPieceState, SessionFile, SessionFileEntry,
load_session_file, save_session_file, write_aria2_control_file,
};
use crate::{
error::{CoreError, Result},
events::{EventBus, EventListener, RuntimeEvent, RuntimeEventKind},
options::{OptionKey, OptionPatch, OptionValue},
progress::{GlobalStat, ProgressSnapshot},
request::{
BtFileInfo, BtPeerInfo, BtPeerMutationResult, BtPieceAvailabilityMutationResult,
BtPieceAvailabilityUpdate, BtPieceBlockUpdate, BtPieceMutationResult, BtPressureSnapshot,
BtRuntimeState, BtRuntimeTickResult, BtTrackerInfo, DownloadId, DownloadStatus,
RequestContext, RequestGroup, RetryAttempt, SegmentAssignment, SegmentRuntimeStats,
SegmentState,
},
runtime::RuntimeConfig,
scheduler::{
RetryHistoryEntry, ScheduleDecision, Scheduler, SchedulerActivityCounters,
SchedulerPlanningObservation, SchedulerState,
},
session::{SaveSessionTarget, Session, SessionState},
};
/// Session-file and control-file persistence helpers for the download engine.
mod session_persistence;
use self::session_persistence::{
build_control_metadata, control_path_for_session_entry, resolve_target_path,
should_persist_group,
};
/// `BitTorrent` runtime mutation helpers attached to the download engine.
mod bt_runtime;
/// Runtime inspection snapshots and progress aggregation helpers.
mod inspection;
/// Waiting-queue and stopped-result management helpers for the engine.
mod queue;
/// Scheduler integration and segment-assignment helpers for the engine.
mod scheduling;
pub use self::inspection::{DownloadRuntimeSnapshot, RuntimeInstrumentationSnapshot};
use self::inspection::{
active_runtime_group_count, build_progress_snapshot, clamp_speed, effective_piece_length,
effective_speed_caps, infer_total_length,
};
use self::scheduling::{build_segment_assignments, retry_history_from_group};
/// Converts a `usize` into `i64`, saturating to `i64::MAX` when it does not fit.
fn usize_to_i64(value: usize) -> i64 {
i64::try_from(value).unwrap_or(i64::MAX)
}
/// Converts a `usize` into `u32`, saturating to `u32::MAX` when it does not fit.
fn usize_to_u32(value: usize) -> u32 {
u32::try_from(value).unwrap_or(u32::MAX)
}
/// Converts a `usize` into `u64`.
fn usize_to_u64(value: usize) -> u64 {
u64::try_from(value).unwrap_or(u64::MAX)
}
/// Converts a `u32` into `usize`, saturating to `usize::MAX` on unsupported targets.
fn u32_to_usize(value: u32) -> usize {
usize::try_from(value).unwrap_or(usize::MAX)
}
/// Stable handle used by higher layers to refer to a download in the engine.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DownloadHandle {
/// Download identifier carried by this handle.
gid: DownloadId,
}
impl DownloadHandle {
/// Creates a new handle for the provided download identifier.
#[must_use]
pub const fn new(gid: DownloadId) -> Self {
Self { gid }
}
/// Returns the identifier carried by this handle.
#[must_use]
pub const fn gid(self) -> DownloadId {
self.gid
}
}
/// In-memory registry that owns all tracked request groups.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DownloadRegistry {
/// Next synthetic gid assigned when callers add a new request.
next_gid: u64,
/// Stored downloads keyed by gid.
groups: HashMap<DownloadId, RequestGroup>,
}
impl Default for DownloadRegistry {
fn default() -> Self {
Self::new()
}
}
impl DownloadRegistry {
/// Creates an empty registry with gid allocation starting at `1`.
#[must_use]
pub fn new() -> Self {
Self {
next_gid: 1,
groups: HashMap::new(),
}
}
/// Allocates the next gid without inserting a request group.
pub fn allocate_gid(&mut self) -> DownloadId {
let gid = DownloadId::new(self.next_gid);
self.next_gid = self.next_gid.saturating_add(1);
gid
}
/// Inserts an existing request group and returns its external handle.
pub fn insert(&mut self, group: RequestGroup) -> DownloadHandle {
let gid = group.gid();
self.groups.insert(gid, group);
DownloadHandle::new(gid)
}
/// Creates a simple URI-backed request group and inserts it into the registry.
pub fn add_uri(&mut self, uri: impl Into<String>) -> DownloadHandle {
let gid = self.allocate_gid();
self.insert(RequestGroup::new(gid, uri))
}
/// Returns the immutable request group for `gid` when present.
#[must_use]
pub fn get(&self, gid: DownloadId) -> Option<&RequestGroup> {
self.groups.get(&gid)
}
/// Returns the mutable request group for `gid` when present.
pub fn get_mut(&mut self, gid: DownloadId) -> Option<&mut RequestGroup> {
self.groups.get_mut(&gid)
}
/// Removes and returns the request group associated with `gid`.
pub fn remove(&mut self, gid: DownloadId) -> Option<RequestGroup> {
self.groups.remove(&gid)
}
/// Returns the number of tracked downloads.
#[must_use]
pub fn len(&self) -> usize {
self.groups.len()
}
/// Returns whether the registry is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.groups.is_empty()
}
/// Iterates over handles for every currently registered gid.
pub fn handles(&self) -> impl Iterator<Item = DownloadHandle> + '_ {
self.groups.keys().copied().map(DownloadHandle::new)
}
}
/// Reference frame used when changing a waiting download's queue position.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum QueuePositionMode {
/// Treat the supplied offset as an absolute queue index.
Set,
/// Apply the supplied offset relative to the current queue index.
Cur,
/// Apply the supplied offset relative to the end of the waiting queue.
End,
}
/// High-level in-memory engine that coordinates downloads, scheduling, session IO, and events.
#[derive(Debug)]
pub struct DownloadEngine {
/// Registry holding all live request groups.
registry: DownloadRegistry,
/// Waiting queue order for paused and waiting downloads.
reserved_queue: Vec<DownloadId>,
/// Shared scheduler used to plan active segments and retry flow.
scheduler: Scheduler,
/// Embedded session/runtime bridge.
session: Session,
/// Cached global stat record updated on demand.
global_stat: GlobalStat,
/// Event bus used by RPC and other observers.
events: EventBus,
/// Engine lifecycle state.
state: SessionState,
/// Monotonic sequence assigned to stopped downloads for tellStopped ordering.
next_stopped_sequence: u64,
}
impl Default for DownloadEngine {
fn default() -> Self {
Self::new()
}
}
impl DownloadEngine {
/// Creates a new engine with the default runtime configuration.
#[must_use]
pub fn new() -> Self {
Self::with_runtime(RuntimeConfig::default())
}
/// Creates a new engine backed by the provided runtime configuration.
#[must_use]
pub fn with_runtime(runtime: RuntimeConfig) -> Self {
Self {
registry: DownloadRegistry::new(),
reserved_queue: Vec::new(),
scheduler: Scheduler::new(),
global_stat: GlobalStat::default(),
events: EventBus::new(),
state: SessionState::Idle,
session: Session::new(runtime),
next_stopped_sequence: 1,
}
}
/// Returns the runtime configuration currently attached to the session bridge.
#[must_use]
pub fn runtime(&self) -> &RuntimeConfig {
self.session.runtime()
}
/// Returns the underlying download registry.
#[must_use]
pub fn registry(&self) -> &DownloadRegistry {
&self.registry
}
/// Returns a mutable reference to the underlying download registry.
pub fn registry_mut(&mut self) -> &mut DownloadRegistry {
&mut self.registry
}
/// Returns the scheduler used by the engine.
#[must_use]
pub fn scheduler(&self) -> &Scheduler {
&self.scheduler
}
/// Returns a mutable reference to the scheduler used by the engine.
pub fn scheduler_mut(&mut self) -> &mut Scheduler {
&mut self.scheduler
}
/// Returns the engine event bus.
#[must_use]
pub fn events(&self) -> &EventBus {
&self.events
}
/// Returns a mutable reference to the engine event bus.
pub fn events_mut(&mut self) -> &mut EventBus {
&mut self.events
}
/// Returns the embedded session bridge.
#[must_use]
pub fn session(&self) -> &Session {
&self.session
}
/// Returns a mutable reference to the embedded session bridge.
pub fn session_mut(&mut self) -> &mut Session {
&mut self.session
}
/// Returns the current engine lifecycle state.
#[must_use]
pub fn state(&self) -> &SessionState {
&self.state
}
/// Returns the cached global stat structure.
#[must_use]
pub fn global_stat(&self) -> &GlobalStat {
&self.global_stat
}
/// Adds a URI download to the registry and enqueues it at the back of the waiting queue.
pub fn add_uri(&mut self, uri: impl Into<String>) -> DownloadHandle {
let handle = self.registry.add_uri(uri);
self.enqueue_reserved_back(handle.gid());
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadAdded).with_gid(handle.gid()));
handle
}
/// Adds a fully-formed request context to the registry and waiting queue.
pub fn add_request(&mut self, context: RequestContext) -> DownloadHandle {
let gid = self.registry.allocate_gid();
let handle = self
.registry
.insert(RequestGroup::with_context(gid, context));
self.enqueue_reserved_back(handle.gid());
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadAdded).with_gid(handle.gid()));
handle
}
/// Requests an orderly shutdown through the session bridge.
pub fn shutdown(&mut self) -> Result<()> {
self.state = SessionState::ShuttingDown;
self.scheduler.set_state(SchedulerState::ShuttingDown);
self.session.shutdown()?;
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::ShutdownRequested));
Ok(())
}
/// Requests an immediate forced shutdown through the session bridge.
pub fn force_shutdown(&mut self) -> Result<()> {
self.state = SessionState::ForceShuttingDown;
self.scheduler.set_state(SchedulerState::Stopped);
self.session.force_shutdown()?;
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::ForceShutdownRequested));
Ok(())
}
/// Persists the in-memory session and, for file targets, control-file metadata.
pub fn save_session(&mut self, target: SaveSessionTarget) -> Result<()> {
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::SessionSaving));
self.session.save_session(target.clone())?;
if let SaveSessionTarget::Path(path) = &target {
let session_file = self.build_session_file(path);
save_session_file(path, &session_file)
.map_err(|_| CoreError::StorageUnavailable("failed to write session file"))?;
for group in self.registry.groups.values() {
if !should_persist_group(*group.status()) {
continue;
}
let target_path = resolve_target_path(group, self.session.global_options());
let control_path = control_path_for_session_entry(path, group.gid());
let control =
build_control_metadata(group, &target_path, self.runtime().piece_length);
if let Some(parent) = control_path.parent() {
std::fs::create_dir_all(parent).map_err(|_| {
CoreError::StorageUnavailable("failed to create control-file directory")
})?;
}
write_aria2_control_file(&control_path, &control)
.map_err(|_| CoreError::StorageUnavailable("failed to write control file"))?;
}
}
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::SessionSaved));
Ok(())
}
/// Loads session state from memory or disk and rebuilds the registry when needed.
pub fn load_session(&mut self, source: SaveSessionTarget) -> Result<()> {
match source {
SaveSessionTarget::Memory => self.session.load_session(SaveSessionTarget::Memory),
SaveSessionTarget::Path(path) => {
let session_file = load_session_file(&path)
.map_err(|_| CoreError::StorageUnavailable("failed to read session file"))?;
self.rebuild_registry_from_session_file(&path, session_file);
if let Err(error) = self
.session
.load_session(SaveSessionTarget::Path(path.clone()))
{
match error {
CoreError::StorageUnavailable(_) => self.session.mark_external_load(path),
other => return Err(other),
}
}
Ok(())
}
}
}
/// Subscribes a listener to engine events.
pub fn register_listener(&mut self, listener: impl EventListener + 'static) {
self.events.subscribe(listener);
}
/// Sets one global option on the embedded session bridge.
pub fn set_option(&mut self, key: impl Into<OptionKey>, value: impl Into<OptionValue>) {
self.session.set_global_option(key, value);
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::OptionChanged));
}
/// Applies a batch global-option patch to the embedded session bridge.
pub fn apply_options(&mut self, patch: OptionPatch) {
self.session.apply_global_option_patch(patch);
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::OptionChanged));
}
/// Builds a progress snapshot for a specific download.
pub fn progress_snapshot(&self, gid: DownloadId) -> Result<ProgressSnapshot> {
let group = self
.registry
.get(gid)
.ok_or(CoreError::UnknownDownloadId(gid))?;
Ok(build_progress_snapshot(
group,
self.runtime(),
active_runtime_group_count(&self.registry),
))
}
/// Builds an engine-level runtime snapshot for a specific download.
pub fn download_runtime_snapshot(&self, gid: DownloadId) -> Result<DownloadRuntimeSnapshot> {
let group = self
.registry
.get(gid)
.ok_or(CoreError::UnknownDownloadId(gid))?;
let total_length = group
.bt_effective_target_length()
.unwrap_or_else(|| group.total_length());
let active_count = active_runtime_group_count(&self.registry);
let (effective_download_limit, effective_upload_limit) =
effective_speed_caps(group, self.runtime(), active_count);
Ok(DownloadRuntimeSnapshot {
gid,
status: *group.status(),
total_length,
completed_length: group.completed_length(),
remaining_length: total_length
.saturating_sub(group.completed_length().min(total_length)),
download_speed: clamp_speed(group.download_speed(), effective_download_limit),
upload_speed: clamp_speed(group.upload_speed(), effective_upload_limit),
effective_download_limit,
effective_upload_limit,
retry_count: group.retry_count(),
num_connections: group.num_connections(),
segment_stats: group.segment_runtime_stats(),
bt_pressure: group.bt_pressure_snapshot(),
})
}
/// Returns aggregate scheduler and resource instrumentation for all downloads.
#[must_use]
pub fn runtime_instrumentation_snapshot(&self) -> RuntimeInstrumentationSnapshot {
let mut snapshot = RuntimeInstrumentationSnapshot {
download_count: self.registry.len(),
active_download_count: 0,
waiting_download_count: 0,
stopped_download_count: 0,
error_download_count: 0,
complete_download_count: 0,
total_active_segments: 0,
total_planned_bytes: 0,
total_remaining_segment_bytes: 0,
total_requestable_pieces: 0,
total_scarce_requestable_pieces: 0,
total_bt_peers: 0,
configured_disk_cache_bytes: self.runtime().disk_cache_bytes,
max_overall_download_limit: self.runtime().max_overall_download_limit,
max_download_limit: self.runtime().max_download_limit,
max_overall_upload_limit: self.runtime().max_overall_upload_limit,
max_upload_limit: self.runtime().max_upload_limit,
scheduler_state: self.scheduler.state(),
scheduler_counters: *self.scheduler.activity_counters(),
last_scheduler_plan: self.scheduler.last_planning_observation().copied(),
};
for group in self.registry.groups.values() {
match group.status() {
DownloadStatus::Active => snapshot.active_download_count += 1,
DownloadStatus::Waiting => snapshot.waiting_download_count += 1,
DownloadStatus::Paused | DownloadStatus::Removed => {
snapshot.stopped_download_count += 1;
}
DownloadStatus::Error => snapshot.error_download_count += 1,
DownloadStatus::Complete => snapshot.complete_download_count += 1,
}
let segment_stats = group.segment_runtime_stats();
snapshot.total_active_segments +=
segment_stats.active_count + segment_stats.retrying_count;
snapshot.total_planned_bytes = snapshot
.total_planned_bytes
.saturating_add(segment_stats.planned_bytes);
snapshot.total_remaining_segment_bytes = snapshot
.total_remaining_segment_bytes
.saturating_add(segment_stats.remaining_bytes);
if let Some(pressure) = group.bt_pressure_snapshot() {
snapshot.total_requestable_pieces += pressure.requestable_pieces;
snapshot.total_scarce_requestable_pieces += pressure.scarce_requestable_pieces;
snapshot.total_bt_peers += pressure.peer_count;
}
}
snapshot
}
/// Returns handles for active downloads.
#[must_use]
pub fn tell_active(&self) -> Vec<DownloadHandle> {
self.registry
.groups
.iter()
.filter_map(|(gid, group)| {
(group.status() == &DownloadStatus::Active).then_some(DownloadHandle::new(*gid))
})
.collect()
}
/// Returns handles for waiting and paused downloads in reserved-queue order.
#[must_use]
pub fn tell_waiting(&self) -> Vec<DownloadHandle> {
self.reserved_queue
.iter()
.filter_map(|gid| {
self.registry.get(*gid).and_then(|group| {
matches!(
group.status(),
DownloadStatus::Waiting | DownloadStatus::Paused
)
.then_some(DownloadHandle::new(*gid))
})
})
.collect()
}
/// Returns handles for stopped downloads ordered by stopped-sequence.
#[must_use]
pub fn tell_stopped(&self) -> Vec<DownloadHandle> {
let mut stopped = self
.registry
.groups
.iter()
.filter_map(|(gid, group)| {
matches!(
group.status(),
DownloadStatus::Complete | DownloadStatus::Removed | DownloadStatus::Error
)
.then_some((group.stopped_sequence().unwrap_or_default(), *gid))
})
.collect::<Vec<_>>();
stopped.sort_by_key(|(sequence, gid)| (*sequence, *gid));
stopped
.into_iter()
.map(|(_, gid)| DownloadHandle::new(gid))
.collect()
}
/// Returns the total number of downloads that have entered a stopped terminal state.
#[must_use]
pub const fn num_stopped_total(&self) -> u64 {
self.next_stopped_sequence.saturating_sub(1)
}
/// Emits a prebuilt runtime event through the engine event bus.
pub fn emit(&mut self, event: RuntimeEvent) {
self.events.emit(event);
}
/// Returns a lightweight handle when the download exists.
#[must_use]
pub fn handle(&self, gid: DownloadId) -> Option<DownloadHandle> {
self.registry.get(gid).map(|_| DownloadHandle::new(gid))
}
/// Returns a mutable request group when the download exists.
pub fn handle_mut(&mut self, gid: DownloadId) -> Option<&mut RequestGroup> {
self.registry.get_mut(gid)
}
/// Resolves a mutable request group or returns `UnknownDownloadId`.
fn group_mut(&mut self, gid: DownloadId) -> Result<&mut RequestGroup> {
self.registry
.get_mut(gid)
.ok_or(CoreError::UnknownDownloadId(gid))
}
/// Applies one scheduler decision to a specific group and synchronizes the session bridge.
fn apply_schedule_decision(&mut self, gid: DownloadId, decision: &ScheduleDecision) {
self.scheduler.record_decision(decision);
match decision {
ScheduleDecision::RunNow(_) | ScheduleDecision::Queue(_) => {
let runtime = self.runtime().clone();
self.remove_from_reserved_queue(gid);
let Some(group) = self.registry.get_mut(gid) else {
return;
};
group.set_status(DownloadStatus::Active);
let segments = self.scheduler.plan_active_segments(group, &runtime);
let assignments = build_segment_assignments(group, &runtime, segments);
group.set_segment_assignments(assignments);
self.scheduler.observe_plan(group, &runtime, segments);
let completed = group.completed_length();
let retry_count = group.retry_count();
let retry_attempts = group.retry_attempts().to_vec();
let active_segments = u32_to_usize(group.num_connections());
self.sync_runtime_bridge(completed, retry_count, retry_attempts, active_segments);
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadStarted).with_gid(gid));
}
ScheduleDecision::RetryLater(_) => {
let (completed, retry_count, retry_attempts, active_segments) = {
let Some(group) = self.registry.get_mut(gid) else {
return;
};
group.increment_retry_count();
let mut attempt =
RetryAttempt::new(group.retry_count(), group.completed_length());
attempt.length = Some(
group
.total_length()
.saturating_sub(group.completed_length()),
);
attempt.error = Some("schedule-retry:error-state".to_string());
attempt.recoverable = true;
group.push_retry_attempt(attempt);
group.clear_segment_assignments();
group.set_status(DownloadStatus::Waiting);
(
group.completed_length(),
group.retry_count(),
group.retry_attempts().to_vec(),
u32_to_usize(group.num_connections()),
)
};
if !self.is_in_reserved_queue(gid) {
self.enqueue_reserved_back(gid);
}
self.sync_runtime_bridge(completed, retry_count, retry_attempts, active_segments);
}
ScheduleDecision::Pause(_) | ScheduleDecision::Remove(_) | ScheduleDecision::Noop => {}
}
}
/// Mirrors scheduler planning state into the embedded session bridge.
fn sync_runtime_bridge(
&mut self,
completed_length: u64,
retry_count: u32,
retry_attempts: Vec<RetryAttempt>,
active_segments: usize,
) {
let segment_plan = self.scheduler.bridge_segment_plan(self.runtime());
self.session.set_segment_plan(segment_plan);
let retry_history = retry_history_from_group(&retry_attempts);
let runtime_state = self.scheduler.bridge_runtime_state(
completed_length,
retry_count,
retry_history,
active_segments,
);
self.session.apply_runtime_schedule_state(runtime_state);
self.session.apply_scheduler_instrumentation(
*self.scheduler.activity_counters(),
self.scheduler.last_planning_observation().copied(),
);
}
}
#[cfg(test)]
/// Engine-focused tests covering registry flow, persistence, snapshots, and BT runtime helpers.
mod tests;
@@ -0,0 +1,198 @@
use super::{
BtPeerInfo, BtPeerMutationResult, BtPieceAvailabilityMutationResult, BtPieceAvailabilityUpdate,
BtPieceBlockUpdate, BtPieceMutationResult, BtRuntimeTickResult, BtTrackerInfo, CoreError,
DownloadEngine, DownloadId, Result,
};
impl DownloadEngine {
/// Replaces the full BT peer snapshot for a download.
pub fn apply_bt_peer_snapshot(
&mut self,
gid: DownloadId,
peers: Vec<BtPeerInfo>,
) -> Result<()> {
let group = self.group_mut(gid)?;
if group.bt().is_none() {
return Err(CoreError::InvalidState(
"bt runtime state is not initialized",
));
}
let _ = group.replace_bt_peer_snapshot(peers);
Ok(())
}
/// Applies an incremental BT peer update to a download.
pub fn apply_bt_peer_update(
&mut self,
gid: DownloadId,
peer: BtPeerInfo,
) -> Result<BtPeerMutationResult> {
let group = self.group_mut(gid)?;
if group.bt().is_none() {
return Err(CoreError::InvalidState(
"bt runtime state is not initialized",
));
}
Ok(group.apply_bt_peer_update(peer))
}
/// Applies an incremental BT piece availability update to a download.
pub fn apply_bt_piece_availability_update(
&mut self,
gid: DownloadId,
update: BtPieceAvailabilityUpdate,
) -> Result<BtPieceAvailabilityMutationResult> {
let group = self.group_mut(gid)?;
if group.bt().is_none() {
return Err(CoreError::InvalidState(
"bt runtime state is not initialized",
));
}
Ok(group.apply_bt_piece_availability_update(update))
}
/// Applies an incremental BT block-completion update to a download.
pub fn apply_bt_piece_block_update(
&mut self,
gid: DownloadId,
update: BtPieceBlockUpdate,
) -> Result<BtPieceMutationResult> {
let group = self.group_mut(gid)?;
if group.bt().is_none() {
return Err(CoreError::InvalidState(
"bt runtime state is not initialized",
));
}
Ok(group.apply_bt_piece_block_update(update))
}
#[expect(
clippy::too_many_arguments,
reason = "BT runtime tick mirrors the RPC-visible counters updated together by one event"
)]
/// Applies a full BT runtime tick, including byte deltas, speeds, timers, and connection count.
pub fn apply_bt_runtime_tick(
&mut self,
gid: DownloadId,
downloaded_delta: u64,
uploaded_delta: u64,
download_speed: u64,
upload_speed: u64,
share_time_delta_secs: u64,
seeding_time_delta_secs: u64,
seeding: bool,
num_connections: Option<u32>,
) -> Result<BtRuntimeTickResult> {
let group = self.group_mut(gid)?;
if group.bt().is_none() {
return Err(CoreError::InvalidState(
"bt runtime state is not initialized",
));
}
Ok(group.apply_bt_runtime_tick(
downloaded_delta,
uploaded_delta,
download_speed,
upload_speed,
share_time_delta_secs,
seeding_time_delta_secs,
seeding,
num_connections,
))
}
/// Advances BT share/seeding timers to `now_unix_secs`.
pub fn tick_bt_runtime_clock(
&mut self,
gid: DownloadId,
now_unix_secs: u64,
seeding: bool,
) -> Result<BtRuntimeTickResult> {
let group = self.group_mut(gid)?;
if group.bt().is_none() {
return Err(CoreError::InvalidState(
"bt runtime state is not initialized",
));
}
Ok(group.tick_bt_runtime_clock(now_unix_secs, seeding))
}
/// Toggles BT seeding state while preserving share-runtime accounting.
pub fn set_bt_seeding_state(
&mut self,
gid: DownloadId,
seeding: bool,
at_unix_secs: Option<u64>,
) -> Result<BtRuntimeTickResult> {
let group = self.group_mut(gid)?;
if group.bt().is_none() {
return Err(CoreError::InvalidState(
"bt runtime state is not initialized",
));
}
Ok(group.set_bt_seeding_state(seeding, at_unix_secs))
}
/// Upserts a BT tracker runtime snapshot keyed by tracker URL.
pub fn apply_bt_tracker_snapshot(
&mut self,
gid: DownloadId,
tracker_url: &str,
tracker_id: Option<String>,
seeders: Option<u32>,
leechers: Option<u32>,
) -> Result<()> {
let group = self.group_mut(gid)?;
let bt = group.bt_mut().ok_or(CoreError::InvalidState(
"bt runtime state is not initialized",
))?;
if let Some(tracker) = bt
.trackers
.iter_mut()
.find(|tracker| tracker.url == tracker_url)
{
if let Some(tracker_id) = tracker_id {
tracker.id = Some(tracker_id);
}
if seeders.is_some() {
tracker.seeders = seeders;
}
if leechers.is_some() {
tracker.leechers = leechers;
}
} else {
bt.trackers.push(BtTrackerInfo {
url: tracker_url.to_owned(),
tier: None,
id: tracker_id,
seeders,
leechers,
});
}
Ok(())
}
/// Records BT byte deltas and timer deltas without changing live speed counters.
pub fn record_bt_runtime_tick(
&mut self,
gid: DownloadId,
downloaded_delta: u64,
uploaded_delta: u64,
share_time_delta_secs: u64,
seeding_time_delta_secs: u64,
seeding: bool,
) -> Result<()> {
let _ = self.apply_bt_runtime_tick(
gid,
downloaded_delta,
uploaded_delta,
0,
0,
share_time_delta_secs,
seeding_time_delta_secs,
seeding,
None,
)?;
Ok(())
}
}
@@ -0,0 +1,369 @@
use super::{
BtPressureSnapshot, BtRuntimeState, CoreError, DownloadEngine, DownloadId, DownloadRegistry,
DownloadStatus, GlobalStat, ProgressSnapshot, RequestGroup, Result, RuntimeConfig,
SchedulerActivityCounters, SchedulerPlanningObservation, SchedulerState, SegmentRuntimeStats,
usize_to_u32, usize_to_u64,
};
/// Runtime metrics for a single download at a specific sampling point.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DownloadRuntimeSnapshot {
/// Download identifier.
pub gid: DownloadId,
/// Current download state.
pub status: DownloadStatus,
/// Total payload length tracked by this snapshot.
pub total_length: u64,
/// Persisted completed length for the download.
pub completed_length: u64,
/// Remaining bytes derived from the tracked total and completed lengths.
pub remaining_length: u64,
/// Effective download throughput after engine-side capping.
pub download_speed: u64,
/// Effective upload throughput after engine-side capping.
pub upload_speed: u64,
/// Effective per-download download cap after global and local policy are merged.
pub effective_download_limit: Option<u64>,
/// Effective per-download upload cap after global and local policy are merged.
pub effective_upload_limit: Option<u64>,
/// Retry counter recorded on the request group.
pub retry_count: u32,
/// Number of active connections the request currently reports.
pub num_connections: u32,
/// Segment planner metrics exported from the request group.
pub segment_stats: SegmentRuntimeStats,
/// `BitTorrent` pressure metrics when the request has BT runtime state.
pub bt_pressure: Option<BtPressureSnapshot>,
}
/// Cross-download runtime counters used by diagnostics and pressure tests.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RuntimeInstrumentationSnapshot {
/// Number of registered downloads.
pub download_count: usize,
/// Number of active downloads.
pub active_download_count: usize,
/// Number of waiting downloads.
pub waiting_download_count: usize,
/// Number of paused or removed downloads still retained in memory.
pub stopped_download_count: usize,
/// Number of errored downloads.
pub error_download_count: usize,
/// Number of completed downloads.
pub complete_download_count: usize,
/// Total count of active or retrying segments across all groups.
pub total_active_segments: usize,
/// Total bytes planned across all runtime segment assignments.
pub total_planned_bytes: u64,
/// Total remaining bytes across all runtime segment assignments.
pub total_remaining_segment_bytes: u64,
/// Aggregate number of requestable BT pieces.
pub total_requestable_pieces: usize,
/// Aggregate number of scarce requestable BT pieces.
pub total_scarce_requestable_pieces: usize,
/// Aggregate number of observed BT peers.
pub total_bt_peers: usize,
/// Configured disk cache capacity in bytes.
pub configured_disk_cache_bytes: u64,
/// Global overall download limit configured in the runtime.
pub max_overall_download_limit: Option<u64>,
/// Global per-download download limit configured in the runtime.
pub max_download_limit: Option<u64>,
/// Global overall upload limit configured in the runtime.
pub max_overall_upload_limit: Option<u64>,
/// Global per-download upload limit configured in the runtime.
pub max_upload_limit: Option<u64>,
/// Current scheduler state snapshot.
pub scheduler_state: SchedulerState,
/// Scheduler activity counters accumulated so far.
pub scheduler_counters: SchedulerActivityCounters,
/// Last recorded planning observation, when available.
pub last_scheduler_plan: Option<SchedulerPlanningObservation>,
}
impl DownloadEngine {
/// Returns the current status of a specific download.
pub fn tell_status(&self, gid: DownloadId) -> Result<DownloadStatus> {
self.registry
.get(gid)
.map(|group| *group.status())
.ok_or(CoreError::UnknownDownloadId(gid))
}
/// Aggregates global counters and capped runtime speeds across all downloads.
#[must_use]
pub fn get_global_stat(&self) -> GlobalStat {
let mut stat = self.global_stat;
let active_count = active_runtime_group_count(&self.registry);
stat.num_active = 0;
stat.num_waiting = 0;
stat.num_stopped = 0;
stat.num_error = 0;
stat.num_complete = 0;
stat.total_length = 0;
stat.completed_length = 0;
stat.download_speed = 0;
stat.upload_speed = 0;
for group in self.registry.groups.values() {
let snapshot = build_progress_snapshot(group, self.runtime(), active_count);
match group.status() {
DownloadStatus::Active => stat.num_active += 1,
DownloadStatus::Waiting => stat.num_waiting += 1,
DownloadStatus::Paused | DownloadStatus::Removed => stat.num_stopped += 1,
DownloadStatus::Error => stat.num_error += 1,
DownloadStatus::Complete => stat.num_complete += 1,
}
stat.total_length = stat.total_length.saturating_add(snapshot.total_length);
stat.completed_length = stat
.completed_length
.saturating_add(snapshot.completed_length);
stat.download_speed = stat.download_speed.saturating_add(snapshot.download_speed);
stat.upload_speed = stat.upload_speed.saturating_add(snapshot.upload_speed);
}
if let Some(limit) = self.runtime().max_overall_download_limit {
stat.download_speed = stat.download_speed.min(limit);
}
if let Some(limit) = self.runtime().max_overall_upload_limit {
stat.upload_speed = stat.upload_speed.min(limit);
}
stat
}
/// Returns the number of registered downloads.
#[must_use]
pub fn download_count(&self) -> usize {
self.registry.len()
}
/// Returns the number of tasks exposed by the engine, matching `download_count`.
#[must_use]
pub fn task_count(&self) -> usize {
self.download_count()
}
/// Returns the gids of currently active downloads.
#[must_use]
pub fn active_downloads(&self) -> Vec<DownloadId> {
self.registry
.groups
.iter()
.filter_map(|(gid, group)| (group.status() == &DownloadStatus::Active).then_some(*gid))
.collect()
}
}
/// Builds a progress snapshot using runtime caps, piece state, and BT-derived metrics.
pub(super) fn build_progress_snapshot(
group: &RequestGroup,
runtime: &RuntimeConfig,
active_count: usize,
) -> ProgressSnapshot {
let piece_length = effective_piece_length(group);
let total_length = infer_total_length(group, piece_length);
let completed_length = completed_length(group, piece_length, total_length);
let bt_selected_payload_length = group.bt_effective_target_length().unwrap_or(0);
let bt_remaining_payload_length = group.bt_remaining_work_length().unwrap_or(0);
let bt_true_seeding = group.bt_is_true_seeding();
let peer_metrics = group.bt_peer_runtime_stats();
let (_, queued, downloading, verified, missing, _) = group.piece_state_counts();
let (download_cap, upload_cap) = effective_speed_caps(group, runtime, active_count);
let download_speed = clamp_speed(
group
.download_speed()
.max(peer_metrics.total_download_speed),
download_cap,
);
let eta_seconds = if download_speed > 0 && total_length > completed_length {
Some((total_length - completed_length).div_ceil(download_speed))
} else {
None
};
ProgressSnapshot {
gid: group.gid(),
status: *group.status(),
total_length,
completed_length,
upload_length: group.upload_length(),
upload_speed: clamp_speed(
group.upload_speed().max(peer_metrics.total_upload_speed),
upload_cap,
),
download_speed,
num_connections: group
.num_connections()
.max(usize_to_u32(peer_metrics.peer_count)),
eta_seconds,
seeding: bt_true_seeding,
share_ratio_milli: compute_share_ratio_milli(group, completed_length),
share_time_secs: group.bt_share_time_secs(),
seeding_time_secs: group.bt_seeding_time_secs(),
bt_selected_payload_length,
bt_remaining_payload_length,
bt_true_seeding,
bt_total_peers: usize_to_u32(peer_metrics.peer_count),
bt_seeders: usize_to_u32(peer_metrics.seeder_count),
bt_leechers: usize_to_u32(peer_metrics.leecher_count),
bt_available_pieces: usize_to_u32(group.bt_available_piece_count()),
bt_verified_pieces: usize_to_u32(verified),
bt_downloading_pieces: usize_to_u32(downloading),
bt_queued_pieces: usize_to_u32(queued),
bt_missing_pieces: usize_to_u32(missing),
}
}
/// Counts active runtime groups, returning at least `1` for cap sharing math.
pub(super) fn active_runtime_group_count(registry: &DownloadRegistry) -> usize {
registry
.groups
.values()
.filter(|group| {
matches!(
group.status(),
DownloadStatus::Active | DownloadStatus::Waiting
)
})
.count()
.max(1)
}
/// Computes effective download and upload caps by combining global and per-group settings.
pub(super) fn effective_speed_caps(
group: &RequestGroup,
runtime: &RuntimeConfig,
active_count: usize,
) -> (Option<u64>, Option<u64>) {
let active_count = usize_to_u64(active_count.max(1));
let overall_download_share = runtime
.max_overall_download_limit
.and_then(|limit| limit.checked_div(active_count))
.map(|limit| limit.max(1));
let overall_upload_share = runtime
.max_overall_upload_limit
.and_then(|limit| limit.checked_div(active_count))
.map(|limit| limit.max(1));
let download_cap = combine_caps(
overall_download_share,
group
.option_limit("max-download-limit")
.or(runtime.max_download_limit),
);
let upload_cap = combine_caps(
overall_upload_share,
group
.option_limit("max-upload-limit")
.or(runtime.max_upload_limit),
);
(download_cap, upload_cap)
}
/// Intersects two optional bandwidth caps.
fn combine_caps(left: Option<u64>, right: Option<u64>) -> Option<u64> {
match (left, right) {
(Some(left), Some(right)) => Some(left.min(right)),
(Some(left), None) => Some(left),
(None, Some(right)) => Some(right),
(None, None) => None,
}
}
/// Applies an optional cap to a runtime speed sample.
pub(super) fn clamp_speed(value: u64, cap: Option<u64>) -> u64 {
cap.map_or(value, |limit| value.min(limit))
}
/// Returns the piece length used for segment and progress math, clamped to at least `1`.
pub(super) fn effective_piece_length(group: &RequestGroup) -> u64 {
group.piece_length().max(1)
}
/// Infers a request's total length from explicit metadata or known piece state.
pub(super) fn infer_total_length(group: &RequestGroup, piece_length: u64) -> u64 {
if group.total_length() > 0 {
return group.total_length();
}
group
.piece_map()
.iter()
.map(|(piece, _)| {
u64::from(piece.0)
.saturating_add(1)
.saturating_mul(piece_length)
})
.max()
.unwrap_or(0)
}
/// Computes the most trustworthy completed length for progress reporting.
fn completed_length(group: &RequestGroup, piece_length: u64, total_length: u64) -> u64 {
let reported = if total_length > 0 {
group.completed_length().min(total_length)
} else {
group.completed_length()
};
let from_verified = group
.piece_map()
.iter()
.filter(|(_, state)| **state == crate::piece::PieceState::Verified)
.map(|(piece, _)| {
let start = u64::from(piece.0).saturating_mul(piece_length);
if total_length == 0 {
piece_length
} else {
total_length.saturating_sub(start).min(piece_length)
}
})
.sum::<u64>();
let merged = reported.max(from_verified);
match group.status() {
DownloadStatus::Complete if total_length > 0 && completion_is_trustworthy(group) => {
total_length
}
_ => merged.min(total_length.max(merged)),
}
}
/// Returns whether a completed group can safely report its entire total length as finished.
fn completion_is_trustworthy(group: &RequestGroup) -> bool {
if let Some(bt) = group.bt() {
if bt.metadata_only {
return false;
}
let selected_total = bt_selected_total_length(bt);
if selected_total > 0 && group.completed_length() < selected_total {
return false;
}
}
true
}
/// Sums the selected BT payload length across all torrent files.
fn bt_selected_total_length(bt: &BtRuntimeState) -> u64 {
bt.files
.iter()
.filter(|file| file.selected)
.fold(0_u64, |acc, file| acc.saturating_add(file.length))
}
/// Computes the BT share ratio in milli-units from live upload and effective payload size.
fn compute_share_ratio_milli(group: &RequestGroup, completed_length: u64) -> Option<u64> {
if let Some(ratio) = group.bt_share_ratio_milli() {
return Some(ratio);
}
let bt = group.bt()?;
let denominator = group
.bt_share_ratio_base_length()
.unwrap_or_else(|| bt_selected_total_length(bt).max(completed_length));
if denominator == 0 {
return None;
}
Some(
group
.upload_length()
.saturating_mul(1000)
.saturating_div(denominator),
)
}
@@ -0,0 +1,204 @@
use super::{
CoreError, DownloadEngine, DownloadId, DownloadStatus, QueuePositionMode, Result, RuntimeEvent,
RuntimeEventKind, usize_to_i64,
};
use std::cmp::Ordering;
impl DownloadEngine {
/// Pauses an active or waiting download and keeps it in the reserved queue.
pub fn pause(&mut self, gid: DownloadId) -> Result<()> {
let status = self
.registry
.get(gid)
.map(|group| *group.status())
.ok_or(CoreError::UnknownDownloadId(gid))?;
let was_active = matches!(status, DownloadStatus::Active);
if !matches!(status, DownloadStatus::Active | DownloadStatus::Waiting) {
return Err(CoreError::InvalidState("download cannot be paused now"));
}
let group = self.group_mut(gid)?;
group.set_status(DownloadStatus::Paused);
if was_active {
self.enqueue_reserved_front(gid);
} else if !self.is_in_reserved_queue(gid) {
self.enqueue_reserved_back(gid);
}
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadPaused).with_gid(gid));
Ok(())
}
/// Resumes a paused download by moving it back into the waiting state.
pub fn resume(&mut self, gid: DownloadId) -> Result<()> {
let status = self
.registry
.get(gid)
.map(|group| *group.status())
.ok_or(CoreError::UnknownDownloadId(gid))?;
if !matches!(status, DownloadStatus::Paused) {
return Err(CoreError::InvalidState("download cannot be unpaused now"));
}
let group = self.group_mut(gid)?;
group.set_status(DownloadStatus::Waiting);
if !self.is_in_reserved_queue(gid) {
self.enqueue_reserved_back(gid);
}
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadResumed).with_gid(gid));
Ok(())
}
/// Marks a download as removed and assigns it a stopped sequence.
pub fn remove(&mut self, gid: DownloadId) -> Result<()> {
self.remove_from_reserved_queue(gid);
self.transition_to_stopped_status(gid, DownloadStatus::Removed)?;
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadRemoved).with_gid(gid));
Ok(())
}
/// Permanently removes a stopped download result from the registry.
pub fn remove_download_result(&mut self, gid: DownloadId) -> Result<()> {
let Some(group) = self.registry.get(gid) else {
return Err(CoreError::UnknownDownloadId(gid));
};
if !matches!(
group.status(),
DownloadStatus::Complete | DownloadStatus::Removed | DownloadStatus::Error
) {
return Err(CoreError::InvalidState(
"download result is not available for active or waiting downloads",
));
}
self.registry.remove(gid);
Ok(())
}
/// Repositions a waiting download within the reserved queue.
pub fn change_position(
&mut self,
gid: DownloadId,
offset: i64,
mode: QueuePositionMode,
) -> Result<usize> {
let Some(current_index) = self
.reserved_queue
.iter()
.position(|candidate| *candidate == gid)
else {
return Err(CoreError::InvalidState(
"download is not in the waiting queue",
));
};
let size = usize_to_i64(self.reserved_queue.len());
let current = usize_to_i64(current_index);
let mut dest = match mode {
QueuePositionMode::Set => offset,
QueuePositionMode::Cur => current.saturating_add(offset),
QueuePositionMode::End => size.saturating_sub(1).saturating_add(offset),
};
dest = dest.clamp(0, size.saturating_sub(1));
let dest_index = usize::try_from(dest).unwrap_or_default();
match current_index.cmp(&dest_index) {
Ordering::Less => {
let Some(window) = self.reserved_queue.get_mut(current_index..=dest_index) else {
return Err(CoreError::InvalidState(
"download is not in the waiting queue",
));
};
window.rotate_left(1);
}
Ordering::Greater => {
let Some(window) = self.reserved_queue.get_mut(dest_index..=current_index) else {
return Err(CoreError::InvalidState(
"download is not in the waiting queue",
));
};
window.rotate_right(1);
}
Ordering::Equal => {}
}
Ok(dest_index)
}
/// Removes every stopped download result and returns the number removed.
pub fn purge_download_results(&mut self) -> usize {
let stopped = self
.registry
.groups
.iter()
.filter_map(|(gid, group)| {
matches!(
group.status(),
DownloadStatus::Complete | DownloadStatus::Removed | DownloadStatus::Error
)
.then_some(*gid)
})
.collect::<Vec<_>>();
let removed = stopped.len();
for gid in stopped {
let _ = self.registry.remove(gid);
}
removed
}
/// Marks a download as complete and emits the completion event.
pub fn complete(&mut self, gid: DownloadId) -> Result<()> {
self.remove_from_reserved_queue(gid);
self.transition_to_stopped_status(gid, DownloadStatus::Complete)?;
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadCompleted).with_gid(gid));
Ok(())
}
/// Marks a download as errored and emits the failure event.
pub fn fail(&mut self, gid: DownloadId) -> Result<()> {
self.remove_from_reserved_queue(gid);
self.transition_to_stopped_status(gid, DownloadStatus::Error)?;
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadErrored).with_gid(gid));
Ok(())
}
/// Assigns a stopped sequence and terminal status to a request group.
fn transition_to_stopped_status(
&mut self,
gid: DownloadId,
status: DownloadStatus,
) -> Result<()> {
let sequence = self.next_stopped_sequence;
self.next_stopped_sequence = self.next_stopped_sequence.saturating_add(1);
let group = self.group_mut(gid)?;
group.set_stopped_sequence(Some(sequence));
group.set_status(status);
Ok(())
}
/// Returns whether `gid` currently appears in the reserved queue.
pub(super) fn is_in_reserved_queue(&self, gid: DownloadId) -> bool {
self.reserved_queue.contains(&gid)
}
/// Removes `gid` from the reserved queue when present.
pub(super) fn remove_from_reserved_queue(&mut self, gid: DownloadId) {
if let Some(index) = self
.reserved_queue
.iter()
.position(|candidate| *candidate == gid)
{
self.reserved_queue.remove(index);
}
}
/// Places `gid` at the back of the reserved queue, removing older duplicates first.
pub(super) fn enqueue_reserved_back(&mut self, gid: DownloadId) {
self.remove_from_reserved_queue(gid);
self.reserved_queue.push(gid);
}
/// Places `gid` at the front of the reserved queue, removing older duplicates first.
pub(super) fn enqueue_reserved_front(&mut self, gid: DownloadId) {
self.remove_from_reserved_queue(gid);
self.reserved_queue.insert(0, gid);
}
}
@@ -0,0 +1,167 @@
use std::time::{SystemTime, UNIX_EPOCH};
use super::{
CoreError, DownloadEngine, DownloadId, DownloadStatus, RequestGroup, Result, RetryAttempt,
RetryHistoryEntry, RuntimeConfig, RuntimeEvent, RuntimeEventKind, ScheduleDecision,
SegmentAssignment, SegmentState, effective_piece_length, infer_total_length, usize_to_u64,
};
impl DownloadEngine {
/// Advances the scheduler clock and emits a scheduler tick event.
pub fn scheduler_tick(&mut self) -> Result<()> {
let _ = self.scheduler.tick();
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::SchedulerTick));
Ok(())
}
/// Prepares a single HTTP download by applying the scheduler decision for its current state.
pub fn prepare_http_download(&mut self, gid: DownloadId) -> Result<RequestGroup> {
let decision = {
let group = self
.registry
.get(gid)
.ok_or(CoreError::UnknownDownloadId(gid))?;
match group.status() {
DownloadStatus::Waiting => ScheduleDecision::Queue(gid),
DownloadStatus::Active => ScheduleDecision::RunNow(gid),
DownloadStatus::Error => ScheduleDecision::RetryLater(gid),
DownloadStatus::Paused | DownloadStatus::Complete | DownloadStatus::Removed => {
ScheduleDecision::Noop
}
}
};
self.apply_schedule_decision(gid, &decision);
self.registry
.get(gid)
.cloned()
.ok_or(CoreError::UnknownDownloadId(gid))
}
/// Runs one scheduler pass and returns the first actionable decision.
#[must_use]
pub fn schedule_once(&mut self) -> ScheduleDecision {
self.scheduler.record_schedule_run();
let _ = self.scheduler.tick();
let mut gids = self
.registry
.groups
.iter()
.filter_map(|(gid, group)| (group.status() == &DownloadStatus::Active).then_some(*gid))
.collect::<Vec<_>>();
gids.sort_by_key(|gid| gid.as_u64());
gids.extend(
self.reserved_queue
.iter()
.copied()
.filter(|gid| self.registry.get(*gid).is_some()),
);
let mut retry_gids = self
.registry
.groups
.iter()
.filter_map(|(gid, group)| (group.status() == &DownloadStatus::Error).then_some(*gid))
.collect::<Vec<_>>();
retry_gids.sort_by_key(|gid| gid.as_u64());
gids.extend(retry_gids);
for gid in gids {
let Some(group) = self.registry.get(gid) else {
continue;
};
let decision = self.scheduler.decide(group);
match decision {
ScheduleDecision::RunNow(_)
| ScheduleDecision::Queue(_)
| ScheduleDecision::RetryLater(_) => {
self.apply_schedule_decision(gid, &decision);
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::SchedulerTick).with_gid(gid));
return decision;
}
ScheduleDecision::Pause(_) | ScheduleDecision::Remove(_) => {
self.scheduler.record_decision(&decision);
return decision;
}
ScheduleDecision::Noop => {}
}
}
self.scheduler.record_decision(&ScheduleDecision::Noop);
ScheduleDecision::Noop
}
}
/// Converts retry attempts into scheduler-facing retry-history entries.
pub(super) fn retry_history_from_group(attempts: &[RetryAttempt]) -> Vec<RetryHistoryEntry> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_secs());
attempts
.iter()
.map(|attempt| RetryHistoryEntry {
at_unix_secs: now,
reason: attempt.error.clone().unwrap_or_else(|| "retry".to_string()),
})
.collect()
}
/// Builds runtime segment assignments for the scheduler-selected active segment count.
pub(super) fn build_segment_assignments(
group: &RequestGroup,
runtime: &RuntimeConfig,
desired_segments: usize,
) -> Vec<SegmentAssignment> {
if desired_segments == 0 {
return Vec::new();
}
let total_length = infer_total_length(group, effective_piece_length(group));
let start_offset = group
.resume_state()
.map_or(0, |state| state.resume_offset)
.max(group.completed_length())
.min(total_length);
let remaining = total_length.saturating_sub(start_offset);
if remaining == 0 {
return Vec::new();
}
let piece_length = effective_piece_length(group);
let alignment = piece_length.max(runtime.min_split_size.max(1));
let segment_count = desired_segments
.min(
usize::try_from(remaining.div_ceil(runtime.min_split_size.max(1)))
.unwrap_or(usize::MAX),
)
.max(1);
let target_span = remaining.div_ceil(usize_to_u64(segment_count));
let mut cursor = start_offset;
let mut assignments = Vec::with_capacity(segment_count);
for slot in 0..segment_count {
if cursor >= total_length {
break;
}
let end = if slot + 1 == segment_count {
total_length
} else {
let raw_end = cursor.saturating_add(target_span).min(total_length);
let aligned_end = raw_end
.div_ceil(alignment)
.saturating_mul(alignment)
.min(total_length);
aligned_end.max(cursor.saturating_add(1))
};
let mut assignment =
SegmentAssignment::new(slot, crate::piece::PieceRange::new(cursor, end));
assignment.state = SegmentState::Active;
assignments.push(assignment);
cursor = end;
}
assignments
}
@@ -0,0 +1,621 @@
use super::{
BtFileInfo, BtRuntimeState, ControlFileVersion, ControlMetadata, DownloadEngine, DownloadId,
DownloadRegistry, DownloadStatus, OptionKey, OptionValue, Path, PathBuf, RequestContext,
RequestGroup, SessionFile, SessionFileEntry, StoredDownloadFile, StoredPieceIndex,
StoredPieceState, infer_total_length, usize_to_u32,
};
use std::collections::BTreeMap;
use crate::{ResumeState, RetryAttempt};
impl DownloadEngine {
/// Builds a serializable session file from every persistable group in the registry.
pub(super) fn build_session_file(&self, session_path: &Path) -> SessionFile {
let entries = self
.registry
.groups
.values()
.filter(|group| should_persist_group(*group.status()))
.map(|group| build_session_entry(group, self.session.global_options(), session_path))
.collect();
SessionFile { entries }
}
/// Reconstructs the in-memory registry and waiting queue from a persisted session file.
pub(super) fn rebuild_registry_from_session_file(
&mut self,
session_path: &Path,
session_file: SessionFile,
) {
let mut registry = DownloadRegistry::new();
let mut reserved_queue = Vec::new();
let mut max_gid = 0_u64;
for entry in session_file.entries {
let mut context = RequestContext::new(entry.uri.clone());
if entry.uris.is_empty() {
context.replace_uris(vec![entry.uri.clone()]);
} else {
context.replace_uris(entry.uris.clone());
}
let mut group = if let Some(gid) = DownloadId::parse_hex(&entry.gid) {
max_gid = max_gid.max(gid.as_u64());
RequestGroup::with_context(gid, context)
} else {
let gid = registry.allocate_gid();
max_gid = max_gid.max(gid.as_u64());
RequestGroup::with_context(gid, context)
};
restore_group_metadata(&mut group, entry.metadata);
let control_path = entry
.metadata_path
.unwrap_or_else(|| control_path_for_session_entry(session_path, group.gid()));
if let Ok(control) = aria2_rust_pro_storage::read_aria2_control_file(&control_path) {
restore_control_metadata(&mut group, control);
}
if matches!(
group.status(),
DownloadStatus::Waiting | DownloadStatus::Paused
) {
reserved_queue.push(group.gid());
}
registry.insert(group);
}
registry.next_gid = max_gid.saturating_add(1).max(1);
self.registry = registry;
self.reserved_queue = reserved_queue;
}
}
/// Returns whether a group status should be persisted into session artifacts.
pub(super) fn should_persist_group(status: DownloadStatus) -> bool {
!matches!(status, DownloadStatus::Complete | DownloadStatus::Removed)
}
/// Builds one persisted session entry from a request group.
fn build_session_entry(
group: &RequestGroup,
global_options: &crate::session::GlobalOptions,
session_path: &Path,
) -> SessionFileEntry {
let target_path = resolve_target_path(group, global_options);
let metadata = Some(build_group_metadata(group));
SessionFileEntry {
gid: group.gid().to_string(),
uri: group.uri().to_owned(),
uris: group.uris().to_vec(),
target_path,
metadata_path: Some(control_path_for_session_entry(session_path, group.gid())),
metadata,
}
}
/// Serializes selected request-group runtime metadata into session-file string fields.
fn build_group_metadata(group: &RequestGroup) -> BTreeMap<String, String> {
let mut metadata = BTreeMap::from([
(
"status".to_owned(),
group.status().as_rpc_status().to_owned(),
),
(
"num_connections".to_owned(),
group.num_connections().to_string(),
),
(
"download_speed".to_owned(),
group.download_speed().to_string(),
),
(
"upload_length".to_owned(),
group.upload_length().to_string(),
),
(
"completed_length".to_owned(),
group.completed_length().to_string(),
),
("retry_count".to_owned(), group.retry_count().to_string()),
]);
if let Some(resume_state) = group.resume_state() {
metadata.insert(
"resume_state".to_owned(),
encode_resume_state_metadata(resume_state),
);
}
if !group.retry_attempts().is_empty() {
metadata.insert(
"retry_attempts".to_owned(),
encode_retry_attempts_metadata(group.retry_attempts()),
);
}
for (key, value) in group.options().entries() {
metadata.insert(format!("opt.{}", key.as_str()), option_value_text(value));
}
if let Some(bt) = group.bt() {
metadata.insert("bt.info_hash".to_owned(), bt.info_hash.clone());
metadata.insert("bt.metadata_only".to_owned(), bt.metadata_only.to_string());
if let Some(name) = &bt.name {
metadata.insert("bt.name".to_owned(), escape_metadata_field(name));
}
if let Some(magnet_uri) = &bt.magnet_uri {
metadata.insert(
"bt.magnet_uri".to_owned(),
escape_metadata_field(magnet_uri),
);
}
if let Some(creation_date) = &bt.creation_date {
metadata.insert(
"bt.creation_date".to_owned(),
escape_metadata_field(creation_date),
);
}
if let Some(comment) = &bt.comment {
metadata.insert("bt.comment".to_owned(), escape_metadata_field(comment));
}
metadata.insert("bt.files_count".to_owned(), bt.files.len().to_string());
for (index, file) in bt.files.iter().enumerate() {
metadata.insert(
format!("bt.file.{index}.path"),
escape_metadata_field(&file.path),
);
metadata.insert(format!("bt.file.{index}.length"), file.length.to_string());
metadata.insert(
format!("bt.file.{index}.selected"),
file.selected.to_string(),
);
if let Some(piece_offset) = file.piece_offset {
metadata.insert(
format!("bt.file.{index}.piece_offset"),
piece_offset.to_string(),
);
}
}
}
metadata
}
/// Resolves the output path that should be associated with a request group.
pub(super) fn resolve_target_path(
group: &RequestGroup,
global_options: &crate::session::GlobalOptions,
) -> PathBuf {
let dir = group
.options()
.get(&OptionKey::from("dir"))
.or_else(|| global_options.get(&OptionKey::from("dir")))
.and_then(OptionValue::as_text)
.map(PathBuf::from);
let file_name = group
.options()
.get(&OptionKey::from("out"))
.and_then(OptionValue::as_text)
.map(str::to_owned)
.or_else(|| uri_file_name(group.uri()))
.unwrap_or_else(|| group.gid().to_string());
match dir {
Some(dir) => dir.join(file_name),
None => PathBuf::from(file_name),
}
}
/// Extracts a best-effort file name from a URI path component.
fn uri_file_name(uri: &str) -> Option<String> {
let trimmed = uri
.split(['?', '#'])
.next()
.unwrap_or(uri)
.trim_end_matches('/');
let candidate = trimmed.rsplit('/').next()?;
if candidate.is_empty() {
None
} else {
Some(candidate.to_owned())
}
}
/// Resolves the per-download control-file path relative to a session file path.
pub(super) fn control_path_for_session_entry(session_path: &Path, gid: DownloadId) -> PathBuf {
let parent = session_path
.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
parent.join("control").join(format!("{gid}.aria2"))
}
/// Builds persisted control-file metadata for a request group.
pub(super) fn build_control_metadata(
group: &RequestGroup,
target_path: &Path,
piece_length: u64,
) -> ControlMetadata {
let resolved_piece_length = group.piece_length().max(piece_length);
let inferred_total_length = infer_total_length(group, resolved_piece_length);
ControlMetadata {
version: ControlFileVersion::CURRENT,
files: vec![StoredDownloadFile {
path: target_path.to_path_buf(),
length: inferred_total_length,
piece_length: resolved_piece_length,
}],
checksums: Vec::new(),
completed_length: group.completed_length(),
retry_count: group.retry_count(),
last_error: group
.retry_attempts()
.last()
.and_then(|attempt| attempt.error.clone()),
last_error_at_unix_ms: None,
last_retry_at_unix_ms: None,
next_retry_at_unix_ms: None,
consecutive_failure_count: Some(usize_to_u32(group.retry_attempts().len())),
active_segment_count: Some(group.num_connections()),
resume_verified_at_unix_ms: None,
resume_generation: group
.resume_state()
.and_then(|resume_state| resume_state.persisted.then_some(1)),
piece_states: group
.piece_map()
.iter()
.map(|(piece, state)| {
(
StoredPieceIndex(piece.0),
map_piece_state_to_storage(*state),
)
})
.collect(),
}
}
/// Maps in-memory piece states into storage-layer piece states.
fn map_piece_state_to_storage(state: crate::piece::PieceState) -> StoredPieceState {
match state {
crate::piece::PieceState::Verified => StoredPieceState::Verified,
crate::piece::PieceState::Queued | crate::piece::PieceState::Downloading => {
StoredPieceState::InFlight
}
crate::piece::PieceState::Pending
| crate::piece::PieceState::Missing
| crate::piece::PieceState::Skipped => StoredPieceState::Pending,
}
}
/// Maps storage-layer piece states back into in-memory piece states.
fn map_piece_state_from_storage(state: StoredPieceState) -> crate::piece::PieceState {
match state {
StoredPieceState::Pending => crate::piece::PieceState::Pending,
StoredPieceState::InFlight => crate::piece::PieceState::Downloading,
StoredPieceState::Verified => crate::piece::PieceState::Verified,
StoredPieceState::Failed => crate::piece::PieceState::Missing,
}
}
/// Serializes an option value into the session metadata text format.
fn option_value_text(value: &OptionValue) -> String {
match value {
OptionValue::Bool(value) => value.to_string(),
OptionValue::Int(value) => value.to_string(),
OptionValue::UInt(value) => value.to_string(),
OptionValue::Text(value) => value.clone(),
OptionValue::List(value) => value.join(","),
OptionValue::Map(value) => value
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>()
.join(","),
OptionValue::Empty => String::default(),
}
}
/// Restores request-group runtime metadata from persisted session metadata fields.
fn restore_group_metadata(group: &mut RequestGroup, metadata: Option<BTreeMap<String, String>>) {
let Some(metadata) = metadata else {
return;
};
let mut bt = BtRuntimeState::default();
let mut bt_seen = false;
let mut bt_files: BTreeMap<usize, BtFileInfo> = BTreeMap::new();
for (key, value) in metadata {
if restore_group_metadata_field(group, &key, &value) {
continue;
}
if restore_bt_metadata_field(&mut bt, &mut bt_files, &key, &value) {
bt_seen = true;
}
}
if bt_seen {
bt.files = bt_files.into_values().collect();
group.set_bt(bt);
}
}
/// Restores one non-BitTorrent metadata field onto a request group.
fn restore_group_metadata_field(group: &mut RequestGroup, key: &str, value: &str) -> bool {
match key {
"status" => {
if let Some(status) = parse_status(value) {
group.set_status(status);
}
true
}
"num_connections" => {
if let Ok(parsed) = value.parse::<u32>() {
group.set_num_connections(parsed);
}
true
}
"download_speed" => {
if let Ok(parsed) = value.parse::<u64>() {
group.set_download_speed(parsed);
}
true
}
"upload_length" => {
if let Ok(parsed) = value.parse::<u64>() {
group.set_upload_length(parsed);
}
true
}
"completed_length" => {
if let Ok(parsed) = value.parse::<u64>() {
group.set_completed_length(parsed);
}
true
}
"retry_count" => {
if let Ok(parsed) = value.parse::<u32>() {
group.set_retry_count(parsed);
}
true
}
"resume_state" => {
if let Some(parsed) = decode_resume_state_metadata(value) {
group.set_resume_state(parsed);
}
true
}
"retry_attempts" => {
group.set_retry_attempts(decode_retry_attempts_metadata(value));
true
}
_ => key.strip_prefix("opt.").is_some_and(|option_key| {
group.set_option(option_key.to_owned(), value.to_owned());
true
}),
}
}
/// Restores one `BitTorrent` metadata field.
fn restore_bt_metadata_field(
bt: &mut BtRuntimeState,
bt_files: &mut BTreeMap<usize, BtFileInfo>,
key: &str,
value: &str,
) -> bool {
match key {
"bt.info_hash" => {
value.clone_into(&mut bt.info_hash);
true
}
"bt.metadata_only" => {
bt.metadata_only = value.parse::<bool>().unwrap_or(false);
true
}
"bt.name" => {
bt.name = Some(unescape_metadata_field(value));
true
}
"bt.magnet_uri" => {
bt.magnet_uri = Some(unescape_metadata_field(value));
true
}
"bt.creation_date" => {
bt.creation_date = Some(unescape_metadata_field(value));
true
}
"bt.comment" => {
bt.comment = Some(unescape_metadata_field(value));
true
}
_ => key
.strip_prefix("bt.file.")
.is_some_and(|rest| restore_bt_file_metadata_field(bt_files, rest, value)),
}
}
/// Restores one `BitTorrent` file metadata field.
fn restore_bt_file_metadata_field(
bt_files: &mut BTreeMap<usize, BtFileInfo>,
rest: &str,
value: &str,
) -> bool {
let mut parts = rest.split('.');
let Some(index_raw) = parts.next() else {
return false;
};
let Some(field) = parts.next() else {
return false;
};
if parts.next().is_some() {
return false;
}
let Ok(index) = index_raw.parse::<usize>() else {
return false;
};
let file = bt_files.entry(index).or_default();
match field {
"path" => file.path = unescape_metadata_field(value),
"length" => {
if let Ok(parsed) = value.parse::<u64>() {
file.length = parsed;
}
}
"selected" => {
file.selected = value.parse::<bool>().unwrap_or(false);
}
"piece_offset" => {
if let Ok(parsed) = value.parse::<u64>() {
file.piece_offset = Some(parsed);
}
}
_ => {}
}
true
}
/// Restores request-group progress and piece state from persisted control metadata.
fn restore_control_metadata(group: &mut RequestGroup, control: ControlMetadata) {
if let Some(file) = control.files.first() {
group.set_total_length(file.length);
group.set_piece_length(file.piece_length);
}
group.set_completed_length(control.completed_length);
group.set_retry_count(control.retry_count);
if control.retry_count > 0 && group.retry_attempts().is_empty() {
let mut attempt = RetryAttempt::new(control.retry_count, control.completed_length);
attempt.length = Some(control.completed_length);
attempt.error.clone_from(&control.last_error);
attempt.recoverable = true;
group.push_retry_attempt(attempt);
}
if control.completed_length > 0 || control.resume_generation.is_some() {
group.set_resume_state(ResumeState {
persisted: control.resume_generation.is_some(),
resume_offset: control.completed_length,
validated_length: Some(control.completed_length),
segment_cursor: None,
});
}
for (piece, state) in control.piece_states {
group.set_piece_state(
crate::piece::PieceId(piece.0),
map_piece_state_from_storage(state),
);
}
}
/// Encodes retry attempts into a compact session-metadata string.
fn encode_retry_attempts_metadata(attempts: &[RetryAttempt]) -> String {
attempts
.iter()
.map(|attempt| {
let length = attempt
.length
.map_or_else(|| "-".to_owned(), |value| value.to_string());
let error = attempt.error.as_deref().unwrap_or("-");
format!(
"{}:{}:{}:{}:{}",
attempt.attempt,
attempt.offset,
length,
u8::from(attempt.recoverable),
escape_metadata_field(error)
)
})
.collect::<Vec<_>>()
.join(",")
}
/// Decodes retry attempts from the compact session-metadata string.
fn decode_retry_attempts_metadata(raw: &str) -> Vec<RetryAttempt> {
raw.split(',')
.filter(|entry| !entry.trim().is_empty())
.filter_map(|entry| {
let mut parts = entry.splitn(5, ':');
let attempt = parts.next()?.parse().ok()?;
let offset = parts.next()?.parse().ok()?;
let length = match parts.next()? {
"-" => None,
value => value.parse().ok(),
};
let recoverable = matches!(parts.next()?, "1" | "true");
let error = match parts.next()? {
"-" => None,
value => Some(unescape_metadata_field(value)),
};
Some(RetryAttempt {
attempt,
offset,
length,
error,
recoverable,
})
})
.collect()
}
/// Encodes resume-state metadata into a compact session-metadata string.
fn encode_resume_state_metadata(resume_state: &ResumeState) -> String {
let validated_length = resume_state
.validated_length
.map_or_else(|| "-".to_owned(), |value| value.to_string());
let segment_cursor = resume_state
.segment_cursor
.map_or_else(|| "-".to_owned(), |piece| piece.0.to_string());
format!(
"{}:{}:{}:{}",
u8::from(resume_state.persisted),
resume_state.resume_offset,
validated_length,
segment_cursor
)
}
/// Decodes resume-state metadata from the compact session-metadata string.
fn decode_resume_state_metadata(raw: &str) -> Option<ResumeState> {
let mut parts = raw.splitn(4, ':');
let persisted = matches!(parts.next()?, "1" | "true");
let resume_offset = parts.next()?.parse().ok()?;
let validated_length = match parts.next()? {
"-" => None,
value => value.parse().ok(),
};
let segment_cursor = match parts.next()? {
"-" => None,
value => value.parse().ok().map(crate::piece::PieceId),
};
Some(ResumeState {
persisted,
resume_offset,
validated_length,
segment_cursor,
})
}
/// Escapes reserved delimiters used by compact metadata encodings.
fn escape_metadata_field(raw: &str) -> String {
raw.replace('\\', "\\\\")
.replace(',', "\\c")
.replace(':', "\\d")
}
/// Reverses `escape_metadata_field`.
fn unescape_metadata_field(raw: &str) -> String {
let mut out = String::new();
let mut chars = raw.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.next() {
Some('c') => out.push(','),
Some('d') => out.push(':'),
Some('\\') | None => out.push('\\'),
Some(other) => {
out.push('\\');
out.push(other);
}
}
} else {
out.push(ch);
}
}
out
}
/// Parses the persisted RPC-style download status string.
fn parse_status(value: &str) -> Option<DownloadStatus> {
match value {
"active" => Some(DownloadStatus::Active),
"waiting" => Some(DownloadStatus::Waiting),
"paused" => Some(DownloadStatus::Paused),
"error" => Some(DownloadStatus::Error),
"complete" => Some(DownloadStatus::Complete),
"removed" => Some(DownloadStatus::Removed),
_ => None,
}
}
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
//! Error codes and error values emitted by the core crate.
use std::fmt::{Display, Formatter};
use crate::request::DownloadId;
/// Standard result type returned by core APIs.
pub type Result<T> = std::result::Result<T, CoreError>;
/// Stable error categories for mapping runtime failures to RPC-facing codes.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ErrorCode {
/// The requested operation is not supported by the current implementation.
Unsupported,
/// The supplied download id does not resolve to a tracked request group.
UnknownDownload,
/// The requested state transition is not valid for the current runtime state.
InvalidState,
/// The runtime is shutting down and cannot accept the requested operation.
ShutdownInProgress,
/// A required persistence or storage action failed.
StorageUnavailable,
}
/// Concrete errors returned by the core engine and state surfaces.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CoreError {
/// The referenced download id is not present in the registry.
UnknownDownloadId(DownloadId),
/// The caller requested a feature that is not yet implemented.
UnsupportedOperation(&'static str),
/// The caller requested an invalid state transition or runtime action.
InvalidState(&'static str),
/// Shutdown has started and the runtime is no longer accepting work.
ShutdownInProgress,
/// Session or control-file storage was unavailable.
StorageUnavailable(&'static str),
}
impl CoreError {
/// Returns the stable error code associated with this error value.
#[must_use]
pub const fn code(&self) -> ErrorCode {
match self {
Self::UnknownDownloadId(_) => ErrorCode::UnknownDownload,
Self::UnsupportedOperation(_) => ErrorCode::Unsupported,
Self::InvalidState(_) => ErrorCode::InvalidState,
Self::ShutdownInProgress => ErrorCode::ShutdownInProgress,
Self::StorageUnavailable(_) => ErrorCode::StorageUnavailable,
}
}
}
impl Display for CoreError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownDownloadId(gid) => write!(f, "unknown download id: {gid}"),
Self::UnsupportedOperation(msg) => write!(f, "unsupported operation: {msg}"),
Self::InvalidState(msg) => write!(f, "invalid runtime state: {msg}"),
Self::ShutdownInProgress => write!(f, "engine shutdown is in progress"),
Self::StorageUnavailable(msg) => write!(f, "storage unavailable: {msg}"),
}
}
}
impl std::error::Error for CoreError {}
+143
View File
@@ -0,0 +1,143 @@
//! Runtime event definitions and the in-memory event bus.
use std::collections::VecDeque;
use crate::{progress::ProgressSnapshot, request::DownloadId};
/// Event categories emitted by the engine as downloads and sessions evolve.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RuntimeEventKind {
/// A download was added to the registry.
DownloadAdded,
/// A download moved into the active set.
DownloadStarted,
/// A download was paused.
DownloadPaused,
/// A paused download was resumed.
DownloadResumed,
/// A download was removed.
DownloadRemoved,
/// A download completed successfully.
DownloadCompleted,
/// A download entered the error state.
DownloadErrored,
/// Global or per-download options changed.
OptionChanged,
/// Session persistence is starting.
SessionSaving,
/// Session persistence finished.
SessionSaved,
/// Graceful shutdown was requested.
ShutdownRequested,
/// Forced shutdown was requested.
ForceShutdownRequested,
/// The scheduler advanced a planning tick.
SchedulerTick,
/// Aggregated statistics were refreshed.
StatisticsUpdated,
/// Piece-level state changed.
PieceUpdated,
}
/// Runtime event payload queued by the in-memory event bus.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RuntimeEvent {
/// The event category.
pub kind: RuntimeEventKind,
/// The affected download id, when applicable.
pub gid: Option<DownloadId>,
/// Optional human-readable message text.
pub message: Option<String>,
/// Optional progress snapshot captured for the event.
pub snapshot: Option<ProgressSnapshot>,
}
impl RuntimeEvent {
/// Creates a new event with the provided kind.
#[must_use]
pub fn new(kind: RuntimeEventKind) -> Self {
Self {
kind,
gid: None,
message: None,
snapshot: None,
}
}
/// Attaches the download id affected by the event.
#[must_use]
pub fn with_gid(mut self, gid: DownloadId) -> Self {
self.gid = Some(gid);
self
}
/// Attaches a human-readable message to the event.
#[must_use]
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
}
/// Listener interface for consumers that want push-style event delivery.
pub trait EventListener: Send {
/// Handles a newly emitted event.
fn on_event(&mut self, event: &RuntimeEvent);
}
/// FIFO event queue with immediate listener fan-out.
#[derive(Default)]
pub struct EventBus {
/// Registered listeners that receive push-style fan-out.
listeners: Vec<Box<dyn EventListener>>,
/// FIFO queue of emitted events waiting to be drained.
queue: VecDeque<RuntimeEvent>,
}
impl std::fmt::Debug for EventBus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EventBus")
.field("listener_count", &self.listeners.len())
.field("queue_len", &self.queue.len())
.finish()
}
}
impl EventBus {
/// Creates an empty event bus.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Registers a new listener that will receive future events.
pub fn subscribe(&mut self, listener: impl EventListener + 'static) {
self.listeners.push(Box::new(listener));
}
/// Emits an event to listeners and stores it in the queue.
pub fn emit(&mut self, event: RuntimeEvent) {
for listener in &mut self.listeners {
listener.on_event(&event);
}
self.queue.push_back(event);
}
/// Drains and returns all queued events in FIFO order.
#[must_use]
pub fn drain(&mut self) -> Vec<RuntimeEvent> {
self.queue.drain(..).collect()
}
/// Returns the number of queued events.
#[must_use]
pub fn len(&self) -> usize {
self.queue.len()
}
/// Returns whether the event queue is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.queue.is_empty()
}
}
+58
View File
@@ -0,0 +1,58 @@
#![doc = "Core runtime, scheduling, and request-state primitives for aria2-rust-pro."]
#![forbid(unsafe_code)]
#![expect(
clippy::if_not_else,
clippy::missing_const_for_fn,
clippy::missing_errors_doc,
clippy::must_use_candidate,
clippy::needless_pass_by_value,
clippy::struct_excessive_bools,
clippy::struct_field_names,
clippy::use_self,
reason = "core crate exposes compatibility-oriented runtime models where strict style lints add noise"
)]
/// Download engine orchestration, queue management, and session persistence.
mod engine;
/// Error types returned by the core crate.
mod error;
/// Runtime event types and the in-memory event bus.
mod events;
/// Typed option keys, values, and patches.
mod options;
/// Piece identifiers, piece ranges, and piece-state storage.
mod piece;
/// Aggregated progress and statistics snapshots.
mod progress;
/// Request, `BitTorrent`, and segment runtime state models.
mod request;
/// Runtime configuration and human-readable size parsing helpers.
mod runtime;
/// Download scheduling policies, planning, and observations.
mod scheduler;
/// Session state, global options, and persistence bridge data.
mod session;
pub use engine::{
DownloadEngine, DownloadHandle, DownloadRegistry, DownloadRuntimeSnapshot, QueuePositionMode,
RuntimeInstrumentationSnapshot,
};
pub use error::{CoreError, ErrorCode, Result};
pub use events::{EventBus, EventListener, RuntimeEvent, RuntimeEventKind};
pub use options::{OptionKey, OptionPatch, OptionValue};
pub use piece::{PieceId, PieceMap, PieceRange, PieceState};
pub use progress::{GlobalStat, GoalProgress, ProgressSnapshot, WorkState};
pub use request::{
BtFileInfo, BtPeerInfo, BtPieceAvailabilityUpdate, BtPressureSnapshot, BtRuntimeState,
BtTrackerInfo, DownloadId, DownloadStatus, RequestContext, RequestGroup, ResumeState,
RetryAttempt, SegmentAssignment, SegmentRuntimeStats, SegmentState,
};
pub use runtime::RuntimeConfig;
pub use scheduler::{
ScheduleDecision, ScheduleDecisionKind, Scheduler, SchedulerActivityCounters,
SchedulerPlanningObservation, SchedulerPolicy, SchedulerState,
};
pub use session::{GlobalOptions, SaveSessionTarget, Session, SessionState};
/// Convenience alias for the primary download task model.
pub type DownloadTask = RequestGroup;
+149
View File
@@ -0,0 +1,149 @@
//! Typed option keys, values, and patch collections.
use std::collections::BTreeMap;
/// Strongly typed option key used by session and request surfaces.
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct OptionKey(
/// Raw option-key text stored by the runtime.
pub String,
);
impl OptionKey {
/// Builds a new owned option key.
#[must_use]
pub fn new(key: impl Into<String>) -> Self {
Self(key.into())
}
/// Returns the raw string representation of the key.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Supported option value shapes accepted by the core surfaces.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum OptionValue {
/// Boolean option value.
Bool(bool),
/// Signed integer option value.
Int(i64),
/// Unsigned integer option value.
UInt(u64),
/// Text option value.
Text(String),
/// Repeated text values.
List(Vec<String>),
/// String-keyed string map values.
Map(BTreeMap<String, String>),
/// Explicit empty value.
Empty,
}
impl OptionValue {
/// Returns the inner string when the value is textual.
#[must_use]
pub fn as_text(&self) -> Option<&str> {
match self {
Self::Text(value) => Some(value.as_str()),
_ => None,
}
}
}
/// Mergeable collection of option overrides.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct OptionPatch {
/// Ordered option entries applied by the patch.
entries: BTreeMap<OptionKey, OptionValue>,
}
impl OptionPatch {
/// Creates an empty patch.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Inserts or replaces a value in the patch.
pub fn insert(
&mut self,
key: impl Into<OptionKey>,
value: impl Into<OptionValue>,
) -> Option<OptionValue> {
self.entries.insert(key.into(), value.into())
}
/// Returns the value for a given key when present.
#[must_use]
pub fn get(&self, key: &OptionKey) -> Option<&OptionValue> {
self.entries.get(key)
}
/// Returns whether the patch contains any entries.
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Merges another patch into this one, overwriting matching keys.
pub fn merge(&mut self, other: OptionPatch) {
self.entries.extend(other.entries);
}
/// Returns the underlying ordered patch entries.
#[must_use]
pub fn entries(&self) -> &BTreeMap<OptionKey, OptionValue> {
&self.entries
}
}
impl From<&str> for OptionKey {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl From<String> for OptionKey {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<bool> for OptionValue {
fn from(value: bool) -> Self {
Self::Bool(value)
}
}
impl From<i64> for OptionValue {
fn from(value: i64) -> Self {
Self::Int(value)
}
}
impl From<u64> for OptionValue {
fn from(value: u64) -> Self {
Self::UInt(value)
}
}
impl From<String> for OptionValue {
fn from(value: String) -> Self {
Self::Text(value)
}
}
impl From<&str> for OptionValue {
fn from(value: &str) -> Self {
Self::Text(value.to_owned())
}
}
impl From<Vec<String>> for OptionValue {
fn from(value: Vec<String>) -> Self {
Self::List(value)
}
}
+104
View File
@@ -0,0 +1,104 @@
//! Piece identifiers, piece states, and in-memory piece maps.
use std::collections::BTreeMap;
/// Stable identifier for a single piece within a download.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PieceId(
/// Zero-based piece index within the download.
pub u32,
);
/// Current lifecycle state of a piece.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PieceState {
/// The piece has not been queued yet.
Pending,
/// The piece is queued and ready to be assigned.
Queued,
/// The piece is currently being downloaded.
Downloading,
/// The piece has been verified successfully.
Verified,
/// The piece is missing and should be retried.
Missing,
/// The piece is intentionally skipped.
Skipped,
}
/// Half-open byte range occupied by a piece or segment.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PieceRange {
/// Inclusive starting byte offset.
pub start: u64,
/// Exclusive ending byte offset.
pub end: u64,
}
impl PieceRange {
/// Creates a new half-open byte range.
#[must_use]
pub const fn new(start: u64, end: u64) -> Self {
Self { start, end }
}
/// Returns the byte length of the range.
#[must_use]
pub const fn len(&self) -> u64 {
self.end.saturating_sub(self.start)
}
/// Returns whether the range contains no bytes.
#[must_use]
pub const fn is_empty(&self) -> bool {
self.start >= self.end
}
}
/// Ordered in-memory map from piece ids to piece states.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct PieceMap {
/// Ordered mapping from piece ids to their current states.
pieces: BTreeMap<PieceId, PieceState>,
}
impl PieceMap {
/// Creates an empty piece map.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Inserts or replaces a piece state.
pub fn insert(&mut self, id: PieceId, state: PieceState) -> Option<PieceState> {
self.pieces.insert(id, state)
}
/// Returns the state for a piece when present.
#[must_use]
pub fn get(&self, id: &PieceId) -> Option<PieceState> {
self.pieces.get(id).copied()
}
/// Sets the state for a piece id.
pub fn set_state(&mut self, id: PieceId, state: PieceState) {
self.pieces.insert(id, state);
}
/// Iterates over all tracked pieces in key order.
pub fn iter(&self) -> impl Iterator<Item = (&PieceId, &PieceState)> {
self.pieces.iter()
}
/// Returns the number of tracked pieces.
#[must_use]
pub fn len(&self) -> usize {
self.pieces.len()
}
/// Returns whether the map is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.pieces.is_empty()
}
}
+332
View File
@@ -0,0 +1,332 @@
//! Progress snapshots and aggregate statistics exposed by the core runtime.
use crate::request::{DownloadId, DownloadStatus};
/// High-level work state used for coarse progress reporting.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WorkState {
/// Work is planned but not yet implemented.
Planned,
/// Work has been implemented.
Implemented,
/// Work has been verified.
Verified,
}
/// Coarse progress information for a multi-phase goal.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GoalProgress {
/// Overall completion percent across the whole goal.
overall_percent: u8,
/// Human-readable name of the current phase.
phase_name: String,
/// Completion percent within the current phase.
phase_percent: u8,
/// Coarse progress state for the current phase.
state: WorkState,
}
impl GoalProgress {
/// Creates a new progress tracker for the given phase.
#[must_use]
pub fn new(phase_name: impl Into<String>) -> Self {
Self {
overall_percent: 1,
phase_name: phase_name.into(),
phase_percent: 20,
state: WorkState::Planned,
}
}
/// Returns the overall completion percentage.
#[must_use]
pub const fn overall_percent(&self) -> u8 {
self.overall_percent
}
/// Returns the current phase name.
#[must_use]
pub fn phase_name(&self) -> &str {
&self.phase_name
}
/// Returns the current phase completion percentage.
#[must_use]
pub const fn phase_percent(&self) -> u8 {
self.phase_percent
}
/// Returns the coarse work state.
#[must_use]
pub const fn state(&self) -> WorkState {
self.state
}
}
/// Aggregated global transfer statistics.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct GlobalStat {
/// Aggregate download throughput in bytes per second.
pub download_speed: u64,
/// Aggregate upload throughput in bytes per second.
pub upload_speed: u64,
/// Number of downloads currently active.
pub num_active: u32,
/// Number of downloads queued and waiting.
pub num_waiting: u32,
/// Number of downloads stopped without error.
pub num_stopped: u32,
/// Number of downloads currently in an error state.
pub num_error: u32,
/// Number of downloads completed successfully.
pub num_complete: u32,
/// Total tracked payload length across downloads.
pub total_length: u64,
/// Total completed payload length across downloads.
pub completed_length: u64,
}
impl GlobalStat {
/// Creates an empty statistics snapshot.
#[must_use]
pub const fn new() -> Self {
Self {
download_speed: 0,
upload_speed: 0,
num_active: 0,
num_waiting: 0,
num_stopped: 0,
num_error: 0,
num_complete: 0,
total_length: 0,
completed_length: 0,
}
}
}
/// Detailed progress snapshot for a single download.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ProgressSnapshot {
/// Download id associated with this snapshot.
pub gid: DownloadId,
/// Current lifecycle status of the download.
pub status: DownloadStatus,
/// Total payload length in bytes.
pub total_length: u64,
/// Completed payload length in bytes.
pub completed_length: u64,
/// Uploaded payload length in bytes.
pub upload_length: u64,
/// Current upload throughput in bytes per second.
pub upload_speed: u64,
/// Current download throughput in bytes per second.
pub download_speed: u64,
/// Number of active connections assigned to the download.
pub num_connections: u32,
/// Estimated seconds remaining when known.
pub eta_seconds: Option<u64>,
/// Whether the runtime currently considers the download seeding.
pub seeding: bool,
/// Share ratio expressed in milli-units when available.
pub share_ratio_milli: Option<u64>,
/// Accumulated share time in seconds when available.
pub share_time_secs: Option<u64>,
/// Accumulated seeding time in seconds when available.
pub seeding_time_secs: Option<u64>,
/// Total selected `BitTorrent` payload length in bytes.
pub bt_selected_payload_length: u64,
/// Remaining selected `BitTorrent` payload length in bytes.
pub bt_remaining_payload_length: u64,
/// Whether the torrent has completed selected work and is truly seeding.
pub bt_true_seeding: bool,
/// Number of peers in the current swarm snapshot.
pub bt_total_peers: u32,
/// Number of peers currently identified as seeders.
pub bt_seeders: u32,
/// Number of peers currently identified as leechers.
pub bt_leechers: u32,
/// Number of pieces with non-zero availability.
pub bt_available_pieces: u32,
/// Number of verified pieces.
pub bt_verified_pieces: u32,
/// Number of actively downloading pieces.
pub bt_downloading_pieces: u32,
/// Number of queued pieces.
pub bt_queued_pieces: u32,
/// Number of missing pieces.
pub bt_missing_pieces: u32,
}
impl ProgressSnapshot {
/// Creates an empty progress snapshot for the given download id and status.
#[must_use]
pub fn new(gid: DownloadId, status: DownloadStatus) -> Self {
Self {
gid,
status,
total_length: 0,
completed_length: 0,
upload_length: 0,
upload_speed: 0,
download_speed: 0,
num_connections: 0,
eta_seconds: None,
seeding: false,
share_ratio_milli: None,
share_time_secs: None,
seeding_time_secs: None,
bt_selected_payload_length: 0,
bt_remaining_payload_length: 0,
bt_true_seeding: false,
bt_total_peers: 0,
bt_seeders: 0,
bt_leechers: 0,
bt_available_pieces: 0,
bt_verified_pieces: 0,
bt_downloading_pieces: 0,
bt_queued_pieces: 0,
bt_missing_pieces: 0,
}
}
/// Returns whether the snapshot has a non-zero payload length.
#[must_use]
pub const fn has_payload_length(&self) -> bool {
self.total_length > 0
}
/// Returns the remaining payload length in bytes.
#[must_use]
pub fn remaining_length(&self) -> u64 {
self.total_length
.saturating_sub(self.completed_length.min(self.total_length))
}
/// Returns whether the payload transfer is complete.
#[must_use]
pub fn transfer_complete(&self) -> bool {
self.has_payload_length() && self.remaining_length() == 0
}
/// Returns whether the transfer is complete or actively seeding.
#[must_use]
pub fn bt_transfer_complete_or_seeding(&self) -> bool {
self.transfer_complete() || self.seeding
}
/// Returns whether any `BitTorrent` share-runtime data is present.
#[must_use]
pub const fn bt_has_share_runtime(&self) -> bool {
self.share_time_secs.is_some() || self.share_ratio_milli.is_some()
}
/// Returns whether peer or piece-availability activity exists.
#[must_use]
pub const fn bt_has_swarm_activity(&self) -> bool {
self.bt_total_peers > 0 || self.bt_available_pieces > 0
}
/// Returns the total number of active `BitTorrent` pieces.
#[must_use]
pub const fn bt_active_piece_count(&self) -> u32 {
self.bt_downloading_pieces
.saturating_add(self.bt_queued_pieces)
}
/// Returns whether the selected `BitTorrent` payload is complete.
#[must_use]
pub const fn bt_payload_complete(&self) -> bool {
self.bt_selected_payload_length > 0
&& self.bt_remaining_payload_length == 0
&& self.completed_length >= self.bt_selected_payload_length
}
/// Returns completion percent in milli-units.
#[must_use]
pub fn completion_percent_milli(&self) -> u64 {
if self.total_length == 0 {
return 0;
}
self.completed_length
.min(self.total_length)
.saturating_mul(1000)
.checked_div(self.total_length)
.unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn progress_snapshot_new_initializes_bt_share_fields() {
let snapshot = ProgressSnapshot::new(DownloadId::new(0x42), DownloadStatus::Waiting);
assert!(!snapshot.seeding);
assert_eq!(snapshot.share_ratio_milli, None);
assert_eq!(snapshot.upload_speed, 0);
assert_eq!(snapshot.share_time_secs, None);
assert!(!snapshot.bt_has_swarm_activity());
assert!(!snapshot.bt_has_share_runtime());
}
#[test]
fn progress_snapshot_bt_completion_semantics_avoid_false_completion() {
let mut snapshot = ProgressSnapshot::new(DownloadId::new(0x43), DownloadStatus::Active);
snapshot.total_length = 10_000;
snapshot.completed_length = 9_000;
assert_eq!(snapshot.remaining_length(), 1_000);
assert!(!snapshot.transfer_complete());
assert!(!snapshot.bt_transfer_complete_or_seeding());
snapshot.seeding = true;
assert!(snapshot.bt_transfer_complete_or_seeding());
assert!(!snapshot.transfer_complete());
}
#[test]
fn progress_snapshot_completion_percent_milli_caps_completed_length() {
let mut snapshot = ProgressSnapshot::new(DownloadId::new(0x44), DownloadStatus::Active);
snapshot.total_length = 2_000;
snapshot.completed_length = 2_500;
assert_eq!(snapshot.remaining_length(), 0);
assert_eq!(snapshot.completion_percent_milli(), 1000);
snapshot.total_length = 0;
assert_eq!(snapshot.completion_percent_milli(), 0);
}
#[test]
fn progress_snapshot_bt_runtime_metrics_report_activity() {
let mut snapshot = ProgressSnapshot::new(DownloadId::new(0x45), DownloadStatus::Active);
snapshot.bt_total_peers = 2;
snapshot.bt_seeders = 1;
snapshot.bt_leechers = 1;
snapshot.bt_available_pieces = 3;
snapshot.bt_downloading_pieces = 2;
snapshot.bt_queued_pieces = 1;
snapshot.bt_missing_pieces = 4;
assert!(snapshot.bt_has_swarm_activity());
assert_eq!(snapshot.bt_active_piece_count(), 3);
assert_eq!(snapshot.bt_seeders + snapshot.bt_leechers, 2);
}
#[test]
fn progress_snapshot_bt_share_runtime_helpers_report_true_seeding() {
let mut snapshot = ProgressSnapshot::new(DownloadId::new(0x46), DownloadStatus::Complete);
snapshot.completed_length = 4_096;
snapshot.share_ratio_milli = Some(1250);
snapshot.share_time_secs = Some(120);
snapshot.seeding_time_secs = Some(90);
snapshot.bt_selected_payload_length = 4_096;
snapshot.bt_remaining_payload_length = 0;
snapshot.bt_true_seeding = true;
snapshot.seeding = true;
assert!(snapshot.bt_has_share_runtime());
assert!(snapshot.bt_payload_complete());
assert!(snapshot.bt_transfer_complete_or_seeding());
assert!(snapshot.bt_true_seeding);
}
}
+40
View File
@@ -0,0 +1,40 @@
//! Request, segment, and BitTorrent runtime state models.
#[cfg(test)]
use std::collections::BTreeMap;
#[cfg(test)]
use crate::piece::{PieceId, PieceRange, PieceState};
/// `BitTorrent` runtime metadata, peer state, and mutation result types.
mod bt;
/// Ordered URI lists, headers, and request metadata for one download.
mod context;
/// Request-group state and request/BT helper submodules.
mod group;
/// Stable download identifiers, statuses, and resume metadata.
mod identity;
/// Segment-assignment models and aggregated segment runtime counters.
mod segment;
#[expect(
clippy::redundant_pub_crate,
reason = "these BT helper types stay crate-internal while sibling modules import them through crate::request"
)]
pub(crate) use self::bt::{
BtPeerMutationResult, BtPieceAvailabilityMutationResult, BtPieceBlockUpdate,
BtPieceMutationResult, BtRuntimeTickResult, BtShareRuntimeState,
};
pub use self::{
bt::{
BtFileInfo, BtPeerInfo, BtPieceAvailabilityUpdate, BtPressureSnapshot, BtRuntimeState,
BtTrackerInfo,
},
context::RequestContext,
group::RequestGroup,
identity::{DownloadId, DownloadStatus, ResumeState, RetryAttempt},
segment::{SegmentAssignment, SegmentRuntimeStats, SegmentState},
};
#[cfg(test)]
mod request_tests;
@@ -0,0 +1,414 @@
use std::collections::BTreeMap;
use crate::piece::{PieceId, PieceState};
/// File entry exposed by torrent metadata and BT RPC responses.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct BtFileInfo {
/// Output path or logical file name for the torrent entry.
pub path: String,
/// Declared file length in bytes.
pub length: u64,
/// Piece-aligned offset where this file begins, when known.
pub piece_offset: Option<u64>,
/// Whether the file is selected for download.
pub selected: bool,
}
impl BtFileInfo {
/// Returns whether the torrent file is selected for transfer.
#[must_use]
pub const fn is_selected(&self) -> bool {
self.selected
}
}
/// Tracker entry associated with a torrent.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct BtTrackerInfo {
/// Tracker announce URL.
pub url: String,
/// Optional tracker tier index.
pub tier: Option<u32>,
/// Optional tracker identifier reported by the server.
pub id: Option<String>,
/// Seeder count reported by the tracker, when available.
pub seeders: Option<u32>,
/// Leecher count reported by the tracker, when available.
pub leechers: Option<u32>,
}
/// Peer entry associated with BT swarm runtime state.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct BtPeerInfo {
/// Optional peer ID from the handshake.
pub peer_id: Option<String>,
/// Peer IP address or host.
pub ip: String,
/// Peer listening port.
pub port: u16,
/// Optional peer client identification string.
pub client_name: Option<String>,
/// Whether the peer is interested in our pieces.
pub interested: bool,
/// Whether the peer currently chokes us.
pub choked: bool,
/// Reported or inferred peer-to-local download speed.
pub download_speed: u64,
/// Reported or inferred local-to-peer upload speed.
pub upload_speed: u64,
/// Whether the peer appears to have the full payload.
pub seeder: bool,
}
/// Piece block completion update emitted by the BT runtime.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BtPieceBlockUpdate {
/// Piece being updated.
pub piece_id: PieceId,
/// Number of completed blocks inside the piece.
pub completed_blocks: u32,
/// Total number of blocks in the piece.
pub total_blocks: u32,
}
/// Piece availability update emitted by the BT runtime.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BtPieceAvailabilityUpdate {
/// Piece being updated.
pub piece_id: PieceId,
/// Number of peers advertising the piece.
pub peers_with_piece: u32,
}
/// Result of applying one piece state transition.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BtPieceMutationResult {
/// Change in verified completed length caused by the mutation.
pub completed_length_delta: i64,
/// Whether the piece transitioned into the verified state.
pub transitioned_to_verified: bool,
/// Previous piece state, if one existed.
pub previous_state: Option<PieceState>,
/// Resulting piece state after the mutation.
pub next_state: PieceState,
/// Byte span covered by the piece.
pub piece_span_length: u64,
/// Number of completed blocks after the mutation.
pub completed_blocks: u32,
/// Total number of blocks in the piece.
pub total_blocks: u32,
/// Block completion ratio in thousandths.
pub block_completion_milli: u64,
}
impl Default for BtPieceMutationResult {
fn default() -> Self {
Self {
completed_length_delta: 0,
transitioned_to_verified: false,
previous_state: None,
next_state: PieceState::Pending,
piece_span_length: 0,
completed_blocks: 0,
total_blocks: 0,
block_completion_milli: 0,
}
}
}
/// Result of applying one piece availability update.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct BtPieceAvailabilityMutationResult {
/// Peer count currently advertising the piece.
pub peers_with_piece: u32,
/// Number of pieces currently available from at least one peer.
pub available_piece_count: usize,
/// Whether the updated piece is requestable right now.
pub piece_is_requestable: bool,
/// Whether the updated piece is already verified locally.
pub piece_is_verified: bool,
}
/// Aggregated swarm counters after a peer mutation.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct BtPeerMutationResult {
/// Total connected peer count after the mutation.
pub peer_count: usize,
/// Seeder count after the mutation.
pub seeder_count: usize,
/// Leecher count after the mutation.
pub leecher_count: usize,
/// Aggregate download speed after the mutation.
pub total_download_speed: u64,
/// Aggregate upload speed after the mutation.
pub total_upload_speed: u64,
/// Whether the mutation replaced an existing peer entry.
pub replaced_existing: bool,
}
/// Transfer and seeding counters observed for one BT runtime tick.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct BtRuntimeTickResult {
/// Completed payload length after the tick.
pub completed_length: u64,
/// Uploaded payload length after the tick.
pub upload_length: u64,
/// Download speed observed during the tick.
pub download_speed: u64,
/// Upload speed observed during the tick.
pub upload_speed: u64,
/// Number of active peer connections.
pub num_connections: u32,
/// Whether the torrent is currently seeding.
pub seeding: bool,
/// Share ratio in thousandths, when it can be derived.
pub share_ratio_milli: Option<u64>,
/// Total share time in seconds.
pub share_time_secs: u64,
/// Total seeding time in seconds.
pub seeding_time_secs: u64,
}
/// Snapshot used by BT heuristics and diagnostics to describe swarm pressure.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct BtPressureSnapshot {
/// Total piece count in the torrent.
pub total_pieces: usize,
/// Number of pieces currently requestable by the local client.
pub requestable_pieces: usize,
/// Number of pieces actively being worked on.
pub active_pieces: usize,
/// Number of requestable pieces that at least one peer can serve.
pub available_requestable_pieces: usize,
/// Number of requestable pieces served by very few peers.
pub scarce_requestable_pieces: usize,
/// Total connected peer count.
pub peer_count: usize,
/// Number of peers believed to be complete seeders.
pub seeder_count: usize,
/// Number of peers still downloading pieces.
pub leecher_count: usize,
}
/// BitTorrent-specific metadata and swarm state attached to a request group.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct BtRuntimeState {
/// Uppercase hexadecimal torrent info hash.
pub info_hash: String,
/// Optional torrent display name.
pub name: Option<String>,
/// Original magnet URI when the torrent was bootstrapped from magnet.
pub magnet_uri: Option<String>,
/// Whether the runtime is still waiting for full torrent metadata.
pub metadata_only: bool,
/// Total `.torrent` metadata size announced through BEP 10 / BEP 9, when known.
pub metadata_size: Option<u32>,
/// Known per-peer `ut_metadata` extension ids keyed by `host:port`.
pub metadata_extension_ids: BTreeMap<String, u8>,
/// Buffered metadata pieces keyed by metadata piece index.
pub metadata_piece_payloads: BTreeMap<u32, Vec<u8>>,
/// Optional torrent creation date string.
pub creation_date: Option<String>,
/// Optional torrent comment string.
pub comment: Option<String>,
/// Known DHT bootstrap or discovered nodes.
pub dht_nodes: Vec<String>,
/// Torrent file entries.
pub files: Vec<BtFileInfo>,
/// Tracker entries associated with the torrent.
pub trackers: Vec<BtTrackerInfo>,
/// Connected or recently seen peers.
pub peers: Vec<BtPeerInfo>,
}
impl BtRuntimeState {
/// Returns the current DHT node list associated with the torrent runtime.
#[must_use]
pub fn dht_nodes(&self) -> &[String] {
&self.dht_nodes
}
/// Returns the torrent file entries currently attached to the runtime state.
#[must_use]
pub fn files(&self) -> &[BtFileInfo] {
&self.files
}
/// Iterates over torrent file entries that are currently selected.
pub fn selected_files(&self) -> impl Iterator<Item = &BtFileInfo> + '_ {
self.files.iter().filter(|file| file.is_selected())
}
/// Returns the number of torrent file entries currently selected.
#[must_use]
pub fn selected_file_count(&self) -> usize {
self.selected_files().count()
}
/// Returns the sum of selected torrent file lengths.
#[must_use]
pub fn selected_total_length(&self) -> u64 {
self.selected_files()
.fold(0_u64, |acc, file| acc.saturating_add(file.length))
}
/// Returns whether at least one torrent file entry is selected.
#[must_use]
pub fn has_selected_files(&self) -> bool {
self.files.iter().any(BtFileInfo::is_selected)
}
/// Returns the selected total length, or the full torrent length when nothing is selected.
#[must_use]
pub fn selected_or_all_total_length(&self) -> u64 {
if self.files.is_empty() {
return 0;
}
let selected = self.selected_total_length();
if selected > 0 {
selected
} else {
self.files
.iter()
.fold(0_u64, |acc, file| acc.saturating_add(file.length))
}
}
}
/// Mutable seeding and share-ratio counters for one torrent session.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct BtShareRuntimeState {
/// Whether the runtime currently considers the torrent to be seeding.
pub seeding: bool,
/// Share ratio in thousandths, when derivable.
pub share_ratio_milli: Option<u64>,
/// Total share time in seconds.
pub share_time_secs: u64,
/// Total seeding time in seconds.
pub seeding_time_secs: u64,
/// Wall-clock second when seeding most recently began.
pub seeding_started_at_secs: Option<u64>,
/// Wall-clock second of the last runtime tick update.
pub last_runtime_tick_secs: Option<u64>,
}
impl BtShareRuntimeState {
/// Builds a zeroed BT share-state snapshot.
#[must_use]
pub const fn new() -> Self {
Self {
seeding: false,
share_ratio_milli: None,
share_time_secs: 0,
seeding_time_secs: 0,
seeding_started_at_secs: None,
last_runtime_tick_secs: None,
}
}
/// Returns whether the torrent is currently in a seeding state.
#[must_use]
pub const fn is_seeding(&self) -> bool {
self.seeding
}
/// Updates the seeding flag and clears the start timestamp when seeding stops.
pub fn set_seeding(&mut self, value: bool) {
self.seeding = value;
if !value {
self.seeding_started_at_secs = None;
}
}
/// Returns the current share ratio in thousandths, when it is known.
#[must_use]
pub const fn share_ratio_milli(&self) -> Option<u64> {
self.share_ratio_milli
}
/// Sets the current share ratio in thousandths.
pub fn set_share_ratio_milli(&mut self, value: Option<u64>) {
self.share_ratio_milli = value;
}
/// Returns the total accumulated share time in seconds.
#[must_use]
pub const fn share_time_secs(&self) -> u64 {
self.share_time_secs
}
/// Overwrites the total accumulated share time in seconds.
pub fn set_share_time_secs(&mut self, value: u64) {
self.share_time_secs = value;
}
/// Adds to the accumulated share time using saturating arithmetic.
pub fn add_share_time_secs(&mut self, delta: u64) {
self.share_time_secs = self.share_time_secs.saturating_add(delta);
}
/// Returns the total accumulated seeding time in seconds.
#[must_use]
pub const fn seeding_time_secs(&self) -> u64 {
self.seeding_time_secs
}
/// Overwrites the accumulated seeding time in seconds.
pub fn set_seeding_time_secs(&mut self, value: u64) {
self.seeding_time_secs = value;
}
/// Adds to the accumulated seeding time using saturating arithmetic.
pub fn add_seeding_time_secs(&mut self, delta: u64) {
self.seeding_time_secs = self.seeding_time_secs.saturating_add(delta);
}
/// Starts seeding bookkeeping at the provided unix timestamp.
pub fn start_seeding(&mut self, at_unix_secs: u64) {
if self.seeding {
self.last_runtime_tick_secs = Some(at_unix_secs);
return;
}
self.seeding = true;
self.seeding_started_at_secs = Some(at_unix_secs);
self.last_runtime_tick_secs = Some(at_unix_secs);
}
/// Stops seeding bookkeeping after first accounting for elapsed runtime.
pub fn stop_seeding(&mut self, at_unix_secs: u64) {
self.tick_runtime(at_unix_secs);
self.seeding = false;
self.seeding_started_at_secs = None;
}
/// Advances share and seeding runtime counters to the provided unix timestamp.
pub fn tick_runtime(&mut self, now_unix_secs: u64) {
let Some(last_tick) = self.last_runtime_tick_secs else {
self.last_runtime_tick_secs = Some(now_unix_secs);
return;
};
let delta = now_unix_secs.saturating_sub(last_tick);
self.last_runtime_tick_secs = Some(now_unix_secs);
self.share_time_secs = self.share_time_secs.saturating_add(delta);
if self.seeding {
self.seeding_time_secs = self.seeding_time_secs.saturating_add(delta);
}
}
/// Derives a share ratio in thousandths from uploaded and completed byte counts.
#[must_use]
pub fn derive_share_ratio_milli(uploaded: u64, completed_base: u64) -> Option<u64> {
if completed_base == 0 {
return None;
}
uploaded.saturating_mul(1000).checked_div(completed_base)
}
/// Refreshes the stored share ratio using the provided length counters.
pub fn refresh_share_ratio_from_lengths(&mut self, uploaded: u64, completed_base: u64) {
self.share_ratio_milli = Self::derive_share_ratio_milli(uploaded, completed_base);
}
}
@@ -0,0 +1,117 @@
use std::collections::BTreeSet;
/// Source URIs and request headers associated with a download group.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct RequestContext {
/// Optional origin label describing how the request was seeded.
pub source: Option<String>,
/// Primary URI shown on RPC and CLI surfaces.
pub uri: String,
/// Full ordered URI list associated with the request.
pub uris: Vec<String>,
/// Optional HTTP referer applied to outbound requests.
pub referer: Option<String>,
/// Additional request headers carried with the request.
pub headers: Vec<(String, String)>,
/// Optional higher-level group identifier from imported session/config data.
pub group_id: Option<String>,
/// Optional diagnostic or migration note carried with the request.
pub note: Option<String>,
}
impl RequestContext {
/// Builds a request context seeded with one primary URI candidate.
#[must_use]
pub fn new(uri: impl Into<String>) -> Self {
let uris = Self::normalize_uris(vec![uri.into()]);
let uri = uris.first().cloned().unwrap_or_default();
Self {
source: None,
uri,
uris,
referer: None,
headers: Vec::new(),
group_id: None,
note: None,
}
}
/// Returns the primary URI currently exposed for the request.
#[must_use]
pub fn uri(&self) -> &str {
&self.uri
}
/// Returns the ordered URI list associated with the request.
#[must_use]
pub fn uris(&self) -> &[String] {
&self.uris
}
/// Replaces the full URI list after normalizing blank entries and duplicates.
pub fn replace_uris(&mut self, uris: Vec<String>) {
self.uris = Self::normalize_uris(uris);
self.sync_primary_uri();
}
/// Appends a URI to the end of the ordered candidate list.
pub fn append_uri(&mut self, uri: impl Into<String>) {
self.insert_uri(self.uris.len(), uri);
}
/// Inserts or repositions a URI at the requested index.
pub fn insert_uri(&mut self, index: usize, uri: impl Into<String>) {
let Some(uri) = Self::normalize_uri(uri.into()) else {
return;
};
let mut index = index.min(self.uris.len());
if let Some(existing_index) = self.uris.iter().position(|current| current == &uri) {
self.uris.remove(existing_index);
if existing_index < index {
index = index.saturating_sub(1);
}
}
self.uris.insert(index, uri);
self.sync_primary_uri();
}
/// Removes the first URI exactly matching the provided string.
pub fn remove_first_matching_uri(&mut self, uri: &str) -> bool {
if let Some(index) = self.uris.iter().position(|current| current == uri) {
self.uris.remove(index);
self.sync_primary_uri();
return true;
}
false
}
/// Adds one request header pair to the context.
pub fn push_header(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.headers.push((key.into(), value.into()));
}
/// Normalizes an ordered URI list by dropping blank entries and duplicates.
fn normalize_uris(uris: Vec<String>) -> Vec<String> {
let mut normalized = Vec::with_capacity(uris.len());
let mut seen = BTreeSet::new();
for uri in uris {
let Some(uri) = Self::normalize_uri(uri) else {
continue;
};
if seen.insert(uri.clone()) {
normalized.push(uri);
}
}
normalized
}
/// Returns `Some(uri)` when the supplied URI text is non-blank after trimming.
fn normalize_uri(uri: String) -> Option<String> {
(!uri.trim().is_empty()).then_some(uri)
}
/// Synchronizes the primary URI field with the first normalized URI entry.
fn sync_primary_uri(&mut self) {
self.uri = self.uris.first().cloned().unwrap_or_default();
}
}
@@ -0,0 +1,153 @@
#![expect(
clippy::arithmetic_side_effects,
reason = "request state uses compact counter math over bounded scheduler/runtime fields"
)]
pub(super) use std::collections::{BTreeMap, BTreeSet};
pub(super) use crate::{
options::{OptionKey, OptionPatch, OptionValue},
piece::{PieceId, PieceMap, PieceRange, PieceState},
runtime::parse_human_size_text,
};
pub(super) use super::{
BtPeerInfo, BtPeerMutationResult, BtPieceAvailabilityMutationResult, BtPieceAvailabilityUpdate,
BtPieceBlockUpdate, BtPieceMutationResult, BtPressureSnapshot, BtRuntimeState,
BtRuntimeTickResult, BtShareRuntimeState, DownloadId, DownloadStatus, RequestContext,
ResumeState, RetryAttempt, SegmentAssignment, SegmentRuntimeStats, SegmentState,
};
/// `BitTorrent` peer snapshot and peer-mutation helpers for a request group.
mod bt_peers;
/// `BitTorrent` piece availability, verification, and pressure helpers.
mod bt_pieces;
/// `BitTorrent` share-ratio, share-time, and seeding-time helpers.
mod bt_share;
/// Core `RequestGroup` data model and field layout.
mod model;
/// General request-group state mutation, accessors, and option helpers.
mod state;
pub use self::model::RequestGroup;
impl RequestGroup {
/// Returns the byte span covered by a piece after clamping the tail piece to the target length.
fn bt_piece_span_length(&self, piece: PieceId) -> u64 {
let piece_length = self.piece_length.max(1);
let start = u64::from(piece.0).saturating_mul(piece_length);
match self.bt_effective_target_length() {
Some(target) if target > 0 => target.saturating_sub(start).min(piece_length),
_ => piece_length,
}
}
/// Applies a piece-state transition and refreshes completion and share-ratio counters accordingly.
fn bt_set_piece_state_and_refresh_completion(
&mut self,
piece: PieceId,
next_state: PieceState,
) -> BtPieceMutationResult {
let previous = self.piece_state(piece);
let span = self.bt_piece_span_length(piece);
if previous != Some(next_state) {
self.set_piece_state(piece, next_state);
}
let was_verified = matches!(previous, Some(PieceState::Verified));
let is_verified = next_state == PieceState::Verified;
let mut delta = 0_i64;
let signed_span = i64::try_from(span).unwrap_or(i64::MAX);
if previous != Some(next_state) && !was_verified && is_verified {
self.add_completed_length(span);
delta = signed_span;
} else if previous != Some(next_state) && was_verified && !is_verified {
self.completed_length = self.completed_length.saturating_sub(span);
delta = signed_span.saturating_neg();
}
self.refresh_bt_share_ratio_from_lengths();
BtPieceMutationResult {
completed_length_delta: delta,
transitioned_to_verified: !was_verified && is_verified,
previous_state: previous,
next_state,
piece_span_length: span,
completed_blocks: 0,
total_blocks: 0,
block_completion_milli: 0,
}
}
/// Builds the final mutation result for a block-progress update after applying the new piece state.
fn bt_apply_piece_progress_result(
&mut self,
update: BtPieceBlockUpdate,
next_state: PieceState,
) -> BtPieceMutationResult {
let mut result =
self.bt_set_piece_state_and_refresh_completion(update.piece_id, next_state);
result.completed_blocks = update.completed_blocks.min(update.total_blocks);
result.total_blocks = update.total_blocks;
result.block_completion_milli =
Self::bt_block_completion_milli(result.completed_blocks, result.total_blocks);
result
}
/// Converts completed block counts into a per-thousand completion ratio for UI and RPC reporting.
fn bt_block_completion_milli(completed_blocks: u32, total_blocks: u32) -> u64 {
if total_blocks == 0 {
return 0;
}
u64::from(completed_blocks.min(total_blocks))
.saturating_mul(1000)
.saturating_div(u64::from(total_blocks))
}
/// Recomputes the active `BitTorrent` share ratio from the latest uploaded and base lengths.
fn refresh_bt_share_ratio_from_lengths(&mut self) {
let Some(denominator) = self.bt_share_ratio_base_length() else {
return;
};
if let Some(share_state) = self.bt_share_state.as_mut() {
share_state.refresh_share_ratio_from_lengths(self.upload_length, denominator);
}
}
/// Mirrors the current peer count into the generic connection counter exposed by the request group.
fn sync_bt_num_connections_to_peer_count(&mut self) {
let peer_count = self.bt.as_ref().map_or(0, |bt| bt.peers.len());
self.num_connections = u32::try_from(peer_count).unwrap_or(u32::MAX);
}
/// Produces aggregate peer counters and bandwidth totals after a peer mutation step.
fn bt_peer_runtime_stats_with_replaced(&self, replaced_existing: bool) -> BtPeerMutationResult {
let Some(bt) = self.bt() else {
return BtPeerMutationResult::default();
};
let peer_count = bt.peers.len();
let seeder_count = bt.peers.iter().filter(|peer| peer.seeder).count();
BtPeerMutationResult {
peer_count,
seeder_count,
leecher_count: peer_count.saturating_sub(seeder_count),
total_download_speed: bt.peers.iter().map(|peer| peer.download_speed).sum(),
total_upload_speed: bt.peers.iter().map(|peer| peer.upload_speed).sum(),
replaced_existing,
}
}
/// Snapshots the current `BitTorrent` runtime counters for periodic scheduler and RPC updates.
fn bt_runtime_tick_result(&self) -> BtRuntimeTickResult {
let share_state = self.bt_share_state();
BtRuntimeTickResult {
completed_length: self.completed_length(),
upload_length: self.upload_length(),
download_speed: self.download_speed(),
upload_speed: self.upload_speed(),
num_connections: self.num_connections(),
seeding: self.bt_is_true_seeding(),
share_ratio_milli: self.bt_share_ratio_milli(),
share_time_secs: share_state.map_or(0, BtShareRuntimeState::share_time_secs),
seeding_time_secs: share_state.map_or(0, BtShareRuntimeState::seeding_time_secs),
}
}
}
@@ -0,0 +1,107 @@
#![expect(
missing_docs,
reason = "RequestGroup BT peer helpers keep the established compatibility facade while isolating peer-state logic"
)]
use super::{BtPeerInfo, BtPeerMutationResult, BtRuntimeState, RequestGroup};
impl RequestGroup {
#[must_use]
pub fn bt(&self) -> Option<&BtRuntimeState> {
self.bt.as_ref()
}
pub fn bt_mut(&mut self) -> Option<&mut BtRuntimeState> {
self.bt.as_mut()
}
pub fn set_bt(&mut self, bt: BtRuntimeState) {
let had_bt = self.bt.is_some();
self.bt = Some(bt);
if had_bt {
self.refresh_bt_share_ratio_from_lengths();
}
}
pub fn clear_bt(&mut self) {
self.bt = None;
}
pub fn replace_bt_peer_snapshot(&mut self, peers: Vec<BtPeerInfo>) -> BtPeerMutationResult {
let Some(bt) = self.bt_mut() else {
return BtPeerMutationResult::default();
};
bt.peers = peers;
self.sync_bt_num_connections_to_peer_count();
self.bt_peer_runtime_stats()
}
#[must_use]
pub fn bt_peer_runtime_stats(&self) -> BtPeerMutationResult {
self.bt_peer_runtime_stats_with_replaced(false)
}
pub fn apply_bt_peer_update(&mut self, peer: BtPeerInfo) -> BtPeerMutationResult {
let Some(bt) = self.bt_mut() else {
return BtPeerMutationResult::default();
};
let key_peer_id = peer.peer_id.as_deref();
let key_ip = peer.ip.as_str();
let key_port = peer.port;
let replaced_existing = if let Some(existing) = bt.peers.iter_mut().find(|candidate| {
(key_peer_id.is_some() && candidate.peer_id.as_deref() == key_peer_id)
|| (candidate.ip == key_ip && candidate.port == key_port)
}) {
*existing = peer;
true
} else {
bt.peers.push(peer);
false
};
self.sync_bt_num_connections_to_peer_count();
self.bt_peer_runtime_stats_with_replaced(replaced_existing)
}
pub fn remove_bt_peer(
&mut self,
peer_id: Option<&str>,
ip: Option<&str>,
port: Option<u16>,
) -> bool {
let Some(bt) = self.bt_mut() else {
return false;
};
let before = bt.peers.len();
bt.peers.retain(|peer| {
let peer_id_match = peer_id.is_some() && peer.peer_id.as_deref() == peer_id;
let endpoint_match = matches!(
(ip, port),
(Some(expected_ip), Some(expected_port))
if peer.ip == expected_ip && peer.port == expected_port
);
!(peer_id_match || endpoint_match)
});
let removed = bt.peers.len() != before;
if removed {
self.sync_bt_num_connections_to_peer_count();
}
removed
}
#[must_use]
pub fn bt_selected_file_count(&self) -> Option<usize> {
self.bt.as_ref().map(BtRuntimeState::selected_file_count)
}
#[must_use]
pub fn bt_selected_total_length(&self) -> Option<u64> {
self.bt.as_ref().map(BtRuntimeState::selected_total_length)
}
#[must_use]
pub fn bt_has_selected_files(&self) -> bool {
self.bt
.as_ref()
.is_some_and(BtRuntimeState::has_selected_files)
}
}
@@ -0,0 +1,166 @@
#![expect(
missing_docs,
reason = "RequestGroup BT piece helpers keep piece-selection behavior stable while isolating swarm-facing state logic"
)]
use super::{
BTreeSet, BtPieceAvailabilityMutationResult, BtPieceAvailabilityUpdate, BtPieceBlockUpdate,
BtPieceMutationResult, BtPressureSnapshot, PieceId, PieceState, RequestGroup,
};
impl RequestGroup {
#[must_use]
pub fn piece_availability(&self) -> &std::collections::BTreeMap<PieceId, u32> {
&self.piece_availability
}
pub fn apply_bt_piece_availability_update(
&mut self,
update: BtPieceAvailabilityUpdate,
) -> BtPieceAvailabilityMutationResult {
if update.peers_with_piece == 0 {
self.piece_availability.remove(&update.piece_id);
} else {
self.piece_availability
.insert(update.piece_id, update.peers_with_piece);
}
let piece_state = self.piece_state(update.piece_id);
BtPieceAvailabilityMutationResult {
peers_with_piece: update.peers_with_piece,
available_piece_count: self.bt_available_piece_count(),
piece_is_requestable: matches!(
piece_state,
Some(PieceState::Pending | PieceState::Queued | PieceState::Missing)
) && update.peers_with_piece > 0,
piece_is_verified: piece_state == Some(PieceState::Verified),
}
}
pub fn clear_piece_availability(&mut self) {
self.piece_availability.clear();
}
pub fn apply_bt_piece_block_update(
&mut self,
update: BtPieceBlockUpdate,
) -> BtPieceMutationResult {
if update.total_blocks == 0 {
return self.bt_apply_piece_progress_result(update, PieceState::Missing);
}
if update.completed_blocks >= update.total_blocks {
return self.bt_apply_piece_progress_result(update, PieceState::Verified);
}
if update.completed_blocks > 0 {
return self.bt_apply_piece_progress_result(update, PieceState::Downloading);
}
self.bt_apply_piece_progress_result(update, PieceState::Queued)
}
pub fn mark_bt_piece_verified(&mut self, piece: PieceId) -> BtPieceMutationResult {
self.bt_set_piece_state_and_refresh_completion(piece, PieceState::Verified)
}
pub fn mark_bt_piece_missing(&mut self, piece: PieceId) -> BtPieceMutationResult {
self.bt_set_piece_state_and_refresh_completion(piece, PieceState::Missing)
}
pub fn mark_bt_piece_downloading(&mut self, piece: PieceId) -> BtPieceMutationResult {
self.bt_set_piece_state_and_refresh_completion(piece, PieceState::Downloading)
}
#[must_use]
pub fn piece_state_counts(&self) -> (usize, usize, usize, usize, usize, usize) {
let mut pending = 0;
let mut queued = 0;
let mut downloading = 0;
let mut verified = 0;
let mut missing = 0;
let mut skipped = 0;
for (_, state) in self.pieces.iter() {
match state {
PieceState::Pending => pending += 1,
PieceState::Queued => queued += 1,
PieceState::Downloading => downloading += 1,
PieceState::Verified => verified += 1,
PieceState::Missing => missing += 1,
PieceState::Skipped => skipped += 1,
}
}
(pending, queued, downloading, verified, missing, skipped)
}
#[must_use]
pub fn bt_verified_piece_count(&self) -> usize {
self.pieces
.iter()
.filter(|(_, state)| matches!(state, PieceState::Verified))
.count()
}
#[must_use]
pub fn bt_requestable_piece_ids(&self, endgame: bool, limit: usize) -> Vec<PieceId> {
if limit == 0 {
return Vec::new();
}
let mut primary = BTreeSet::new();
let mut endgame_candidates = BTreeSet::new();
for (piece_id, state) in self.pieces.iter() {
match state {
PieceState::Pending | PieceState::Queued | PieceState::Missing => {
primary.insert(*piece_id);
}
PieceState::Downloading if endgame => {
endgame_candidates.insert(*piece_id);
}
PieceState::Verified | PieceState::Skipped | PieceState::Downloading => {}
}
}
let mut selected = Vec::with_capacity(limit);
selected.extend(primary.into_iter().take(limit));
if selected.len() < limit {
selected.extend(endgame_candidates.into_iter().take(limit - selected.len()));
}
selected
}
#[must_use]
pub fn bt_available_piece_count(&self) -> usize {
self.piece_availability
.iter()
.filter(|(_, peers)| **peers > 0)
.count()
}
#[must_use]
pub fn bt_pressure_snapshot(&self) -> Option<BtPressureSnapshot> {
self.bt.as_ref()?;
let piece_count = self.pieces.iter().count();
let requestable = self.bt_requestable_piece_ids(false, piece_count.max(1));
let (_, queued, downloading, _, _, _) = self.piece_state_counts();
let peer_stats = self.bt_peer_runtime_stats();
let mut available_requestable_pieces = 0;
let mut scarce_requestable_pieces = 0;
for piece_id in &requestable {
let peers = self.piece_availability.get(piece_id).copied().unwrap_or(0);
if peers > 0 {
available_requestable_pieces += 1;
}
if peers > 0 && peers <= 1 {
scarce_requestable_pieces += 1;
}
}
Some(BtPressureSnapshot {
total_pieces: piece_count,
requestable_pieces: requestable.len(),
active_pieces: downloading.saturating_add(queued),
available_requestable_pieces,
scarce_requestable_pieces,
peer_count: peer_stats.peer_count,
seeder_count: peer_stats.seeder_count,
leecher_count: peer_stats.leecher_count,
})
}
}
@@ -0,0 +1,177 @@
#![expect(
missing_docs,
reason = "RequestGroup BT share/runtime helpers keep seeding counters and ratio semantics stable while isolating runtime bookkeeping"
)]
use super::{BtRuntimeTickResult, BtShareRuntimeState, RequestGroup};
impl RequestGroup {
#[must_use]
pub fn bt_share_state(&self) -> Option<&BtShareRuntimeState> {
self.bt_share_state.as_ref()
}
pub fn bt_share_state_mut(&mut self) -> Option<&mut BtShareRuntimeState> {
self.bt_share_state.as_mut()
}
pub fn set_bt_share_state(&mut self, state: BtShareRuntimeState) {
self.bt_share_state = Some(state);
}
pub fn clear_bt_share_state(&mut self) {
self.bt_share_state = None;
}
#[must_use]
pub fn bt_is_seeding(&self) -> bool {
self.bt_share_state
.as_ref()
.is_some_and(BtShareRuntimeState::is_seeding)
}
#[must_use]
pub fn bt_share_ratio_milli(&self) -> Option<u64> {
self.bt_share_state
.as_ref()
.and_then(BtShareRuntimeState::share_ratio_milli)
}
#[must_use]
pub fn bt_share_time_secs(&self) -> Option<u64> {
self.bt_share_state
.as_ref()
.map(BtShareRuntimeState::share_time_secs)
}
#[must_use]
pub fn bt_seeding_time_secs(&self) -> Option<u64> {
self.bt_share_state
.as_ref()
.map(BtShareRuntimeState::seeding_time_secs)
}
#[must_use]
pub fn bt_share_ratio_base_length(&self) -> Option<u64> {
let target = self.bt_effective_target_length()?;
if target == 0 {
return Some(0);
}
Some(target.max(self.completed_length))
}
#[must_use]
pub fn bt_effective_target_length(&self) -> Option<u64> {
let bt = self.bt.as_ref()?;
if bt.metadata_only {
return Some(0);
}
let selected_or_all = bt.selected_or_all_total_length();
if selected_or_all == 0 {
return Some(self.total_length);
}
if self.total_length == 0 {
Some(selected_or_all)
} else {
Some(selected_or_all.min(self.total_length))
}
}
#[must_use]
pub fn bt_remaining_work_length(&self) -> Option<u64> {
let target = self.bt_effective_target_length()?;
Some(target.saturating_sub(self.completed_length.min(target)))
}
#[must_use]
pub fn bt_is_true_seeding(&self) -> bool {
self.bt_is_seeding()
&& matches!(self.bt_effective_target_length(), Some(target) if target > 0)
&& self.bt_remaining_work_length() == Some(0)
}
pub fn ensure_bt_share_state(&mut self) -> Option<&mut BtShareRuntimeState> {
self.bt.as_ref()?;
if self.bt_share_state.is_none() {
self.bt_share_state = Some(BtShareRuntimeState::default());
}
self.bt_share_state.as_mut()
}
pub fn refresh_bt_share_runtime(&mut self) -> BtRuntimeTickResult {
self.refresh_bt_share_ratio_from_lengths();
self.bt_runtime_tick_result()
}
pub fn set_bt_seeding_state(
&mut self,
seeding: bool,
at_unix_secs: Option<u64>,
) -> BtRuntimeTickResult {
if let Some(share_state) = self.ensure_bt_share_state() {
match (seeding, at_unix_secs) {
(true, Some(now)) => share_state.start_seeding(now),
(false, Some(now)) => share_state.stop_seeding(now),
(value, None) => share_state.set_seeding(value),
}
}
self.refresh_bt_share_runtime()
}
pub fn tick_bt_runtime_clock(
&mut self,
now_unix_secs: u64,
seeding: bool,
) -> BtRuntimeTickResult {
if let Some(share_state) = self.ensure_bt_share_state() {
if share_state.is_seeding() != seeding {
if seeding {
share_state.start_seeding(now_unix_secs);
} else {
share_state.stop_seeding(now_unix_secs);
}
} else {
share_state.tick_runtime(now_unix_secs);
}
}
self.refresh_bt_share_runtime()
}
#[expect(
clippy::too_many_arguments,
reason = "BT runtime tick input mirrors the grouped counters provided by the dispatcher"
)]
pub fn apply_bt_runtime_tick(
&mut self,
downloaded_delta: u64,
uploaded_delta: u64,
download_speed: u64,
upload_speed: u64,
share_time_delta_secs: u64,
seeding_time_delta_secs: u64,
seeding: bool,
num_connections: Option<u32>,
) -> BtRuntimeTickResult {
if downloaded_delta > 0 {
self.add_completed_length(downloaded_delta);
}
if uploaded_delta > 0 {
self.set_upload_length(self.upload_length().saturating_add(uploaded_delta));
}
self.set_download_speed(download_speed);
self.set_upload_speed(upload_speed);
if let Some(num_connections) = num_connections {
self.set_num_connections(num_connections);
}
if let Some(share_state) = self.ensure_bt_share_state() {
share_state.set_seeding(seeding);
if share_time_delta_secs > 0 {
share_state.add_share_time_secs(share_time_delta_secs);
}
if seeding_time_delta_secs > 0 {
share_state.add_seeding_time_secs(seeding_time_delta_secs);
}
}
self.refresh_bt_share_runtime()
}
}
@@ -0,0 +1,51 @@
use super::{
BTreeMap, BtRuntimeState, BtShareRuntimeState, DownloadId, DownloadStatus, OptionPatch,
PieceId, PieceMap, RequestContext, ResumeState, RetryAttempt, SegmentAssignment,
};
/// Canonical in-memory request group model used by the runtime and RPC layers.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RequestGroup {
/// Stable identifier exposed on the RPC surface.
pub(super) gid: DownloadId,
/// Source URIs, headers, and request metadata for the transfer.
pub(super) context: RequestContext,
/// Current lifecycle state of the transfer.
pub(super) status: DownloadStatus,
/// Piece map tracking completed and pending ranges.
pub(super) pieces: PieceMap,
/// Per-download option overrides layered over runtime defaults.
pub(super) options: OptionPatch,
/// Total expected payload length in bytes.
pub(super) total_length: u64,
/// Piece size used for segmented scheduling and control-file state.
pub(super) piece_length: u64,
/// Total uploaded payload length in bytes.
pub(super) upload_length: u64,
/// Last observed upload speed in bytes per second.
pub(super) upload_speed: u64,
/// Last observed download speed in bytes per second.
pub(super) download_speed: u64,
/// Number of currently active source connections.
pub(super) num_connections: u32,
/// Verified completed payload length in bytes.
pub(super) completed_length: u64,
/// Monotonic sequence used to order stopped downloads for RPC listing.
pub(super) stopped_sequence: Option<u64>,
/// Number of retry cycles already consumed by this request group.
pub(super) retry_count: u32,
/// Recorded retry attempts for diagnostics and RPC status reporting.
pub(super) retry_attempts: Vec<RetryAttempt>,
/// Resume metadata recovered from storage or prior runtime state.
pub(super) resume_state: Option<ResumeState>,
/// DHT token cached for the next announce-peer exchange.
pub(super) dht_token: Option<Vec<u8>>,
/// Planned and active segment assignments for split transfers.
pub(super) segment_assignments: Vec<SegmentAssignment>,
/// Availability counters for each piece observed from swarm peers.
pub(super) piece_availability: BTreeMap<PieceId, u32>,
/// BitTorrent-specific runtime state when the group is BT-backed.
pub(super) bt: Option<BtRuntimeState>,
/// Seeding and share-ratio counters when the BT runtime is active.
pub(super) bt_share_state: Option<BtShareRuntimeState>,
}
@@ -0,0 +1,345 @@
#![expect(
missing_docs,
reason = "RequestGroup state accessors intentionally keep the aria2-compatible surface flat and stable"
)]
use super::{
DownloadId, DownloadStatus, OptionKey, OptionPatch, OptionValue, PieceId, PieceMap, PieceRange,
PieceState, RequestContext, RequestGroup, ResumeState, RetryAttempt, SegmentAssignment,
SegmentRuntimeStats, SegmentState, parse_human_size_text,
};
impl RequestGroup {
#[must_use]
pub fn new(gid: DownloadId, uri: impl Into<String>) -> Self {
Self {
gid,
context: RequestContext::new(uri),
status: DownloadStatus::Waiting,
pieces: PieceMap::new(),
options: OptionPatch::new(),
total_length: 0,
piece_length: 0,
upload_length: 0,
upload_speed: 0,
download_speed: 0,
num_connections: 0,
completed_length: 0,
stopped_sequence: None,
retry_count: 0,
retry_attempts: Vec::new(),
resume_state: None,
dht_token: None,
segment_assignments: Vec::new(),
piece_availability: std::collections::BTreeMap::new(),
bt: None,
bt_share_state: None,
}
}
#[must_use]
pub fn with_context(gid: DownloadId, context: RequestContext) -> Self {
let mut context = context;
let uris = if context.uris.is_empty() {
vec![context.uri.clone()]
} else {
std::mem::take(&mut context.uris)
};
context.replace_uris(uris);
Self {
gid,
context,
status: DownloadStatus::Waiting,
pieces: PieceMap::new(),
options: OptionPatch::new(),
total_length: 0,
piece_length: 0,
upload_length: 0,
upload_speed: 0,
download_speed: 0,
num_connections: 0,
completed_length: 0,
stopped_sequence: None,
retry_count: 0,
retry_attempts: Vec::new(),
resume_state: None,
dht_token: None,
segment_assignments: Vec::new(),
piece_availability: std::collections::BTreeMap::new(),
bt: None,
bt_share_state: None,
}
}
#[must_use]
pub const fn gid(&self) -> DownloadId {
self.gid
}
#[must_use]
pub fn uri(&self) -> &str {
self.context.uri()
}
#[must_use]
pub fn uris(&self) -> &[String] {
self.context.uris()
}
#[must_use]
pub const fn status(&self) -> &DownloadStatus {
&self.status
}
#[must_use]
pub fn context(&self) -> &RequestContext {
&self.context
}
pub fn context_mut(&mut self) -> &mut RequestContext {
&mut self.context
}
pub fn set_status(&mut self, status: DownloadStatus) {
self.status = status;
}
#[must_use]
pub fn piece_map(&self) -> &PieceMap {
&self.pieces
}
pub fn piece_map_mut(&mut self) -> &mut PieceMap {
&mut self.pieces
}
pub fn set_piece_state(&mut self, piece: PieceId, state: PieceState) {
self.pieces.set_state(piece, state);
}
#[must_use]
pub fn piece_state(&self, piece: PieceId) -> Option<PieceState> {
self.pieces.get(&piece)
}
#[must_use]
pub fn options(&self) -> &OptionPatch {
&self.options
}
pub fn options_mut(&mut self) -> &mut OptionPatch {
&mut self.options
}
pub fn set_option(&mut self, key: impl Into<OptionKey>, value: impl Into<OptionValue>) {
self.options.insert(key, value);
}
#[must_use]
pub fn option_limit(&self, key: &str) -> Option<u64> {
parse_option_limit(self.options.get(&OptionKey::new(key)))
}
#[must_use]
pub const fn total_length(&self) -> u64 {
self.total_length
}
pub fn set_total_length(&mut self, value: u64) {
self.total_length = value;
self.refresh_bt_share_ratio_from_lengths();
}
#[must_use]
pub const fn piece_length(&self) -> u64 {
self.piece_length
}
pub fn set_piece_length(&mut self, value: u64) {
self.piece_length = value;
}
#[must_use]
pub const fn upload_length(&self) -> u64 {
self.upload_length
}
pub fn set_upload_length(&mut self, value: u64) {
self.upload_length = value;
self.refresh_bt_share_ratio_from_lengths();
}
#[must_use]
pub const fn upload_speed(&self) -> u64 {
self.upload_speed
}
pub fn set_upload_speed(&mut self, value: u64) {
self.upload_speed = value;
}
#[must_use]
pub const fn download_speed(&self) -> u64 {
self.download_speed
}
pub fn set_download_speed(&mut self, value: u64) {
self.download_speed = value;
}
#[must_use]
pub const fn num_connections(&self) -> u32 {
self.num_connections
}
pub fn set_num_connections(&mut self, value: u32) {
self.num_connections = value;
}
#[must_use]
pub const fn completed_length(&self) -> u64 {
self.completed_length
}
pub fn set_completed_length(&mut self, value: u64) {
self.completed_length = value;
self.refresh_bt_share_ratio_from_lengths();
}
pub fn add_completed_length(&mut self, delta: u64) {
self.completed_length = self.completed_length.saturating_add(delta);
self.refresh_bt_share_ratio_from_lengths();
}
#[must_use]
pub const fn stopped_sequence(&self) -> Option<u64> {
self.stopped_sequence
}
pub fn set_stopped_sequence(&mut self, value: Option<u64>) {
self.stopped_sequence = value;
}
#[must_use]
pub const fn retry_count(&self) -> u32 {
self.retry_count
}
pub fn set_retry_count(&mut self, value: u32) {
self.retry_count = value;
}
pub fn increment_retry_count(&mut self) {
self.retry_count = self.retry_count.saturating_add(1);
}
#[must_use]
pub fn retry_attempts(&self) -> &[RetryAttempt] {
&self.retry_attempts
}
pub fn retry_attempts_mut(&mut self) -> &mut Vec<RetryAttempt> {
&mut self.retry_attempts
}
pub fn set_retry_attempts(&mut self, attempts: Vec<RetryAttempt>) {
self.retry_attempts = attempts;
}
pub fn push_retry_attempt(&mut self, attempt: RetryAttempt) {
self.retry_attempts.push(attempt);
}
pub fn clear_retry_attempts(&mut self) {
self.retry_attempts.clear();
}
#[must_use]
pub fn resume_state(&self) -> Option<&ResumeState> {
self.resume_state.as_ref()
}
pub fn resume_state_mut(&mut self) -> Option<&mut ResumeState> {
self.resume_state.as_mut()
}
pub fn set_resume_state(&mut self, state: ResumeState) {
self.resume_state = Some(state);
}
pub fn clear_resume_state(&mut self) {
self.resume_state = None;
}
#[must_use]
pub fn dht_token(&self) -> Option<&[u8]> {
self.dht_token.as_deref()
}
pub fn set_dht_token(&mut self, token: Option<Vec<u8>>) {
self.dht_token = token;
}
#[must_use]
pub fn segment_assignments(&self) -> &[SegmentAssignment] {
&self.segment_assignments
}
pub fn segment_assignments_mut(&mut self) -> &mut Vec<SegmentAssignment> {
&mut self.segment_assignments
}
pub fn set_segment_assignments(&mut self, assignments: Vec<SegmentAssignment>) {
self.num_connections = u32::try_from(assignments.len()).unwrap_or(u32::MAX);
self.segment_assignments = assignments;
}
pub fn clear_segment_assignments(&mut self) {
self.num_connections = 0;
self.segment_assignments.clear();
}
#[must_use]
pub fn segment_runtime_stats(&self) -> SegmentRuntimeStats {
let mut stats = SegmentRuntimeStats::default();
let mut covered_start: Option<u64> = None;
let mut covered_end: Option<u64> = None;
for assignment in &self.segment_assignments {
stats.segment_count += 1;
match assignment.state {
SegmentState::Active => stats.active_count += 1,
SegmentState::Retrying => stats.retrying_count += 1,
SegmentState::Complete => stats.complete_count += 1,
SegmentState::Planned => {}
}
stats.planned_bytes = stats.planned_bytes.saturating_add(assignment.range.len());
stats.completed_bytes = stats
.completed_bytes
.saturating_add(assignment.completed_length.min(assignment.range.len()));
stats.remaining_bytes = stats
.remaining_bytes
.saturating_add(assignment.remaining_length());
covered_start = Some(covered_start.map_or(assignment.range.start, |start| {
start.min(assignment.range.start)
}));
covered_end =
Some(covered_end.map_or(assignment.range.end, |end| end.max(assignment.range.end)));
}
stats.covered_range = covered_start
.zip(covered_end)
.map(|(start, end)| PieceRange::new(start, end));
stats
}
}
/// Parses positive numeric option values from integer or human-size option forms.
fn parse_option_limit(value: Option<&OptionValue>) -> Option<u64> {
match value {
Some(OptionValue::UInt(value)) => (*value > 0).then_some(*value),
Some(OptionValue::Int(value)) => u64::try_from(*value).ok().filter(|value| *value > 0),
Some(OptionValue::Text(value)) => parse_human_size_text(value).filter(|limit| *limit > 0),
_ => None,
}
}
@@ -0,0 +1,107 @@
use std::fmt::{Display, Formatter};
use crate::piece::PieceId;
/// Stable identifier for a download group.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DownloadId(u64);
impl DownloadId {
/// Wraps the raw numeric identifier used internally and on the RPC surface.
#[must_use]
pub const fn new(raw: u64) -> Self {
Self(raw)
}
/// Returns the raw numeric identifier.
#[must_use]
pub const fn as_u64(self) -> u64 {
self.0
}
/// Parses the hexadecimal GID representation used by aria2 RPC clients.
#[must_use]
pub fn parse_hex(raw: &str) -> Option<Self> {
u64::from_str_radix(raw, 16).ok().map(Self)
}
}
impl Display for DownloadId {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{:016x}", self.0)
}
}
/// User-visible lifecycle state for a download group.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DownloadStatus {
/// The group is actively transferring data.
Active,
/// The group is queued and waiting to start.
Waiting,
/// The group is paused by user or scheduler action.
Paused,
/// The group stopped because the last attempt failed.
Error,
/// The group finished successfully.
Complete,
/// The group was removed from runtime state.
Removed,
}
impl DownloadStatus {
/// Returns the lowercase RPC status token expected by aria2-compatible clients.
#[must_use]
pub const fn as_rpc_status(&self) -> &'static str {
match self {
Self::Active => "active",
Self::Waiting => "waiting",
Self::Paused => "paused",
Self::Error => "error",
Self::Complete => "complete",
Self::Removed => "removed",
}
}
}
/// Captures one retry decision for a request or segment.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct RetryAttempt {
/// Retry attempt ordinal, starting at one for the first retry.
pub attempt: u32,
/// Byte offset at which the retry resumes.
pub offset: u64,
/// Optional retry length when the retry only covers one segment.
pub length: Option<u64>,
/// Human-readable error that triggered the retry.
pub error: Option<String>,
/// Whether the error is considered recoverable by the scheduler.
pub recoverable: bool,
}
impl RetryAttempt {
/// Builds a recoverable retry record for the provided attempt number and offset.
#[must_use]
pub const fn new(attempt: u32, offset: u64) -> Self {
Self {
attempt,
offset,
length: None,
error: None,
recoverable: true,
}
}
}
/// Resume metadata recovered from persisted session state.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct ResumeState {
/// Whether this resume snapshot came from persisted storage.
pub persisted: bool,
/// Byte offset from which the resumed transfer should continue.
pub resume_offset: u64,
/// Verified payload length recovered from prior state, if known.
pub validated_length: Option<u64>,
/// Optional piece cursor used to continue segmented scheduling.
pub segment_cursor: Option<PieceId>,
}
@@ -0,0 +1,929 @@
use super::*;
#[test]
fn request_context_replace_uris_filters_blank_entries_and_duplicates() {
let mut context = RequestContext::new(String::new());
assert_eq!(context.uri(), "");
assert!(context.uris().is_empty());
context.replace_uris(vec![
String::new(),
"https://example.org/file.iso".to_owned(),
"https://example.org/file.iso".to_owned(),
" ".to_owned(),
"https://mirror.example.org/file.iso".to_owned(),
]);
assert_eq!(context.uri(), "https://example.org/file.iso");
assert_eq!(
context.uris(),
&[
"https://example.org/file.iso".to_owned(),
"https://mirror.example.org/file.iso".to_owned(),
]
);
}
#[test]
fn request_context_insert_and_append_reposition_existing_uris_without_duplicates() {
let mut context = RequestContext::new("https://example.org/file.iso");
context.replace_uris(vec![
"https://example.org/file.iso".to_owned(),
"https://mirror-a.example.org/file.iso".to_owned(),
"https://mirror-b.example.org/file.iso".to_owned(),
]);
context.insert_uri(0, "https://mirror-b.example.org/file.iso");
assert_eq!(context.uri(), "https://mirror-b.example.org/file.iso");
assert_eq!(
context.uris(),
&[
"https://mirror-b.example.org/file.iso".to_owned(),
"https://example.org/file.iso".to_owned(),
"https://mirror-a.example.org/file.iso".to_owned(),
]
);
context.append_uri("https://example.org/file.iso");
context.append_uri(" ");
assert_eq!(context.uri(), "https://mirror-b.example.org/file.iso");
assert_eq!(
context.uris(),
&[
"https://mirror-b.example.org/file.iso".to_owned(),
"https://mirror-a.example.org/file.iso".to_owned(),
"https://example.org/file.iso".to_owned(),
]
);
}
#[test]
fn request_context_remove_last_uri_clears_primary_uri() {
let mut context = RequestContext::new("https://example.org/last.iso");
assert!(context.remove_first_matching_uri("https://example.org/last.iso"));
assert_eq!(context.uri(), "");
assert!(context.uris().is_empty());
assert!(!context.remove_first_matching_uri("https://example.org/last.iso"));
}
#[test]
fn request_group_with_context_normalizes_stale_primary_and_uri_list() {
let group = RequestGroup::with_context(
DownloadId::new(0x77),
RequestContext {
source: None,
uri: "https://stale.example.org/file.iso".to_owned(),
uris: vec![
String::new(),
"https://mirror-a.example.org/file.iso".to_owned(),
"https://mirror-a.example.org/file.iso".to_owned(),
"https://mirror-b.example.org/file.iso".to_owned(),
],
referer: None,
headers: Vec::new(),
group_id: None,
note: None,
},
);
assert_eq!(group.uri(), "https://mirror-a.example.org/file.iso");
assert_eq!(
group.uris(),
&[
"https://mirror-a.example.org/file.iso".to_owned(),
"https://mirror-b.example.org/file.iso".to_owned(),
]
);
}
#[test]
fn request_group_tracks_retry_attempt_history() {
let mut group = RequestGroup::new(DownloadId::new(0x1234), "https://example.test/file");
group.increment_retry_count();
group.push_retry_attempt(RetryAttempt {
attempt: group.retry_count(),
offset: 8192,
length: Some(4096),
error: Some("connection reset".to_string()),
recoverable: true,
});
assert_eq!(group.retry_count(), 1);
let [attempt] = group.retry_attempts() else {
panic!("retry_attempts should contain exactly one entry");
};
assert_eq!(attempt.offset, 8192);
assert_eq!(attempt.length, Some(4096));
assert_eq!(attempt.error.as_deref(), Some("connection reset"));
}
#[test]
fn request_group_resume_state_roundtrip() {
let mut group = RequestGroup::new(DownloadId::new(0x66), "https://example.test/file");
group.set_resume_state(ResumeState {
persisted: true,
resume_offset: 32768,
validated_length: Some(4096),
segment_cursor: Some(PieceId(8)),
});
let resume = group.resume_state().expect("resume state should exist");
assert!(resume.persisted);
assert_eq!(resume.resume_offset, 32768);
assert_eq!(resume.validated_length, Some(4096));
assert_eq!(resume.segment_cursor, Some(PieceId(8)));
group.clear_resume_state();
assert!(group.resume_state().is_none());
}
#[test]
fn request_group_dht_token_roundtrip() {
let mut group = RequestGroup::new(DownloadId::new(0x67), "magnet:?xt=urn:btih:token");
assert!(group.dht_token().is_none());
group.set_dht_token(Some(b"tok".to_vec()));
assert_eq!(group.dht_token(), Some(&b"tok"[..]));
group.set_dht_token(None);
assert!(group.dht_token().is_none());
}
#[test]
fn request_group_clear_retry_attempts() {
let mut group = RequestGroup::new(DownloadId::new(0x9), "https://example.test/file");
group.push_retry_attempt(RetryAttempt::new(1, 0));
group.push_retry_attempt(RetryAttempt::new(2, 1024));
assert_eq!(group.retry_attempts().len(), 2);
group.clear_retry_attempts();
assert!(group.retry_attempts().is_empty());
}
#[test]
fn request_group_tracks_segment_assignments() {
let mut group = RequestGroup::new(DownloadId::new(0xa), "https://example.test/file");
group.set_segment_assignments(vec![
SegmentAssignment::new(0, PieceRange::new(0, 1024)),
SegmentAssignment::new(1, PieceRange::new(1024, 2048)),
]);
assert_eq!(group.num_connections(), 2);
let [_, second_assignment] = group.segment_assignments() else {
panic!("segment_assignments should contain exactly two entries");
};
assert_eq!(second_assignment.range, PieceRange::new(1024, 2048));
group.clear_segment_assignments();
assert_eq!(group.num_connections(), 0);
assert!(group.segment_assignments().is_empty());
}
#[test]
fn request_group_bt_runtime_state_roundtrip_with_full_payload() {
let mut group = RequestGroup::new(DownloadId::new(0xb), "magnet:?xt=urn:btih:ABCDEF");
let bt = BtRuntimeState {
info_hash: "0123456789ABCDEF0123456789ABCDEF01234567".to_owned(),
name: Some("ubuntu.iso".to_owned()),
magnet_uri: Some("magnet:?xt=urn:btih:0123456789ABCDEF0123456789ABCDEF01234567".to_owned()),
metadata_only: true,
metadata_size: Some(32_768),
metadata_extension_ids: BTreeMap::from([("192.0.2.10:51413".to_owned(), 3_u8)]),
metadata_piece_payloads: BTreeMap::from([(0_u32, b"metadata-piece-0".to_vec())]),
creation_date: Some("2026-05-26T12:00:00Z".to_owned()),
comment: Some("bt runtime".to_owned()),
dht_nodes: vec!["router.bittorrent.com:6881".to_owned()],
files: vec![
BtFileInfo {
path: "ubuntu.iso".to_owned(),
length: 2048,
piece_offset: Some(0),
selected: true,
},
BtFileInfo {
path: "readme.txt".to_owned(),
length: 128,
piece_offset: Some(2),
selected: false,
},
],
trackers: vec![
BtTrackerInfo {
url: "udp://tracker.example.org:6969/announce".to_owned(),
tier: Some(0),
id: Some("trk-0".to_owned()),
seeders: Some(10),
leechers: Some(3),
},
BtTrackerInfo {
url: "https://tracker2.example.org/announce".to_owned(),
tier: Some(1),
id: None,
seeders: None,
leechers: None,
},
],
peers: vec![BtPeerInfo {
peer_id: Some("-TR3000-ABCDEF123456".to_owned()),
ip: "192.0.2.10".to_owned(),
port: 51413,
client_name: Some("Transmission".to_owned()),
interested: true,
choked: false,
download_speed: 4096,
upload_speed: 2048,
seeder: false,
}],
};
group.set_bt(bt.clone());
let saved = group.bt().expect("bt runtime state should be set");
assert_eq!(saved, &bt);
assert_eq!(
saved.dht_nodes(),
&["router.bittorrent.com:6881".to_owned()]
);
assert_eq!(saved.files.len(), 2);
assert_eq!(saved.trackers.len(), 2);
assert_eq!(saved.peers.len(), 1);
}
#[test]
fn request_group_bt_runtime_state_mutation_via_bt_mut() {
let mut group = RequestGroup::new(DownloadId::new(0xc), "magnet:?xt=urn:btih:AAAA");
group.set_bt(BtRuntimeState {
info_hash: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned(),
name: Some("seed".to_owned()),
magnet_uri: Some("magnet:?xt=urn:btih:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned()),
metadata_only: true,
metadata_size: None,
metadata_extension_ids: BTreeMap::new(),
metadata_piece_payloads: BTreeMap::new(),
creation_date: None,
comment: None,
dht_nodes: vec!["dht.transmissionbt.com:6881".to_owned()],
files: vec![BtFileInfo {
path: "seed.bin".to_owned(),
length: 1,
piece_offset: Some(0),
selected: true,
}],
trackers: vec![],
peers: vec![],
});
let bt = group.bt_mut().expect("bt runtime state should be mutable");
bt.metadata_only = false;
bt.comment = Some("metadata complete".to_owned());
bt.dht_nodes.push("router.utorrent.com:6881".to_owned());
bt.files.push(BtFileInfo {
path: "extra.bin".to_owned(),
length: 512,
piece_offset: Some(1),
selected: true,
});
bt.trackers.push(BtTrackerInfo {
url: "https://tracker.example.org/announce".to_owned(),
tier: Some(0),
id: Some("trk-a".to_owned()),
seeders: Some(1),
leechers: Some(0),
});
bt.peers.push(BtPeerInfo {
peer_id: None,
ip: "198.51.100.20".to_owned(),
port: 60000,
client_name: None,
interested: true,
choked: true,
download_speed: 0,
upload_speed: 0,
seeder: true,
});
let after = group.bt().expect("bt runtime state should still exist");
assert!(!after.metadata_only);
assert_eq!(after.comment.as_deref(), Some("metadata complete"));
assert_eq!(after.dht_nodes.len(), 2);
assert_eq!(after.files.len(), 2);
assert_eq!(after.trackers.len(), 1);
assert_eq!(after.peers.len(), 1);
}
#[test]
fn request_group_bt_runtime_state_can_be_cleared() {
let mut group = RequestGroup::new(DownloadId::new(0xd), "magnet:?xt=urn:btih:BBBB");
group.set_bt(BtRuntimeState {
info_hash: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_owned(),
name: None,
magnet_uri: Some("magnet:?xt=urn:btih:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_owned()),
metadata_only: true,
metadata_size: None,
metadata_extension_ids: BTreeMap::new(),
metadata_piece_payloads: BTreeMap::new(),
creation_date: None,
comment: None,
dht_nodes: Vec::new(),
files: Vec::new(),
trackers: Vec::new(),
peers: Vec::new(),
});
assert!(group.bt().is_some());
assert!(group.bt_mut().is_some());
group.clear_bt();
assert!(group.bt().is_none());
assert!(group.bt_mut().is_none());
}
#[test]
fn bt_runtime_state_reports_selected_file_helpers() {
let bt = BtRuntimeState {
info_hash: "0123456789abcdef0123456789abcdef01234567".to_owned(),
name: Some("example".to_owned()),
magnet_uri: None,
metadata_only: false,
metadata_size: None,
metadata_extension_ids: BTreeMap::new(),
metadata_piece_payloads: BTreeMap::new(),
creation_date: None,
comment: None,
dht_nodes: vec![
"router.bittorrent.com:6881".to_owned(),
"router.utorrent.com:6881".to_owned(),
],
files: vec![
BtFileInfo {
path: "selected.iso".to_owned(),
length: 2048,
piece_offset: Some(0),
selected: true,
},
BtFileInfo {
path: "ignored.txt".to_owned(),
length: 128,
piece_offset: Some(2048),
selected: false,
},
],
trackers: vec![],
peers: vec![],
};
let [first_file, ..] = bt.files() else {
panic!("bt files should contain at least one entry");
};
assert!(first_file.is_selected());
assert_eq!(bt.dht_nodes().len(), 2);
assert!(bt.has_selected_files());
assert_eq!(bt.selected_file_count(), 1);
assert_eq!(bt.selected_total_length(), 2048);
let selected_paths: Vec<_> = bt.selected_files().map(|file| file.path.as_str()).collect();
assert_eq!(selected_paths, vec!["selected.iso"]);
}
#[test]
fn request_group_bt_share_state_roundtrip_and_accessors_work() {
let mut group = RequestGroup::new(DownloadId::new(0xe), "magnet:?xt=urn:btih:CCCC");
group.set_bt_share_state(BtShareRuntimeState {
seeding: true,
share_ratio_milli: Some(1500),
share_time_secs: 3600,
seeding_time_secs: 900,
seeding_started_at_secs: None,
last_runtime_tick_secs: None,
});
group.set_bt(BtRuntimeState {
info_hash: "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC".to_owned(),
name: Some("payload".to_owned()),
magnet_uri: None,
metadata_only: false,
metadata_size: None,
metadata_extension_ids: BTreeMap::new(),
metadata_piece_payloads: BTreeMap::new(),
creation_date: None,
comment: None,
dht_nodes: vec!["router.bittorrent.com:6881".to_owned()],
files: vec![BtFileInfo {
path: "payload.bin".to_owned(),
length: 4096,
piece_offset: Some(0),
selected: true,
}],
trackers: vec![],
peers: vec![],
});
assert!(group.bt_is_seeding());
assert_eq!(group.bt_share_ratio_milli(), Some(1500));
assert_eq!(group.bt_share_time_secs(), Some(3600));
assert_eq!(group.bt_seeding_time_secs(), Some(900));
assert_eq!(group.bt_selected_file_count(), Some(1));
assert_eq!(group.bt_selected_total_length(), Some(4096));
assert!(group.bt_has_selected_files());
let share = group
.bt_share_state_mut()
.expect("share state should exist");
share.add_share_time_secs(120);
share.add_seeding_time_secs(30);
share.set_seeding(false);
assert!(!group.bt_is_seeding());
assert_eq!(group.bt_share_time_secs(), Some(3720));
assert_eq!(group.bt_seeding_time_secs(), Some(930));
group.clear_bt_share_state();
assert!(group.bt_share_state().is_none());
assert!(!group.bt_is_seeding());
}
#[test]
fn bt_share_runtime_state_tracks_seeding_runtime_and_ratio_derivation() {
let mut state = BtShareRuntimeState::new();
state.start_seeding(100);
state.tick_runtime(130);
state.tick_runtime(170);
state.stop_seeding(200);
state.tick_runtime(250);
state.refresh_share_ratio_from_lengths(6000, 4000);
assert!(!state.is_seeding());
assert_eq!(state.share_time_secs(), 150);
assert_eq!(state.seeding_time_secs(), 100);
assert_eq!(state.share_ratio_milli(), Some(1500));
assert_eq!(BtShareRuntimeState::derive_share_ratio_milli(100, 0), None);
}
#[test]
fn request_group_bt_runtime_tick_updates_true_seeding_and_share_runtime() {
let mut group = RequestGroup::new(DownloadId::new(0x101), "magnet:?xt=urn:btih:RUNTIME");
group.set_total_length(2_048);
group.set_completed_length(2_048);
group.set_bt(BtRuntimeState {
metadata_only: false,
files: vec![BtFileInfo {
path: "payload.bin".to_owned(),
length: 2_048,
piece_offset: Some(0),
selected: true,
}],
..BtRuntimeState::default()
});
let started = group.set_bt_seeding_state(true, Some(100));
assert!(group.bt_is_seeding());
assert!(group.bt_is_true_seeding());
assert!(started.seeding);
assert_eq!(started.share_ratio_milli, Some(0));
let advanced = group.tick_bt_runtime_clock(130, true);
assert_eq!(advanced.share_time_secs, 30);
assert_eq!(advanced.seeding_time_secs, 30);
assert!(advanced.seeding);
let tick = group.apply_bt_runtime_tick(0, 512, 64, 128, 10, 10, true, Some(8));
assert_eq!(group.upload_length(), 512);
assert_eq!(group.download_speed(), 64);
assert_eq!(group.upload_speed(), 128);
assert_eq!(group.num_connections(), 8);
assert_eq!(tick.share_time_secs, 40);
assert_eq!(tick.seeding_time_secs, 40);
assert_eq!(tick.share_ratio_milli, Some(250));
assert!(tick.seeding);
let stopped = group.tick_bt_runtime_clock(160, false);
assert!(!group.bt_is_seeding());
assert!(!group.bt_is_true_seeding());
assert!(!stopped.seeding);
assert_eq!(stopped.share_time_secs, 70);
assert_eq!(stopped.seeding_time_secs, 70);
}
#[test]
fn request_group_bt_share_ratio_base_length_follows_selected_payload() {
let mut group = RequestGroup::new(DownloadId::new(0x102), "magnet:?xt=urn:btih:BASE");
group.set_total_length(10_000);
group.set_completed_length(3_000);
group.set_bt(BtRuntimeState {
metadata_only: false,
files: vec![
BtFileInfo {
path: "selected-a.bin".to_owned(),
length: 2_000,
piece_offset: Some(0),
selected: true,
},
BtFileInfo {
path: "selected-b.bin".to_owned(),
length: 1_000,
piece_offset: Some(2),
selected: true,
},
BtFileInfo {
path: "ignored.bin".to_owned(),
length: 7_000,
piece_offset: Some(3),
selected: false,
},
],
..BtRuntimeState::default()
});
assert_eq!(group.bt_share_ratio_base_length(), Some(3_000));
group.set_completed_length(1_000);
assert_eq!(group.bt_share_ratio_base_length(), Some(3_000));
group.set_completed_length(5_000);
assert_eq!(group.bt_share_ratio_base_length(), Some(5_000));
}
#[test]
fn request_group_refresh_bt_share_runtime_uses_share_ratio_base_length() {
let mut group = RequestGroup::new(DownloadId::new(0x103), "magnet:?xt=urn:btih:RATIO");
group.set_total_length(10_000);
group.set_completed_length(6_000);
group.set_upload_length(3_000);
group.set_bt(BtRuntimeState {
metadata_only: false,
files: vec![
BtFileInfo {
path: "selected.bin".to_owned(),
length: 4_000,
piece_offset: Some(0),
selected: true,
},
BtFileInfo {
path: "ignored.bin".to_owned(),
length: 6_000,
piece_offset: Some(4),
selected: false,
},
],
..BtRuntimeState::default()
});
group.set_bt_share_state(BtShareRuntimeState::default());
let snapshot = group.refresh_bt_share_runtime();
assert_eq!(group.bt_share_ratio_base_length(), Some(6_000));
assert_eq!(snapshot.share_ratio_milli, Some(500));
assert_eq!(group.bt_share_ratio_milli(), Some(500));
}
#[test]
fn request_group_set_bt_refreshes_cached_share_ratio_after_selection_changes() {
let mut group = RequestGroup::new(DownloadId::new(0x104), "magnet:?xt=urn:btih:SELECT");
group.set_total_length(10_000);
group.set_completed_length(4_000);
group.set_upload_length(2_000);
group.set_bt(BtRuntimeState {
metadata_only: false,
files: vec![
BtFileInfo {
path: "disc-a.bin".to_owned(),
length: 5_000,
piece_offset: Some(0),
selected: true,
},
BtFileInfo {
path: "disc-b.bin".to_owned(),
length: 5_000,
piece_offset: Some(5),
selected: true,
},
],
..BtRuntimeState::default()
});
group.set_bt_share_state(BtShareRuntimeState::default());
assert_eq!(
group.refresh_bt_share_runtime().share_ratio_milli,
Some(200)
);
group.set_bt(BtRuntimeState {
metadata_only: false,
files: vec![
BtFileInfo {
path: "disc-a.bin".to_owned(),
length: 4_000,
piece_offset: Some(0),
selected: true,
},
BtFileInfo {
path: "disc-b.bin".to_owned(),
length: 6_000,
piece_offset: Some(4),
selected: false,
},
],
..BtRuntimeState::default()
});
assert_eq!(group.bt_selected_total_length(), Some(4_000));
assert_eq!(group.bt_share_ratio_milli(), Some(500));
}
#[test]
fn request_group_set_upload_length_refreshes_cached_bt_share_ratio() {
let mut group = RequestGroup::new(DownloadId::new(0x105), "magnet:?xt=urn:btih:UPLOAD");
group.set_total_length(4_000);
group.set_completed_length(4_000);
group.set_upload_length(1_000);
group.set_bt(BtRuntimeState {
metadata_only: false,
files: vec![BtFileInfo {
path: "payload.bin".to_owned(),
length: 4_000,
piece_offset: Some(0),
selected: true,
}],
..BtRuntimeState::default()
});
group.set_bt_share_state(BtShareRuntimeState::default());
assert_eq!(
group.refresh_bt_share_runtime().share_ratio_milli,
Some(250)
);
group.set_upload_length(2_000);
assert_eq!(group.bt_share_ratio_milli(), Some(500));
}
#[test]
fn request_group_bt_piece_helpers_report_counts_and_requestable_ids() {
let mut group = RequestGroup::new(DownloadId::new(0xf), "magnet:?xt=urn:btih:DDDD");
group.set_piece_state(PieceId(0), PieceState::Verified);
group.set_piece_state(PieceId(1), PieceState::Pending);
group.set_piece_state(PieceId(2), PieceState::Queued);
group.set_piece_state(PieceId(3), PieceState::Downloading);
group.set_piece_state(PieceId(4), PieceState::Missing);
group.set_piece_state(PieceId(5), PieceState::Skipped);
assert_eq!(group.bt_verified_piece_count(), 1);
assert_eq!(group.piece_state_counts(), (1, 1, 1, 1, 1, 1));
assert_eq!(
group.bt_requestable_piece_ids(false, 8),
vec![PieceId(1), PieceId(2), PieceId(4)]
);
assert_eq!(
group.bt_requestable_piece_ids(true, 8),
vec![PieceId(1), PieceId(2), PieceId(4), PieceId(3)]
);
}
#[test]
fn request_group_bt_effective_target_and_remaining_length_follow_selection() {
let mut group = RequestGroup::new(DownloadId::new(0x10), "magnet:?xt=urn:btih:EEEE");
group.set_total_length(10_000);
group.set_completed_length(4_000);
group.set_bt(BtRuntimeState {
metadata_only: false,
files: vec![
BtFileInfo {
path: "wanted-a.bin".to_owned(),
length: 3_000,
piece_offset: Some(0),
selected: true,
},
BtFileInfo {
path: "wanted-b.bin".to_owned(),
length: 2_000,
piece_offset: Some(3),
selected: true,
},
BtFileInfo {
path: "ignored.bin".to_owned(),
length: 5_000,
piece_offset: Some(5),
selected: false,
},
],
..BtRuntimeState::default()
});
assert_eq!(group.bt_effective_target_length(), Some(5_000));
assert_eq!(group.bt_remaining_work_length(), Some(1_000));
group.set_completed_length(9_000);
assert_eq!(group.bt_remaining_work_length(), Some(0));
}
#[test]
fn request_group_bt_piece_block_update_tracks_piece_progress_and_selected_span() {
let mut group = RequestGroup::new(DownloadId::new(0x11), "magnet:?xt=urn:btih:FFFF");
group.set_total_length(4_096);
group.set_piece_length(1_024);
group.set_bt(BtRuntimeState {
metadata_only: false,
files: vec![BtFileInfo {
path: "wanted.bin".to_owned(),
length: 2_500,
piece_offset: Some(0),
selected: true,
}],
..BtRuntimeState::default()
});
let partial = group.apply_bt_piece_block_update(BtPieceBlockUpdate {
piece_id: PieceId(2),
completed_blocks: 2,
total_blocks: 4,
});
assert_eq!(group.piece_state(PieceId(2)), Some(PieceState::Downloading));
assert_eq!(group.completed_length(), 0);
assert_eq!(partial.completed_length_delta, 0);
assert_eq!(partial.previous_state, None);
assert_eq!(partial.next_state, PieceState::Downloading);
assert_eq!(partial.piece_span_length, 452);
assert_eq!(partial.block_completion_milli, 500);
assert!(!partial.transitioned_to_verified);
let verified = group.apply_bt_piece_block_update(BtPieceBlockUpdate {
piece_id: PieceId(2),
completed_blocks: 4,
total_blocks: 4,
});
assert_eq!(group.piece_state(PieceId(2)), Some(PieceState::Verified));
assert_eq!(group.completed_length(), 452);
assert_eq!(verified.completed_length_delta, 452);
assert_eq!(verified.previous_state, Some(PieceState::Downloading));
assert_eq!(verified.next_state, PieceState::Verified);
assert_eq!(verified.block_completion_milli, 1000);
assert!(verified.transitioned_to_verified);
let missing = group.apply_bt_piece_block_update(BtPieceBlockUpdate {
piece_id: PieceId(2),
completed_blocks: 0,
total_blocks: 0,
});
assert_eq!(group.piece_state(PieceId(2)), Some(PieceState::Missing));
assert_eq!(group.completed_length(), 0);
assert_eq!(missing.completed_length_delta, -452);
assert_eq!(missing.previous_state, Some(PieceState::Verified));
assert_eq!(missing.next_state, PieceState::Missing);
assert_eq!(missing.piece_span_length, 452);
assert_eq!(missing.block_completion_milli, 0);
}
#[test]
fn request_group_bt_peer_and_availability_updates_report_runtime_stats() {
let mut group = RequestGroup::new(DownloadId::new(0x12), "magnet:?xt=urn:btih:9999");
group.set_piece_state(PieceId(0), PieceState::Missing);
group.set_piece_state(PieceId(1), PieceState::Queued);
group.set_piece_state(PieceId(2), PieceState::Verified);
group.set_bt(BtRuntimeState {
info_hash: "9999".to_owned(),
metadata_only: false,
..BtRuntimeState::default()
});
let available = group.apply_bt_piece_availability_update(BtPieceAvailabilityUpdate {
piece_id: PieceId(0),
peers_with_piece: 3,
});
assert_eq!(available.available_piece_count, 1);
assert_eq!(available.peers_with_piece, 3);
assert!(available.piece_is_requestable);
assert!(!available.piece_is_verified);
let verified_piece = group.apply_bt_piece_availability_update(BtPieceAvailabilityUpdate {
piece_id: PieceId(2),
peers_with_piece: 5,
});
assert_eq!(verified_piece.available_piece_count, 2);
assert!(!verified_piece.piece_is_requestable);
assert!(verified_piece.piece_is_verified);
let peer_a = group.apply_bt_peer_update(BtPeerInfo {
peer_id: Some("peer-a".to_owned()),
ip: "127.0.0.1".to_owned(),
port: 6881,
client_name: Some("client-a".to_owned()),
interested: true,
choked: false,
download_speed: 512,
upload_speed: 64,
seeder: false,
});
assert_eq!(peer_a.peer_count, 1);
assert_eq!(peer_a.seeder_count, 0);
assert_eq!(peer_a.leecher_count, 1);
assert_eq!(peer_a.total_download_speed, 512);
assert_eq!(peer_a.total_upload_speed, 64);
assert!(!peer_a.replaced_existing);
let peer_b = group.apply_bt_peer_update(BtPeerInfo {
peer_id: Some("peer-a".to_owned()),
ip: "127.0.0.1".to_owned(),
port: 6881,
client_name: Some("client-a2".to_owned()),
interested: false,
choked: true,
download_speed: 1_024,
upload_speed: 256,
seeder: true,
});
assert_eq!(peer_b.peer_count, 1);
assert_eq!(peer_b.seeder_count, 1);
assert_eq!(peer_b.leecher_count, 0);
assert_eq!(peer_b.total_download_speed, 1_024);
assert_eq!(peer_b.total_upload_speed, 256);
assert!(peer_b.replaced_existing);
}
#[test]
fn request_group_segment_runtime_stats_capture_assignment_load() {
let mut group = RequestGroup::new(DownloadId::new(0x13), "https://example.org/segments.bin");
group.set_segment_assignments(vec![
SegmentAssignment {
slot: 0,
range: PieceRange::new(0, 1024),
completed_length: 512,
state: SegmentState::Active,
},
SegmentAssignment {
slot: 1,
range: PieceRange::new(1024, 2048),
completed_length: 1024,
state: SegmentState::Complete,
},
SegmentAssignment {
slot: 2,
range: PieceRange::new(2048, 3072),
completed_length: 128,
state: SegmentState::Retrying,
},
]);
let stats = group.segment_runtime_stats();
assert_eq!(stats.segment_count, 3);
assert_eq!(stats.active_count, 1);
assert_eq!(stats.retrying_count, 1);
assert_eq!(stats.complete_count, 1);
assert_eq!(stats.planned_bytes, 3072);
assert_eq!(stats.completed_bytes, 1664);
assert_eq!(stats.remaining_bytes, 1408);
assert_eq!(stats.covered_range, Some(PieceRange::new(0, 3072)));
}
#[test]
fn request_group_bt_pressure_snapshot_reports_requestable_and_scarcity() {
let mut group = RequestGroup::new(DownloadId::new(0x14), "magnet:?xt=urn:btih:PRESSURE2");
group.set_piece_state(PieceId(0), PieceState::Verified);
group.set_piece_state(PieceId(1), PieceState::Pending);
group.set_piece_state(PieceId(2), PieceState::Downloading);
group.set_piece_state(PieceId(3), PieceState::Queued);
group.set_piece_state(PieceId(4), PieceState::Missing);
group.set_bt(BtRuntimeState {
metadata_only: false,
peers: vec![
BtPeerInfo {
peer_id: Some("peer-a".to_owned()),
ip: "198.51.100.10".to_owned(),
port: 6881,
client_name: None,
interested: true,
choked: false,
download_speed: 256,
upload_speed: 64,
seeder: false,
},
BtPeerInfo {
peer_id: Some("peer-b".to_owned()),
ip: "198.51.100.11".to_owned(),
port: 6882,
client_name: None,
interested: false,
choked: true,
download_speed: 0,
upload_speed: 32,
seeder: true,
},
],
..BtRuntimeState::default()
});
group.apply_bt_piece_availability_update(BtPieceAvailabilityUpdate {
piece_id: PieceId(1),
peers_with_piece: 1,
});
group.apply_bt_piece_availability_update(BtPieceAvailabilityUpdate {
piece_id: PieceId(3),
peers_with_piece: 3,
});
let pressure = group
.bt_pressure_snapshot()
.expect("bt pressure snapshot should exist");
assert_eq!(pressure.total_pieces, 5);
assert_eq!(pressure.requestable_pieces, 3);
assert_eq!(pressure.active_pieces, 2);
assert_eq!(pressure.available_requestable_pieces, 2);
assert_eq!(pressure.scarce_requestable_pieces, 1);
assert_eq!(pressure.peer_count, 2);
assert_eq!(pressure.seeder_count, 1);
assert_eq!(pressure.leecher_count, 1);
}
@@ -0,0 +1,67 @@
use crate::piece::PieceRange;
/// Scheduling state for a single HTTP segment assignment.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SegmentState {
/// The segment is planned but no worker has claimed it yet.
Planned,
/// The segment is actively downloading.
Active,
/// The segment is waiting for a retry.
Retrying,
/// The segment finished successfully.
Complete,
}
/// Active or planned range assignment for a segmented transfer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SegmentAssignment {
/// Scheduler slot associated with this assignment.
pub slot: usize,
/// Piece range covered by the assignment.
pub range: PieceRange,
/// Number of bytes already completed inside the range.
pub completed_length: u64,
/// Current scheduling state of the assignment.
pub state: SegmentState,
}
impl SegmentAssignment {
/// Builds a planned assignment for the provided slot and range.
#[must_use]
pub const fn new(slot: usize, range: PieceRange) -> Self {
Self {
slot,
range,
completed_length: 0,
state: SegmentState::Planned,
}
}
/// Returns the remaining byte count inside the assigned range.
#[must_use]
pub const fn remaining_length(&self) -> u64 {
self.range.len().saturating_sub(self.completed_length)
}
}
/// Aggregated runtime counters for the segment scheduler.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SegmentRuntimeStats {
/// Total number of segment assignments known to the scheduler.
pub segment_count: usize,
/// Number of assignments currently marked active.
pub active_count: usize,
/// Number of assignments currently waiting for retry.
pub retrying_count: usize,
/// Number of assignments already completed.
pub complete_count: usize,
/// Total byte length covered by all planned ranges.
pub planned_bytes: u64,
/// Total byte length completed across all assignments.
pub completed_bytes: u64,
/// Remaining byte length across all assignments.
pub remaining_bytes: u64,
/// Smallest range spanning all scheduled segments, if one exists.
pub covered_range: Option<PieceRange>,
}
+206
View File
@@ -0,0 +1,206 @@
//! Runtime configuration defaults and human-readable size parsing helpers.
use std::path::PathBuf;
/// Runtime configuration used to build and operate the download engine.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RuntimeConfig {
/// Number of worker threads available to the runtime.
pub worker_threads: usize,
/// Maximum number of simultaneously active downloads.
pub max_active_downloads: usize,
/// Maximum number of tracked downloads.
pub max_downloads: usize,
/// Desired split count per download.
pub split: usize,
/// Canonical maximum connections per server option.
pub max_connections_per_server: usize,
/// Compatibility alias for the maximum connections per server option.
pub max_connection_per_server: usize,
/// Global download-rate cap in bytes per second.
pub max_overall_download_limit: Option<u64>,
/// Per-download download-rate cap in bytes per second.
pub max_download_limit: Option<u64>,
/// Global upload-rate cap in bytes per second.
pub max_overall_upload_limit: Option<u64>,
/// Per-download upload-rate cap in bytes per second.
pub max_upload_limit: Option<u64>,
/// Minimum size used when splitting work into segments.
pub min_split_size: u64,
/// Default piece length for newly created downloads.
pub piece_length: u64,
/// RPC listen port.
pub rpc_port: u16,
/// `BitTorrent` listen port.
pub listen_port: u16,
/// Configured disk-cache size in bytes.
pub disk_cache_bytes: u64,
/// Event queue buffer size.
pub event_buffer_size: usize,
/// Optional session file path.
pub session_path: Option<PathBuf>,
/// Interval between session saves in seconds.
pub save_session_interval_secs: u64,
/// Graceful shutdown timeout in seconds.
pub graceful_shutdown_timeout_secs: u64,
/// Whether XML-RPC endpoints are enabled.
pub allow_xmlrpc: bool,
/// Whether JSON-RPC endpoints are enabled.
pub allow_jsonrpc: bool,
/// Whether resume behavior is enabled.
pub allow_resume: bool,
/// Whether IPv6 support is enabled.
pub enable_ipv6: bool,
/// Whether HTTP 400 responses are retryable.
pub retry_on_400: bool,
/// Whether HTTP 403 responses are retryable.
pub retry_on_403: bool,
/// Whether HTTP 406 responses are retryable.
pub retry_on_406: bool,
/// Whether unknown failures are retryable.
pub retry_on_unknown: bool,
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self {
worker_threads: 4,
max_active_downloads: 5,
max_downloads: 16,
split: 5,
max_connections_per_server: 1,
max_connection_per_server: 1,
max_overall_download_limit: None,
max_download_limit: None,
max_overall_upload_limit: None,
max_upload_limit: None,
min_split_size: 1_024,
piece_length: 1_024,
rpc_port: 6_800,
listen_port: 6_881,
disk_cache_bytes: 16 * 1_024 * 1_024,
event_buffer_size: 256,
session_path: None,
save_session_interval_secs: 30,
graceful_shutdown_timeout_secs: 10,
allow_xmlrpc: true,
allow_jsonrpc: true,
allow_resume: true,
enable_ipv6: false,
retry_on_400: true,
retry_on_403: true,
retry_on_406: true,
retry_on_unknown: true,
}
}
}
impl RuntimeConfig {
/// Returns a copy with the session path set.
#[must_use]
pub fn with_session_path(mut self, path: impl Into<PathBuf>) -> Self {
self.session_path = Some(path.into());
self
}
/// Returns a copy with the worker-thread count overridden.
#[must_use]
pub fn with_worker_threads(mut self, count: usize) -> Self {
self.worker_threads = count;
self
}
/// Returns a copy with the RPC port overridden.
#[must_use]
pub fn with_rpc_port(mut self, port: u16) -> Self {
self.rpc_port = port;
self
}
/// Returns a copy with the session path parsed from a string-like value.
#[must_use]
pub fn with_session_path_str(mut self, path: impl Into<String>) -> Self {
self.session_path = Some(PathBuf::from(path.into()));
self
}
/// Returns the effective max-connections-per-server setting.
#[must_use]
pub fn effective_max_connections_per_server(&self) -> usize {
self.max_connections_per_server
.max(self.max_connection_per_server)
.max(1)
}
/// Returns the effective split count.
#[must_use]
pub fn effective_split(&self) -> usize {
self.split.max(1)
}
/// Returns the effective maximum parallel segment count.
#[must_use]
pub fn effective_parallel_segments(&self) -> usize {
self.effective_split()
.min(self.effective_max_connections_per_server())
.max(1)
}
/// Returns retryability flags for the common HTTP error buckets.
#[must_use]
pub fn retryable_status_codes(&self) -> [bool; 4] {
[
self.retry_on_400,
self.retry_on_403,
self.retry_on_406,
self.retry_on_unknown,
]
}
}
/// Parses a human-readable byte-size string into a byte count.
#[must_use]
#[expect(
clippy::redundant_pub_crate,
reason = "session and request helpers reuse the parser while the runtime module remains crate-private"
)]
pub(crate) fn parse_human_size_text(value: &str) -> Option<u64> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
let split_index = trimmed
.find(|ch: char| !ch.is_ascii_digit())
.unwrap_or(trimmed.len());
let (digits, suffix) = trimmed.split_at(split_index);
let base = digits.parse::<u64>().ok()?;
let suffix = suffix.trim();
let factor = if suffix.is_empty() {
1
} else if suffix.eq_ignore_ascii_case("k") || suffix.eq_ignore_ascii_case("kb") {
1_024
} else if suffix.eq_ignore_ascii_case("m") || suffix.eq_ignore_ascii_case("mb") {
1_024 * 1_024
} else if suffix.eq_ignore_ascii_case("g") || suffix.eq_ignore_ascii_case("gb") {
1_024 * 1_024 * 1_024
} else {
return None;
};
Some(base.saturating_mul(factor))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_human_size_text_supports_plain_and_suffix_forms() {
assert_eq!(parse_human_size_text("2048"), Some(2048));
assert_eq!(parse_human_size_text("2K"), Some(2 * 1024));
assert_eq!(parse_human_size_text("4M"), Some(4 * 1024 * 1024));
assert_eq!(parse_human_size_text("3gb"), Some(3 * 1024 * 1024 * 1024));
assert_eq!(parse_human_size_text(""), None);
assert_eq!(parse_human_size_text("12T"), None);
}
}
+559
View File
@@ -0,0 +1,559 @@
//! Scheduling policies, planning state, and per-tick observations.
use crate::{
piece::{PieceId, PieceRange, PieceState},
request::{DownloadId, DownloadStatus, RequestGroup},
runtime::RuntimeConfig,
};
/// Policy used to choose the next runnable download.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum SchedulerPolicy {
/// Fair scheduling that balances active and waiting work.
#[default]
Fair,
/// FIFO queue ordering.
FirstInFirstOut,
/// LIFO queue ordering.
LastInFirstOut,
/// Round-robin scheduling.
RoundRobin,
}
/// Current scheduler lifecycle state.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum SchedulerState {
/// Scheduler is idle.
#[default]
Idle,
/// Scheduler is ready to plan work.
Ready,
/// Scheduler is actively running work.
Running,
/// Scheduler is paused.
Paused,
/// Scheduler is shutting down.
ShuttingDown,
/// Scheduler has stopped.
Stopped,
}
/// Action produced by a scheduling pass.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ScheduleDecision {
/// Start the given download immediately.
RunNow(DownloadId),
/// Keep the given download in the waiting queue.
Queue(DownloadId),
/// Pause the given download.
Pause(DownloadId),
/// Remove the given download.
Remove(DownloadId),
/// Requeue the given download for later retry.
RetryLater(DownloadId),
/// No action was required.
Noop,
}
/// Copyable discriminator for schedule decisions.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ScheduleDecisionKind {
/// Decision kind for starting a download immediately.
RunNow,
/// Decision kind for queueing a download.
Queue,
/// Decision kind for pausing a download.
Pause,
/// Decision kind for removing a download.
Remove,
/// Decision kind for retrying a download later.
RetryLater,
/// Decision kind for taking no action.
Noop,
}
/// Counters gathered while the scheduler is running.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SchedulerActivityCounters {
/// Number of scheduler ticks observed.
pub tick_count: u64,
/// Number of scheduling passes that started.
pub schedule_run_count: u64,
/// Number of immediate-run decisions emitted.
pub run_now_decision_count: u64,
/// Number of queue decisions emitted.
pub queue_decision_count: u64,
/// Number of pause decisions emitted.
pub pause_decision_count: u64,
/// Number of remove decisions emitted.
pub remove_decision_count: u64,
/// Number of retry-later decisions emitted.
pub retry_later_decision_count: u64,
/// Number of noop decisions emitted.
pub noop_decision_count: u64,
/// Most recent decision kind, when one has been recorded.
pub last_decision: Option<ScheduleDecisionKind>,
}
/// Snapshot of the most recent planning observation.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SchedulerPlanningObservation {
/// Download id for the observation.
pub gid: DownloadId,
/// Total payload length reported by the group.
pub total_length: u64,
/// Payload length considered plannable by the scheduler.
pub plannable_length: u64,
/// Completed portion of the plannable length.
pub completed_length: u64,
/// Remaining plannable bytes.
pub remaining_bytes: u64,
/// Number of segments the scheduler planned.
pub planned_segments: usize,
/// Number of active segments already running.
pub active_segment_count: usize,
/// Number of pieces currently requestable.
pub requestable_pieces: usize,
/// Number of active pieces currently downloading or queued.
pub active_piece_count: usize,
/// Number of requestable pieces available from peers.
pub available_requestable_pieces: usize,
/// Number of requestable pieces available from scarce peers only.
pub scarce_requestable_pieces: usize,
/// Number of peers visible in the swarm snapshot.
pub peer_count: usize,
/// Whether the observation considers endgame mode ready.
pub bt_endgame_ready: bool,
}
/// Parameters used to split a download into active segments.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SegmentPlan {
/// Requested split count for the download.
pub split: usize,
/// Minimum byte size allowed for a segment.
pub min_split_size: u64,
/// Piece length used to align piece-aware work.
pub piece_length: u64,
/// Maximum connections allowed per server.
pub max_connections_per_server: usize,
}
impl SegmentPlan {
/// Builds a segment plan from the runtime configuration.
#[must_use]
pub fn from_runtime(runtime: &RuntimeConfig) -> Self {
Self {
split: runtime.effective_split(),
min_split_size: runtime.min_split_size.max(1),
piece_length: runtime.piece_length.max(1),
max_connections_per_server: runtime.effective_max_connections_per_server(),
}
}
}
/// Entry recorded for a retry in the scheduler bridge state.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct RetryHistoryEntry {
/// Unix timestamp when the retry was recorded.
pub at_unix_secs: u64,
/// Human-readable retry reason.
pub reason: String,
}
/// Runtime state mirrored from the scheduler into the session bridge.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct RuntimeScheduleState {
/// Completed payload length mirrored from runtime state.
pub completed_length: u64,
/// Total retry count mirrored from runtime state.
pub retry_count: u32,
/// Retry history mirrored from runtime state.
pub retry_history: Vec<RetryHistoryEntry>,
/// Number of active segments mirrored from runtime state.
pub active_segments: usize,
}
impl RuntimeScheduleState {
/// Records a retry entry in the bridge state.
pub fn record_retry(&mut self, at_unix_secs: u64, reason: impl Into<String>) {
self.retry_count = self.retry_count.saturating_add(1);
self.retry_history.push(RetryHistoryEntry {
at_unix_secs,
reason: reason.into(),
});
}
/// Updates the mirrored completed length.
pub fn set_completed_length(&mut self, completed_length: u64) {
self.completed_length = completed_length;
}
/// Updates the mirrored active-segment count.
pub fn set_active_segments(&mut self, active_segments: usize) {
self.active_segments = active_segments;
}
/// Returns whether the scheduler should enter endgame mode.
#[must_use]
pub fn is_endgame_ready(remaining_pieces: usize, endgame_threshold: usize) -> bool {
remaining_pieces > 0 && remaining_pieces <= endgame_threshold.max(1)
}
}
/// Piece-selection options used when choosing `BitTorrent` work.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BtPieceSelectionOptions {
/// Maximum number of candidate pieces to return.
pub max_candidates: usize,
/// Whether downloading pieces stay eligible during endgame.
pub include_downloading_in_endgame: bool,
/// Whether endgame mode is currently active.
pub endgame_mode: bool,
}
impl Default for BtPieceSelectionOptions {
fn default() -> Self {
Self {
max_candidates: 32,
include_downloading_in_endgame: true,
endgame_mode: false,
}
}
}
/// Main scheduler state machine and planning helper.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Scheduler {
/// Scheduling policy used for decisions.
policy: SchedulerPolicy,
/// Current scheduler lifecycle state.
state: SchedulerState,
/// Maximum number of active downloads allowed at once.
max_active: usize,
/// Accumulated activity counters.
activity_counters: SchedulerActivityCounters,
/// Most recent planning observation captured by the scheduler.
last_planning_observation: Option<SchedulerPlanningObservation>,
}
impl Default for Scheduler {
fn default() -> Self {
Self::new()
}
}
impl Scheduler {
#[must_use]
/// Derives the number of bytes that are actually plannable for a group.
fn effective_plannable_total_length(group: &RequestGroup) -> u64 {
let total = group.total_length();
let Some(bt) = group.bt() else {
return total;
};
if bt.metadata_only {
return 0;
}
if bt.files.is_empty() {
return total;
}
let selected_total = bt
.files
.iter()
.filter(|file| file.selected)
.fold(0_u64, |acc, file| acc.saturating_add(file.length));
if selected_total == 0 {
return 0;
}
if total == 0 {
selected_total
} else {
selected_total.min(total)
}
}
/// Creates a scheduler with the default fair policy.
#[must_use]
pub fn new() -> Self {
Self {
policy: SchedulerPolicy::default(),
state: SchedulerState::default(),
max_active: 3,
activity_counters: SchedulerActivityCounters::default(),
last_planning_observation: None,
}
}
/// Returns a copy with the requested scheduling policy.
#[must_use]
pub fn with_policy(mut self, policy: SchedulerPolicy) -> Self {
self.policy = policy;
self
}
/// Returns the active scheduling policy.
#[must_use]
pub fn policy(&self) -> SchedulerPolicy {
self.policy
}
/// Returns the scheduler lifecycle state.
#[must_use]
pub fn state(&self) -> SchedulerState {
self.state
}
/// Updates the scheduler lifecycle state.
pub fn set_state(&mut self, state: SchedulerState) {
self.state = state;
}
/// Sets the maximum number of active downloads.
pub fn set_max_active(&mut self, max_active: usize) {
self.max_active = max_active;
}
/// Returns the configured max-active count.
#[must_use]
pub fn max_active(&self) -> usize {
self.max_active
}
/// Returns the accumulated activity counters.
#[must_use]
pub fn activity_counters(&self) -> &SchedulerActivityCounters {
&self.activity_counters
}
/// Returns the latest planning observation when available.
#[must_use]
pub fn last_planning_observation(&self) -> Option<&SchedulerPlanningObservation> {
self.last_planning_observation.as_ref()
}
/// Records that a scheduling pass started.
pub fn record_schedule_run(&mut self) {
self.activity_counters.schedule_run_count =
self.activity_counters.schedule_run_count.saturating_add(1);
}
/// Records a single scheduling decision in the activity counters.
pub fn record_decision(&mut self, decision: &ScheduleDecision) {
let kind = match decision {
ScheduleDecision::RunNow(_) => {
self.activity_counters.run_now_decision_count = self
.activity_counters
.run_now_decision_count
.saturating_add(1);
ScheduleDecisionKind::RunNow
}
ScheduleDecision::Queue(_) => {
self.activity_counters.queue_decision_count = self
.activity_counters
.queue_decision_count
.saturating_add(1);
ScheduleDecisionKind::Queue
}
ScheduleDecision::Pause(_) => {
self.activity_counters.pause_decision_count = self
.activity_counters
.pause_decision_count
.saturating_add(1);
ScheduleDecisionKind::Pause
}
ScheduleDecision::Remove(_) => {
self.activity_counters.remove_decision_count = self
.activity_counters
.remove_decision_count
.saturating_add(1);
ScheduleDecisionKind::Remove
}
ScheduleDecision::RetryLater(_) => {
self.activity_counters.retry_later_decision_count = self
.activity_counters
.retry_later_decision_count
.saturating_add(1);
ScheduleDecisionKind::RetryLater
}
ScheduleDecision::Noop => {
self.activity_counters.noop_decision_count =
self.activity_counters.noop_decision_count.saturating_add(1);
ScheduleDecisionKind::Noop
}
};
self.activity_counters.last_decision = Some(kind);
}
/// Chooses the next coarse action for the provided download group.
#[must_use]
pub fn decide(&self, group: &RequestGroup) -> ScheduleDecision {
match group.status() {
DownloadStatus::Waiting => ScheduleDecision::Queue(group.gid()),
DownloadStatus::Paused => ScheduleDecision::Pause(group.gid()),
DownloadStatus::Removed => ScheduleDecision::Remove(group.gid()),
DownloadStatus::Error => ScheduleDecision::RetryLater(group.gid()),
DownloadStatus::Complete => ScheduleDecision::Noop,
DownloadStatus::Active => ScheduleDecision::RunNow(group.gid()),
}
}
/// Advances the scheduler lifecycle by one tick.
#[must_use]
pub fn tick(&mut self) -> SchedulerState {
self.activity_counters.tick_count = self.activity_counters.tick_count.saturating_add(1);
self.state = match self.state {
SchedulerState::Idle => SchedulerState::Ready,
SchedulerState::Ready | SchedulerState::Running => SchedulerState::Running,
SchedulerState::Paused => SchedulerState::Paused,
SchedulerState::ShuttingDown | SchedulerState::Stopped => SchedulerState::Stopped,
};
self.state
}
/// Builds the segment-plan snapshot mirrored into session state.
#[must_use]
pub fn bridge_segment_plan(&self, runtime: &RuntimeConfig) -> SegmentPlan {
let _ = self;
SegmentPlan::from_runtime(runtime)
}
/// Builds the runtime-state snapshot mirrored into session state.
pub fn bridge_runtime_state(
&self,
completed_length: u64,
retry_count: u32,
retry_history: Vec<RetryHistoryEntry>,
active_segments: usize,
) -> RuntimeScheduleState {
let _ = self;
RuntimeScheduleState {
completed_length,
retry_count,
retry_history,
active_segments,
}
}
/// Computes the number of active segments the scheduler should plan.
#[must_use]
pub fn plan_active_segments(&self, group: &RequestGroup, runtime: &RuntimeConfig) -> usize {
if !matches!(
group.status(),
DownloadStatus::Active | DownloadStatus::Waiting
) {
return 0;
}
let split = runtime.effective_split();
let max_conn = runtime.effective_max_connections_per_server();
let max_parallel = split.min(max_conn).max(1);
let min_split_size = runtime.min_split_size.max(1);
let total = Self::effective_plannable_total_length(group);
let completed = group.completed_length().min(total);
let remaining = total.saturating_sub(completed);
if remaining == 0 {
return 0;
}
let by_size = usize::try_from(remaining.div_ceil(min_split_size)).unwrap_or(usize::MAX);
max_parallel.min(by_size.max(1))
}
/// Captures a planning observation for later inspection and persistence.
pub fn observe_plan(
&mut self,
group: &RequestGroup,
_runtime: &RuntimeConfig,
planned_segments: usize,
) {
let plannable_length = Self::effective_plannable_total_length(group);
let completed_length = group.completed_length().min(plannable_length);
let remaining_bytes = plannable_length.saturating_sub(completed_length);
let pressure = group.bt_pressure_snapshot();
self.last_planning_observation = Some(SchedulerPlanningObservation {
gid: group.gid(),
total_length: group.total_length(),
plannable_length,
completed_length,
remaining_bytes,
planned_segments,
active_segment_count: usize::try_from(group.num_connections()).unwrap_or(usize::MAX),
requestable_pieces: pressure
.as_ref()
.map_or(0, |snapshot| snapshot.requestable_pieces),
active_piece_count: pressure
.as_ref()
.map_or(0, |snapshot| snapshot.active_pieces),
available_requestable_pieces: pressure
.as_ref()
.map_or(0, |snapshot| snapshot.available_requestable_pieces),
scarce_requestable_pieces: pressure
.as_ref()
.map_or(0, |snapshot| snapshot.scarce_requestable_pieces),
peer_count: pressure.as_ref().map_or(0, |snapshot| snapshot.peer_count),
bt_endgame_ready: pressure.as_ref().is_some_and(|snapshot| {
snapshot.requestable_pieces > 0
&& snapshot.requestable_pieces <= planned_segments.max(1)
}),
});
}
/// Selects candidate `BitTorrent` pieces that are eligible for requests.
pub fn select_bt_piece_candidates(
&self,
group: &RequestGroup,
options: BtPieceSelectionOptions,
) -> Vec<PieceId> {
let _ = self;
group.bt_requestable_piece_ids(
options.endgame_mode && options.include_downloading_in_endgame,
options.max_candidates.max(1),
)
}
/// Builds piece-aligned byte ranges for the selected `BitTorrent` pieces.
pub fn plan_bt_piece_request_ranges(
&self,
group: &RequestGroup,
runtime: &RuntimeConfig,
options: BtPieceSelectionOptions,
) -> Vec<PieceRange> {
let _ = self;
let piece_length = runtime.piece_length.max(1);
self.select_bt_piece_candidates(group, options)
.into_iter()
.map(|piece_id| {
let start = u64::from(piece_id.0).saturating_mul(piece_length);
PieceRange::new(start, start.saturating_add(piece_length))
})
.collect()
}
/// Counts pieces that still require `BitTorrent` work.
pub fn bt_remaining_piece_count(&self, group: &RequestGroup) -> usize {
let _ = self;
group
.piece_map()
.iter()
.filter(|(_, state)| {
matches!(
state,
PieceState::Pending
| PieceState::Queued
| PieceState::Missing
| PieceState::Downloading
)
})
.count()
}
}
#[cfg(test)]
mod scheduler_tests;
@@ -0,0 +1,287 @@
use super::*;
use crate::{
piece::{PieceId, PieceState},
request::{BtFileInfo, BtPeerInfo, BtRuntimeState, DownloadId},
};
#[test]
fn segment_plan_uses_runtime_limits() {
let runtime = RuntimeConfig {
split: 8,
max_connections_per_server: 3,
max_connection_per_server: 2,
min_split_size: 1024,
..RuntimeConfig::default()
};
let plan = SegmentPlan::from_runtime(&runtime);
assert_eq!(plan.split, 8);
assert_eq!(plan.max_connections_per_server, 3);
assert_eq!(plan.min_split_size, 1024);
}
#[test]
fn plan_active_segments_respects_remaining_size_and_limits() {
let scheduler = Scheduler::new();
let runtime = RuntimeConfig {
split: 6,
max_connections_per_server: 4,
max_connection_per_server: 4,
min_split_size: 1024,
..RuntimeConfig::default()
};
let mut group = RequestGroup::new(DownloadId::new(1), "https://example.org/file.bin");
group.set_status(DownloadStatus::Active);
group.set_total_length(10 * 1024);
group.set_completed_length(2 * 1024);
assert_eq!(scheduler.plan_active_segments(&group, &runtime), 4);
group.set_completed_length(9 * 1024 + 900);
assert_eq!(scheduler.plan_active_segments(&group, &runtime), 1);
group.set_completed_length(group.total_length());
assert_eq!(scheduler.plan_active_segments(&group, &runtime), 0);
}
#[test]
fn plan_active_segments_returns_zero_for_non_runnable_states() {
let scheduler = Scheduler::new();
let runtime = RuntimeConfig::default();
let mut group = RequestGroup::new(DownloadId::new(2), "https://example.org/file.bin");
group.set_total_length(2048);
group.set_completed_length(0);
group.set_status(DownloadStatus::Paused);
assert_eq!(scheduler.plan_active_segments(&group, &runtime), 0);
group.set_status(DownloadStatus::Error);
assert_eq!(scheduler.plan_active_segments(&group, &runtime), 0);
}
#[test]
fn plan_active_segments_returns_zero_for_bt_metadata_only() {
let scheduler = Scheduler::new();
let runtime = RuntimeConfig {
split: 4,
max_connections_per_server: 4,
max_connection_per_server: 4,
min_split_size: 1024,
..RuntimeConfig::default()
};
let mut group = RequestGroup::new(DownloadId::new(3), "magnet:?xt=urn:btih:ABC");
group.set_status(DownloadStatus::Active);
group.set_total_length(8 * 1024);
group.set_completed_length(1024);
group.set_bt(BtRuntimeState {
metadata_only: true,
..BtRuntimeState::default()
});
assert_eq!(scheduler.plan_active_segments(&group, &runtime), 0);
}
#[test]
fn plan_active_segments_uses_bt_selected_files_length() {
let scheduler = Scheduler::new();
let runtime = RuntimeConfig {
split: 8,
max_connections_per_server: 8,
max_connection_per_server: 8,
min_split_size: 1024,
..RuntimeConfig::default()
};
let mut group = RequestGroup::new(DownloadId::new(4), "magnet:?xt=urn:btih:DEF");
group.set_status(DownloadStatus::Active);
group.set_total_length(10 * 1024);
group.set_completed_length(3500);
group.set_bt(BtRuntimeState {
files: vec![
BtFileInfo {
path: "wanted.bin".to_owned(),
length: 4 * 1024,
piece_offset: Some(0),
selected: true,
},
BtFileInfo {
path: "unwanted.bin".to_owned(),
length: 6 * 1024,
piece_offset: Some(4),
selected: false,
},
],
..BtRuntimeState::default()
});
// Selected total is 4096; after completed 3500 only 596 bytes remain,
// so the scheduler should avoid over-planning and keep a single segment.
assert_eq!(scheduler.plan_active_segments(&group, &runtime), 1);
}
#[test]
fn scheduler_selects_bt_piece_candidates_and_endgame_behavior() {
let scheduler = Scheduler::new();
let mut group = RequestGroup::new(DownloadId::new(5), "magnet:?xt=urn:btih:FFF");
group.set_piece_state(PieceId(0), PieceState::Verified);
group.set_piece_state(PieceId(1), PieceState::Pending);
group.set_piece_state(PieceId(2), PieceState::Missing);
group.set_piece_state(PieceId(3), PieceState::Downloading);
group.set_piece_state(PieceId(4), PieceState::Queued);
let normal = scheduler.select_bt_piece_candidates(
&group,
BtPieceSelectionOptions {
max_candidates: 8,
include_downloading_in_endgame: true,
endgame_mode: false,
},
);
assert_eq!(normal, vec![PieceId(1), PieceId(2), PieceId(4)]);
let endgame = scheduler.select_bt_piece_candidates(
&group,
BtPieceSelectionOptions {
max_candidates: 8,
include_downloading_in_endgame: true,
endgame_mode: true,
},
);
assert_eq!(
endgame,
vec![PieceId(1), PieceId(2), PieceId(4), PieceId(3)]
);
}
#[test]
fn scheduler_plans_bt_piece_ranges_from_runtime_piece_length() {
let scheduler = Scheduler::new();
let runtime = RuntimeConfig {
piece_length: 1024,
..RuntimeConfig::default()
};
let mut group = RequestGroup::new(DownloadId::new(6), "magnet:?xt=urn:btih:GGG");
group.set_piece_state(PieceId(2), PieceState::Pending);
group.set_piece_state(PieceId(5), PieceState::Missing);
let ranges = scheduler.plan_bt_piece_request_ranges(
&group,
&runtime,
BtPieceSelectionOptions {
max_candidates: 2,
include_downloading_in_endgame: false,
endgame_mode: false,
},
);
assert_eq!(
ranges,
vec![PieceRange::new(2048, 3072), PieceRange::new(5120, 6144)]
);
}
#[test]
fn runtime_schedule_state_reports_endgame_readiness() {
assert!(RuntimeScheduleState::is_endgame_ready(1, 3));
assert!(RuntimeScheduleState::is_endgame_ready(3, 3));
assert!(!RuntimeScheduleState::is_endgame_ready(4, 3));
assert!(!RuntimeScheduleState::is_endgame_ready(0, 3));
}
#[test]
fn scheduler_activity_counters_track_ticks_and_decisions() {
let mut scheduler = Scheduler::new();
assert_eq!(scheduler.activity_counters().tick_count, 0);
assert_eq!(scheduler.activity_counters().schedule_run_count, 0);
scheduler.record_schedule_run();
let _ = scheduler.tick();
scheduler.record_decision(&ScheduleDecision::Queue(DownloadId::new(0x21)));
scheduler.record_decision(&ScheduleDecision::RunNow(DownloadId::new(0x21)));
scheduler.record_decision(&ScheduleDecision::RetryLater(DownloadId::new(0x21)));
scheduler.record_decision(&ScheduleDecision::Noop);
let counters = scheduler.activity_counters();
assert_eq!(counters.tick_count, 1);
assert_eq!(counters.schedule_run_count, 1);
assert_eq!(counters.queue_decision_count, 1);
assert_eq!(counters.run_now_decision_count, 1);
assert_eq!(counters.retry_later_decision_count, 1);
assert_eq!(counters.noop_decision_count, 1);
assert_eq!(counters.last_decision, Some(ScheduleDecisionKind::Noop));
}
#[test]
fn scheduler_records_last_planning_observation_for_bt_pressure() {
let mut scheduler = Scheduler::new();
let runtime = RuntimeConfig {
split: 5,
max_connections_per_server: 3,
max_connection_per_server: 3,
min_split_size: 1024,
..RuntimeConfig::default()
};
let mut group = RequestGroup::new(DownloadId::new(0x22), "magnet:?xt=urn:btih:PRESSURE");
group.set_status(DownloadStatus::Active);
group.set_total_length(6 * 1024);
group.set_completed_length(1024);
group.set_piece_state(PieceId(0), PieceState::Verified);
group.set_piece_state(PieceId(1), PieceState::Pending);
group.set_piece_state(PieceId(2), PieceState::Downloading);
group.set_piece_state(PieceId(3), PieceState::Queued);
group.set_piece_state(PieceId(4), PieceState::Missing);
group.set_bt(BtRuntimeState {
metadata_only: false,
files: vec![BtFileInfo {
path: "payload.bin".to_owned(),
length: 6 * 1024,
piece_offset: Some(0),
selected: true,
}],
peers: vec![
BtPeerInfo {
peer_id: Some("peer-a".to_owned()),
ip: "192.0.2.1".to_owned(),
port: 6881,
client_name: None,
interested: true,
choked: false,
download_speed: 64,
upload_speed: 32,
seeder: false,
},
BtPeerInfo {
peer_id: Some("peer-b".to_owned()),
ip: "192.0.2.2".to_owned(),
port: 6882,
client_name: None,
interested: false,
choked: true,
download_speed: 0,
upload_speed: 16,
seeder: true,
},
],
..BtRuntimeState::default()
});
group.apply_bt_piece_availability_update(crate::request::BtPieceAvailabilityUpdate {
piece_id: PieceId(1),
peers_with_piece: 1,
});
group.apply_bt_piece_availability_update(crate::request::BtPieceAvailabilityUpdate {
piece_id: PieceId(3),
peers_with_piece: 2,
});
let planned = scheduler.plan_active_segments(&group, &runtime);
scheduler.observe_plan(&group, &runtime, planned);
let observation = scheduler
.last_planning_observation()
.expect("planning observation should be recorded");
assert_eq!(observation.gid, DownloadId::new(0x22));
assert_eq!(observation.planned_segments, 3);
assert_eq!(observation.remaining_bytes, 5 * 1024);
assert_eq!(observation.requestable_pieces, 3);
assert_eq!(observation.active_piece_count, 2);
assert_eq!(observation.available_requestable_pieces, 2);
assert_eq!(observation.scarce_requestable_pieces, 1);
assert_eq!(observation.peer_count, 2);
assert!(observation.bt_endgame_ready);
}
+564
View File
@@ -0,0 +1,564 @@
//! Session state, runtime option projection, and persistence bridge snapshots.
use std::{collections::BTreeMap, path::PathBuf};
use crate::{
error::{CoreError, Result},
options::{OptionKey, OptionPatch, OptionValue},
progress::GlobalStat,
runtime::{RuntimeConfig, parse_human_size_text},
scheduler::{
RetryHistoryEntry, RuntimeScheduleState, SchedulerActivityCounters,
SchedulerPlanningObservation, SegmentPlan,
},
};
/// High-level lifecycle state for the session and engine.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SessionState {
/// No active runtime work is happening.
Idle,
/// The runtime is actively processing downloads.
Running,
/// Work is paused but can be resumed.
Paused,
/// Session data is currently being saved.
Saving,
/// Graceful shutdown has been requested.
ShuttingDown,
/// Forced shutdown has been requested.
ForceShuttingDown,
/// The runtime has stopped.
Stopped,
}
/// Global option store applied across downloads.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct GlobalOptions {
/// Stored global option values keyed by option name.
values: BTreeMap<OptionKey, OptionValue>,
}
impl GlobalOptions {
/// Creates an empty global option store.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Sets or replaces a global option value.
pub fn set(&mut self, key: impl Into<OptionKey>, value: impl Into<OptionValue>) {
self.values.insert(key.into(), value.into());
}
/// Returns a global option value when present.
#[must_use]
pub fn get(&self, key: &OptionKey) -> Option<&OptionValue> {
self.values.get(key)
}
/// Returns all stored global options.
#[must_use]
pub fn values(&self) -> &BTreeMap<OptionKey, OptionValue> {
&self.values
}
/// Applies every entry from the provided patch.
pub fn apply_patch(&mut self, patch: OptionPatch) {
self.values.extend(patch.entries().clone());
}
}
/// Target used when saving or loading a session snapshot.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SaveSessionTarget {
/// Use the in-memory snapshot slot.
Memory,
/// Use a snapshot associated with a persisted path.
Path(PathBuf),
}
/// In-memory session state plus persisted snapshots and scheduler bridge data.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Session {
/// Runtime configuration projected from defaults and global options.
runtime: RuntimeConfig,
/// Current high-level session state.
state: SessionState,
/// Global option store applied to all downloads.
global_options: GlobalOptions,
/// Aggregated transfer statistics.
stats: GlobalStat,
/// Preferred session file path when persistence is configured.
session_file: Option<PathBuf>,
/// In-memory snapshot slot used for round trips.
memory_snapshot: Option<SessionSnapshot>,
/// Path-keyed snapshots saved during the current process lifetime.
path_snapshots: BTreeMap<PathBuf, SessionSnapshot>,
/// Mirrored scheduler and runtime bridge data.
bridge: SessionBridge,
}
/// Internal saved session payload used for in-memory and path snapshots.
#[derive(Clone, Debug, Eq, PartialEq)]
/// Saved session payload mirrored into in-memory and path snapshots.
struct SessionSnapshot {
/// Global options captured at save time.
global_options: GlobalOptions,
/// Global statistics captured at save time.
stats: GlobalStat,
/// Scheduler bridge data captured at save time.
bridge: SessionBridge,
}
/// Scheduler and runtime data mirrored into the session snapshot.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SessionBridge {
/// Effective split count mirrored from scheduler planning.
pub split: usize,
/// Last active segment plan when present.
pub segment_plan: Option<SegmentPlan>,
/// Completed payload length mirrored from runtime state.
pub completed_length: u64,
/// Accumulated retry count mirrored from runtime state.
pub retry_count: u32,
/// Retry history mirrored from runtime state.
pub retry_history: Vec<RetryHistoryEntry>,
/// Number of active segments mirrored from runtime state.
pub active_segments: usize,
/// Scheduler counters mirrored into the session snapshot.
pub scheduler_counters: SchedulerActivityCounters,
/// Most recent scheduler planning observation when available.
pub last_scheduler_plan: Option<SchedulerPlanningObservation>,
}
impl Session {
/// Creates a new session from runtime configuration defaults.
#[must_use]
pub fn new(runtime: RuntimeConfig) -> Self {
let bridge = SessionBridge::from_runtime(&runtime);
let session_file = runtime.session_path.clone();
Self {
session_file,
runtime,
state: SessionState::Idle,
global_options: GlobalOptions::default(),
stats: GlobalStat::default(),
memory_snapshot: None,
path_snapshots: BTreeMap::new(),
bridge,
}
}
/// Returns the projected runtime configuration.
#[must_use]
pub fn runtime(&self) -> &RuntimeConfig {
&self.runtime
}
/// Returns the current session state.
#[must_use]
pub fn state(&self) -> &SessionState {
&self.state
}
/// Returns the global option store.
#[must_use]
pub fn global_options(&self) -> &GlobalOptions {
&self.global_options
}
/// Returns a mutable reference to the global option store.
pub fn global_options_mut(&mut self) -> &mut GlobalOptions {
&mut self.global_options
}
/// Returns the global statistics snapshot.
#[must_use]
pub fn stats(&self) -> &GlobalStat {
&self.stats
}
/// Returns a mutable reference to the global statistics snapshot.
pub fn stats_mut(&mut self) -> &mut GlobalStat {
&mut self.stats
}
/// Returns the current session file path when configured.
#[must_use]
pub fn session_file(&self) -> Option<&PathBuf> {
self.session_file.as_ref()
}
/// Sets the session file path.
pub fn set_session_file(&mut self, path: impl Into<PathBuf>) {
self.session_file = Some(path.into());
}
/// Marks a session as loaded from an external source path.
pub fn mark_external_load(&mut self, path: impl Into<PathBuf>) {
self.session_file = Some(path.into());
self.state = SessionState::Idle;
}
/// Moves the session into the paused state.
pub fn pause(&mut self) -> Result<()> {
self.state = SessionState::Paused;
Ok(())
}
/// Moves the session into the running state.
pub fn resume(&mut self) -> Result<()> {
self.state = SessionState::Running;
Ok(())
}
/// Starts graceful shutdown.
pub fn shutdown(&mut self) -> Result<()> {
self.state = SessionState::ShuttingDown;
Ok(())
}
/// Starts forced shutdown.
pub fn force_shutdown(&mut self) -> Result<()> {
self.state = SessionState::ForceShuttingDown;
Ok(())
}
/// Saves the current session snapshot to memory or a path target.
pub fn save_session(&mut self, target: SaveSessionTarget) -> Result<()> {
self.state = SessionState::Saving;
let snapshot = SessionSnapshot {
global_options: self.global_options.clone(),
stats: self.stats,
bridge: self.bridge.clone(),
};
match target {
SaveSessionTarget::Memory => {
self.memory_snapshot = Some(snapshot);
Ok(())
}
SaveSessionTarget::Path(path) => {
self.path_snapshots.insert(path.clone(), snapshot);
self.session_file = Some(path);
Ok(())
}
}
}
/// Loads a previously saved session snapshot.
pub fn load_session(&mut self, source: SaveSessionTarget) -> Result<()> {
let snapshot = match source {
SaveSessionTarget::Memory => self
.memory_snapshot
.as_ref()
.ok_or(CoreError::StorageUnavailable(
"no in-memory session snapshot available",
))?
.clone(),
SaveSessionTarget::Path(path) => {
self.session_file = Some(path.clone());
self.path_snapshots
.get(&path)
.ok_or(CoreError::StorageUnavailable(
"no session snapshot available for requested path",
))?
.clone()
}
};
self.global_options = snapshot.global_options;
self.stats = snapshot.stats;
self.bridge = snapshot.bridge;
self.refresh_runtime_from_global_options();
self.state = SessionState::Idle;
Ok(())
}
/// Sets and immediately applies a single global option.
pub fn set_global_option(&mut self, key: impl Into<OptionKey>, value: impl Into<OptionValue>) {
let key = key.into();
let value = value.into();
self.apply_runtime_option(&key, &value);
self.global_options.set(key, value);
}
/// Applies and stores a patch of global options.
pub fn apply_global_option_patch(&mut self, patch: OptionPatch) {
for (key, value) in patch.entries() {
self.apply_runtime_option(key, value);
}
self.global_options.apply_patch(patch);
}
/// Returns the mirrored scheduler bridge snapshot.
#[must_use]
pub fn bridge(&self) -> &SessionBridge {
&self.bridge
}
/// Returns a mutable scheduler bridge snapshot.
pub fn bridge_mut(&mut self) -> &mut SessionBridge {
&mut self.bridge
}
/// Updates the active segment plan stored in the session bridge.
pub fn set_segment_plan(&mut self, segment_plan: SegmentPlan) {
self.bridge.split = segment_plan.split;
self.bridge.segment_plan = Some(segment_plan);
}
/// Mirrors scheduler runtime state into the bridge snapshot.
pub fn apply_runtime_schedule_state(&mut self, state: RuntimeScheduleState) {
self.bridge.completed_length = state.completed_length;
self.bridge.retry_count = state.retry_count;
self.bridge.retry_history = state.retry_history;
self.bridge.active_segments = state.active_segments;
}
/// Mirrors scheduler instrumentation into the bridge snapshot.
pub fn apply_scheduler_instrumentation(
&mut self,
counters: SchedulerActivityCounters,
last_plan: Option<SchedulerPlanningObservation>,
) {
self.bridge.scheduler_counters = counters;
self.bridge.last_scheduler_plan = last_plan;
}
/// Applies a single stored option onto the projected runtime config.
fn apply_runtime_option(&mut self, key: &OptionKey, value: &OptionValue) {
match key.as_str() {
"max-overall-download-limit" => {
self.runtime.max_overall_download_limit = parse_optional_limit(value);
}
"max-download-limit" => {
self.runtime.max_download_limit = parse_optional_limit(value);
}
"max-overall-upload-limit" => {
self.runtime.max_overall_upload_limit = parse_optional_limit(value);
}
"max-upload-limit" => {
self.runtime.max_upload_limit = parse_optional_limit(value);
}
"disk-cache" => {
if let Some(value) = parse_option_size(value) {
self.runtime.disk_cache_bytes = value;
}
}
_ => {}
}
}
/// Rebuilds runtime projection from every stored global option.
fn refresh_runtime_from_global_options(&mut self) {
let entries = self.global_options.values().clone();
for (key, value) in &entries {
self.apply_runtime_option(key, value);
}
}
}
impl SessionBridge {
/// Builds a bridge snapshot from runtime defaults.
#[must_use]
pub fn from_runtime(runtime: &RuntimeConfig) -> Self {
Self {
split: runtime.effective_split(),
segment_plan: Some(SegmentPlan::from_runtime(runtime)),
completed_length: 0,
retry_count: 0,
retry_history: Vec::new(),
active_segments: 0,
scheduler_counters: SchedulerActivityCounters::default(),
last_scheduler_plan: None,
}
}
}
/// Parses a single option value into a byte count when possible.
fn parse_option_size(value: &OptionValue) -> Option<u64> {
match value {
OptionValue::UInt(value) => Some(*value),
OptionValue::Int(value) => u64::try_from(*value).ok(),
OptionValue::Text(value) => parse_human_size_text(value),
_ => None,
}
}
/// Parses a positive byte-sized limit value from an option.
fn parse_optional_limit(value: &OptionValue) -> Option<u64> {
parse_option_size(value).filter(|limit| *limit > 0)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use crate::{
error::{CoreError, Result},
runtime::RuntimeConfig,
scheduler::{RetryHistoryEntry, RuntimeScheduleState, SegmentPlan},
session::{SaveSessionTarget, Session},
};
#[test]
fn session_round_trip_memory_snapshot() -> Result<()> {
let mut session = Session::new(RuntimeConfig::default());
session.set_global_option("max-concurrent-downloads", "8");
session.stats_mut().download_speed = 1024;
session.save_session(SaveSessionTarget::Memory)?;
session.set_global_option("max-concurrent-downloads", "1");
session.stats_mut().download_speed = 1;
session.load_session(SaveSessionTarget::Memory)?;
assert_eq!(
session
.global_options()
.get(&"max-concurrent-downloads".into())
.and_then(|v| v.as_text()),
Some("8")
);
assert_eq!(session.stats().download_speed, 1024);
Ok(())
}
#[test]
fn session_load_path_snapshot_updates_runtime_target() -> Result<()> {
let mut session = Session::new(RuntimeConfig::default());
let path = PathBuf::from("session-a2.txt");
session.set_global_option("dir", "/srv/aria2/a2");
session.save_session(SaveSessionTarget::Path(path.clone()))?;
session.set_global_option("dir", "/srv/aria2/override");
session.load_session(SaveSessionTarget::Path(path.clone()))?;
assert_eq!(session.session_file(), Some(&path));
assert_eq!(
session
.global_options()
.get(&"dir".into())
.and_then(|v| v.as_text()),
Some("/srv/aria2/a2")
);
Ok(())
}
#[test]
fn session_load_missing_snapshot_reports_storage_unavailable() {
let mut session = Session::new(RuntimeConfig::default());
let result = session.load_session(SaveSessionTarget::Path(PathBuf::from("missing.txt")));
assert_eq!(
result,
Err(CoreError::StorageUnavailable(
"no session snapshot available for requested path"
))
);
}
#[test]
fn session_bridge_round_trip_persists_completed_and_retry_state() -> Result<()> {
let mut session = Session::new(RuntimeConfig::default());
session.set_segment_plan(SegmentPlan {
split: 6,
min_split_size: 1024,
piece_length: 1024,
max_connections_per_server: 3,
});
session.apply_runtime_schedule_state(RuntimeScheduleState {
completed_length: 8192,
retry_count: 2,
retry_history: vec![RetryHistoryEntry {
at_unix_secs: 1_700_000_000,
reason: "http 403".to_string(),
}],
active_segments: 2,
});
session.save_session(SaveSessionTarget::Memory)?;
session.apply_runtime_schedule_state(RuntimeScheduleState {
completed_length: 4,
retry_count: 0,
retry_history: Vec::new(),
active_segments: 0,
});
session.load_session(SaveSessionTarget::Memory)?;
assert_eq!(session.bridge().split, 6);
assert_eq!(session.bridge().completed_length, 8192);
assert_eq!(session.bridge().retry_count, 2);
assert_eq!(session.bridge().retry_history.len(), 1);
assert_eq!(session.bridge().active_segments, 2);
Ok(())
}
#[test]
fn session_bridge_round_trip_persists_scheduler_instrumentation() -> Result<()> {
let mut session = Session::new(RuntimeConfig::default());
let counters = crate::scheduler::SchedulerActivityCounters {
tick_count: 3,
schedule_run_count: 2,
queue_decision_count: 1,
run_now_decision_count: 1,
last_decision: Some(crate::scheduler::ScheduleDecisionKind::RunNow),
..Default::default()
};
session.bridge_mut().scheduler_counters = counters;
session.bridge_mut().last_scheduler_plan =
Some(crate::scheduler::SchedulerPlanningObservation {
gid: crate::request::DownloadId::new(0x44),
total_length: 4096,
plannable_length: 4096,
completed_length: 1024,
remaining_bytes: 3072,
planned_segments: 3,
active_segment_count: 2,
requestable_pieces: 2,
active_piece_count: 1,
available_requestable_pieces: 1,
scarce_requestable_pieces: 1,
peer_count: 4,
bt_endgame_ready: true,
});
session.save_session(SaveSessionTarget::Memory)?;
session.bridge_mut().scheduler_counters =
crate::scheduler::SchedulerActivityCounters::default();
session.bridge_mut().last_scheduler_plan = None;
session.load_session(SaveSessionTarget::Memory)?;
assert_eq!(session.bridge().scheduler_counters, counters);
assert_eq!(
session
.bridge()
.last_scheduler_plan
.as_ref()
.map(|plan| plan.remaining_bytes),
Some(3072)
);
Ok(())
}
#[test]
fn session_global_speed_and_cache_options_mutate_runtime_surface() {
let mut session = Session::new(RuntimeConfig::default());
session.set_global_option("max-overall-download-limit", "8M");
session.set_global_option("max-download-limit", "2M");
session.set_global_option("max-overall-upload-limit", "4M");
session.set_global_option("max-upload-limit", "1M");
session.set_global_option("disk-cache", "32M");
assert_eq!(
session.runtime().max_overall_download_limit,
Some(8 * 1024 * 1024)
);
assert_eq!(session.runtime().max_download_limit, Some(2 * 1024 * 1024));
assert_eq!(
session.runtime().max_overall_upload_limit,
Some(4 * 1024 * 1024)
);
assert_eq!(session.runtime().max_upload_limit, Some(1024 * 1024));
assert_eq!(session.runtime().disk_cache_bytes, 32 * 1024 * 1024);
}
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "aria2-rust-pro-protocol"
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
[lib]
name = "aria2_rust_pro_protocol"
path = "src/lib.rs"
[dependencies]
adler2 = "2"
crc32fast = "1"
md-5 = "0.10"
quick-xml = "0.38"
sha1 = "0.10"
sha2 = "0.10"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
aria2-rust-pro-storage.workspace = true
[lints]
workspace = true
@@ -0,0 +1,59 @@
//! Authentication models shared across protocol connectors.
#![forbid(unsafe_code)]
use std::collections::HashMap;
/// Authentication scheme recognized by the protocol layer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AuthScheme {
/// HTTP Basic authentication.
Basic,
/// HTTP Digest authentication.
Digest,
/// Bearer-token authentication.
Bearer,
/// SPNEGO or Negotiate authentication.
Negotiate,
/// NTLM authentication.
Ntlm,
/// OAuth2-derived bearer flows.
OAuth2,
/// Caller accepts any supported scheme.
Any,
}
/// Authentication material supplied to a protocol connector.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AuthCredentialModel {
/// Scheme the credential applies to.
pub scheme: AuthScheme,
/// Optional username component.
pub username: Option<String>,
/// Optional password or shared secret.
pub password: Option<String>,
/// Optional opaque bearer token.
pub token: Option<String>,
/// Optional authentication realm.
pub realm: Option<String>,
}
/// Authentication challenge emitted by a server.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AuthChallengeModel {
/// Challenged scheme.
pub scheme: AuthScheme,
/// Optional realm attached to the challenge.
pub realm: Option<String>,
/// Additional challenge parameters keyed by attribute name.
pub parameters: HashMap<String, String>,
}
/// Cached credential entry associated with an origin.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AuthCacheEntry {
/// Origin or protection-space key.
pub origin: String,
/// Credential cached for the origin.
pub credential: AuthCredentialModel,
}
+23
View File
@@ -0,0 +1,23 @@
//! BitTorrent-oriented re-exports from the protocol crate.
#![forbid(unsafe_code)]
pub use crate::{
bt_metalink::{
BtMetalinkError, MagnetUri, MetalinkDocument, ParserStatus, ProtocolSupportMatrix,
ProtocolSupportState, TorrentMetadata, TorrentMetadataError, protocol_support_matrix,
},
magnet::{MagnetBootstrapModel, MagnetMetadataModel, MagnetUriModel, parse_magnet_bootstrap},
metalink::{
MetalinkChecksumModel, MetalinkDocumentModel, MetalinkFileModel, MetalinkParseResult,
MetalinkParserModel, MetalinkResourceModel,
},
torrent::{
DhtMessageModel, PeerWireExtensionHandshakeModel, PeerWireMessageModel,
PeerWireMetadataMessageModel, PeerWireMetadataMessageType, TorrentBootstrapModel,
TorrentFileEntryModel, TorrentHashModel, TorrentInfoModel, TorrentMessageModel,
TorrentMetadataModel, TorrentPeerModel, TorrentPieceModel, TorrentTrackerModel,
parse_torrent_bootstrap,
},
tracker::{DhtNodeModel, TrackerPeerListModel, TrackerRequestModel},
};
@@ -0,0 +1,391 @@
//! Compatibility wrappers that bridge BitTorrent, magnet, and Metalink models.
#![forbid(unsafe_code)]
use std::fmt::{Display, Formatter};
use crate::{
magnet::{MagnetUriModel, parse_magnet_uri},
metalink::{MetalinkDocumentModel, parse_metalink_document},
torrent::{TorrentMetadataModel, parse_torrent_metadata},
};
/// Declares how far a protocol family has progressed in the current implementation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProtocolSupportState {
/// The protocol is known but not yet wired into the workspace.
Planned,
/// Parsing and model registration exist, but transfer execution is pending.
Registered,
/// A compatibility skeleton is present and exposes the public API shape.
Skeleton,
}
/// Summarizes protocol readiness across BitTorrent-adjacent inputs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProtocolSupportMatrix {
/// Current state of `.torrent` metadata handling.
pub bit_torrent: ProtocolSupportState,
/// Current state of magnet URI handling.
pub magnet: ProtocolSupportState,
/// Current state of Metalink XML handling.
pub metalink: ProtocolSupportState,
}
/// Returns the current protocol support matrix exposed by this compatibility layer.
#[must_use]
pub const fn protocol_support_matrix() -> ProtocolSupportMatrix {
ProtocolSupportMatrix {
bit_torrent: ProtocolSupportState::Skeleton,
magnet: ProtocolSupportState::Registered,
metalink: ProtocolSupportState::Registered,
}
}
/// Compatibility wrapper for parsed magnet URI metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MagnetUri {
/// Parsed BTIH info hash.
pub info_hash: String,
/// Optional display name from the `dn` query field.
pub display_name: Option<String>,
/// Ordered tracker URLs from `tr` query fields.
pub trackers: Vec<String>,
/// Ordered web-seed URLs from `ws` query fields.
pub web_seeds: Vec<String>,
}
/// Compatibility wrapper for parsed Metalink documents.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MetalinkDocument {
/// Root element name used for diagnostics.
pub root_element: String,
/// Parser state describing whether a real model was produced.
pub status: ParserStatus,
/// Parsed Metalink document model when parsing succeeded.
pub document: Option<MetalinkDocumentModel>,
}
/// Records whether a compatibility parser stayed stubbed or produced a model.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParserStatus {
/// Parsing support is registered but no real model was built.
RegisteredStub,
/// Parsing produced a protocol-layer document model.
ParsedModel,
}
/// Errors raised while converting BitTorrent-adjacent formats into compatibility models.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BtMetalinkError {
/// The magnet URI was malformed.
InvalidMagnet {
/// Parser-specific explanation of the magnet failure.
reason: String,
},
/// The requested torrent feature is not yet implemented.
UnsupportedTorrentBinary,
/// The Metalink document was malformed or unsupported.
InvalidMetalink {
/// Parser-specific explanation of the Metalink failure.
reason: String,
},
}
impl Display for BtMetalinkError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidMagnet { reason } => write!(f, "invalid magnet: {reason}"),
Self::UnsupportedTorrentBinary => {
f.write_str("torrent binary parsing is not implemented")
}
Self::InvalidMetalink { reason } => write!(f, "invalid metalink: {reason}"),
}
}
}
impl std::error::Error for BtMetalinkError {}
impl MagnetUri {
/// Builds the compatibility wrapper from the protocol-layer model.
#[must_use]
pub fn from_model(model: MagnetUriModel) -> Self {
Self {
info_hash: model.info_hash,
display_name: model.display_name,
trackers: model.trackers,
web_seeds: model.web_seeds,
}
}
/// Parses a magnet URI into the compatibility skeleton.
///
/// # Errors
///
/// Returns an error when the `magnet:?` prefix is missing or the URI does
/// not contain an `xt=urn:btih:<hash>` value.
pub fn parse(input: &str) -> Result<Self, BtMetalinkError> {
parse_magnet_uri(input).map(Self::from_model)
}
}
impl MetalinkDocument {
/// Builds the compatibility wrapper from the protocol-layer Metalink model.
#[must_use]
pub fn from_model(model: MetalinkDocumentModel) -> Self {
Self {
root_element: "metalink".to_owned(),
status: ParserStatus::ParsedModel,
document: Some(model),
}
}
/// Parses Metalink XML text through the protocol-layer Metalink parser.
///
/// # Errors
///
/// Returns an error when the input is not a valid Metalink document or
/// when the document has no actionable resources.
pub fn parse(input: &str) -> Result<Self, BtMetalinkError> {
let document = parse_metalink_document(input)
.map_err(|reason| BtMetalinkError::InvalidMetalink { reason })?;
Ok(Self {
root_element: "metalink".to_owned(),
status: ParserStatus::ParsedModel,
document: Some(document),
})
}
}
/// Compatibility wrapper for parsed torrent metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TorrentMetadata {
/// Parsed torrent metadata model.
pub model: TorrentMetadataModel,
}
/// Errors raised while parsing `.torrent` metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TorrentMetadataError {
/// The torrent payload was syntactically invalid.
Invalid {
/// Parser-specific explanation of the torrent failure.
reason: String,
},
/// The payload used an unsupported feature.
Unsupported {
/// Name of the unsupported torrent feature.
feature: &'static str,
},
}
impl Display for TorrentMetadataError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Invalid { reason } => write!(f, "invalid torrent metadata: {reason}"),
Self::Unsupported { feature } => write!(f, "{feature} is not implemented"),
}
}
}
impl std::error::Error for TorrentMetadataError {}
impl TorrentMetadata {
/// Wraps a protocol-layer torrent model.
#[must_use]
pub fn from_model(model: TorrentMetadataModel) -> Self {
Self { model }
}
/// Returns a shared reference to the underlying torrent metadata model.
#[must_use]
pub fn as_model(&self) -> &TorrentMetadataModel {
&self.model
}
/// Consumes the wrapper and returns the underlying torrent metadata model.
#[must_use]
pub fn into_model(self) -> TorrentMetadataModel {
self.model
}
/// Parses torrent binary metadata through the shared protocol-layer parser.
///
/// # Errors
///
/// Returns a structured invalid-metadata error when the payload cannot be parsed.
pub fn parse(input: &[u8]) -> Result<Self, TorrentMetadataError> {
parse_torrent_metadata(input)
.map(Self::from_model)
.map_err(|error| TorrentMetadataError::Invalid { reason: error })
}
}
#[cfg(test)]
mod tests {
use super::{
MagnetUri, MetalinkDocument, ParserStatus, TorrentMetadata, TorrentMetadataError,
parse_magnet_uri,
};
use crate::{
TorrentFileEntryModel, TorrentInfoModel, TorrentMetadataModel, TorrentPeerModel,
TorrentTrackerModel,
};
#[test]
fn magnet_wrapper_parse_matches_magnet_model_fields() {
let input = "magnet:?xt=urn:btih:0123456789abcdef&dn=Ubuntu%2024.04&tr=http%3A%2F%2Ftracker.example.org%2Fannounce&ws=https%3A%2F%2Fcdn.example.org%2Fubuntu.iso";
let parsed = MagnetUri::parse(input).expect("magnet wrapper should parse");
let model = parse_magnet_uri(input).expect("protocol magnet parser should parse");
assert_eq!(parsed.info_hash, model.info_hash);
assert_eq!(parsed.display_name, model.display_name);
assert_eq!(parsed.trackers, model.trackers);
assert_eq!(parsed.web_seeds, model.web_seeds);
}
#[test]
fn torrent_wrapper_round_trips_model_without_changing_shape() {
let model = TorrentMetadataModel {
info: TorrentInfoModel {
name: "sample".to_owned(),
piece_length: 16,
pieces: vec![[0_u8; 20]],
files: vec![TorrentFileEntryModel {
path: "sample.bin".to_owned(),
length: 16,
piece_offset: Some(0),
selected: true,
}],
hash: None,
private: false,
},
announce: Some("http://tracker.example.org/announce".to_owned()),
trackers: vec![TorrentTrackerModel {
url: "http://tracker.example.org/announce".to_owned(),
tier: Some(1),
id: None,
seeders: None,
leechers: None,
}],
peers: vec![TorrentPeerModel {
peer_id: None,
ip: "127.0.0.1".to_owned(),
port: 6881,
client_name: None,
interested: false,
choked: true,
}],
dht_nodes: vec!["router.example.org:6881".to_owned()],
pieces: Vec::new(),
creation_date: None,
comment: None,
};
let wrapper = TorrentMetadata::from_model(model.clone());
assert_eq!(wrapper.as_model(), &model);
assert_eq!(wrapper.into_model(), model);
}
#[test]
fn torrent_wrapper_surfaces_parse_errors_as_invalid_metadata() {
let error = TorrentMetadata::parse(b"not-a-torrent").expect_err("bad torrent should fail");
match error {
TorrentMetadataError::Invalid { reason } => assert!(!reason.is_empty()),
TorrentMetadataError::Unsupported { feature } => {
panic!("unexpected torrent error variant: unsupported feature {feature}")
}
}
}
#[test]
fn metalink_wrapper_parse_returns_real_parsed_model() {
let parsed = MetalinkDocument::parse(
r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0" xmlns="urn:ietf:params:xml:ns:metalink">
<identity>wrapped fixture</identity>
<file name="wrapped.iso">
<description>real parser payload</description>
<url priority="1" location="us">https://mirror.example.com/wrapped.iso</url>
</file>
</metalink>"#,
)
.expect("metalink wrapper should parse through the real model");
assert_eq!(parsed.root_element, "metalink");
assert_eq!(parsed.status, ParserStatus::ParsedModel);
let document = parsed
.document
.expect("wrapper should retain parsed document");
assert_eq!(document.version.as_deref(), Some("4.0"));
assert_eq!(document.identity.as_deref(), Some("wrapped fixture"));
assert_eq!(document.files.len(), 1);
assert_eq!(document.files[0].name, "wrapped.iso");
assert_eq!(
document.files[0].description.as_deref(),
Some("real parser payload")
);
assert_eq!(
document.files[0].resources[0].url,
"https://mirror.example.com/wrapped.iso"
);
}
#[test]
fn metalink_wrapper_preserves_normalized_file_metadata_and_resource_hints() {
let parsed = MetalinkDocument::parse(
r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0" xmlns="urn:ietf:params:xml:ns:metalink">
<identity>wrapper fixture</identity>
<file name=" wrapped.iso ">
<identity> release-42 </identity>
<signature><![CDATA[SIG-WRAP]]></signature>
<hash type="SHA256"> AA BB </hash>
<url location=" us " maxconnections="8">https://mirror.example.com/wrapped.iso</url>
</file>
</metalink>"#,
)
.expect("metalink wrapper should preserve normalized parser output");
let document = parsed
.document
.expect("wrapper should retain parsed document");
assert_eq!(document.identity.as_deref(), Some("wrapper fixture"));
assert_eq!(document.files[0].name, "wrapped.iso");
assert_eq!(document.files[0].identifier.as_deref(), Some("release-42"));
assert_eq!(document.files[0].signatures, vec!["SIG-WRAP".to_owned()]);
assert_eq!(document.files[0].checksums[0].algorithm, "sha-256");
assert_eq!(document.files[0].checksums[0].value, "aabb");
assert_eq!(
document.files[0].resources[0].location.as_deref(),
Some("us")
);
assert_eq!(document.files[0].resources[0].max_connections, Some(8));
assert_eq!(
document.files[0].resources[0].type_hint.as_deref(),
Some("https")
);
}
#[test]
fn metalink_wrapper_parse_rejects_invalid_root_only_stub_shape() {
let error = MetalinkDocument::parse(
r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0" xmlns="urn:ietf:params:xml:ns:metalink">
<file name="empty.iso">
<url priority="1"> </url>
</file>
</metalink>"#,
)
.expect_err("root-only stub success should be rejected");
assert!(
error
.to_string()
.contains("metalink document contains no resource urls"),
"unexpected error: {error}"
);
}
}
@@ -0,0 +1,88 @@
//! Downloader traits plus real and fixture-backed transport implementations.
#![forbid(unsafe_code)]
pub(super) use std::{
collections::{BTreeMap, HashMap},
env,
error::Error as StdError,
fs::{File, OpenOptions},
io::SeekFrom,
sync::{
Arc, Mutex, OnceLock,
atomic::{AtomicU64, Ordering},
},
time::{Duration, SystemTime, UNIX_EPOCH},
};
pub(super) use aria2_rust_pro_storage::{ByteSink, ObservedByteSink, ObservedFileSink};
pub(super) use reqwest::{
NoProxy, Proxy,
blocking::{Client, Response},
header::{HeaderMap, HeaderName, HeaderValue, RANGE},
};
/// Monotonic suffix used to keep streamed fixture temp paths unique even when
/// wall-clock precision collapses under parallel test execution.
static NEXT_TEMP_STREAM_SINK_ID: AtomicU64 = AtomicU64::new(0);
/// Process-wide cache for optional live HTTP timing diagnostics.
static HTTP_TIMING_PROBE_ENABLED: OnceLock<bool> = OnceLock::new();
/// Maximum normalized request shapes retained for repeated live HTTP requests.
const MAX_PREPARED_REQUEST_CACHE_ENTRIES: usize = 512;
/// Maximum proxy-specific clients retained to preserve connection pooling.
const MAX_PROXY_CLIENT_CACHE_ENTRIES: usize = 128;
/// Default idle connection budget kept per host for repeated live HTTP range work.
const LIVE_HTTP_POOL_MAX_IDLE_PER_HOST: usize = 32;
/// Idle timeout used to keep same-host range fanout warm across short runtime bursts.
const LIVE_HTTP_POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
/// TCP keepalive used for long-lived live HTTP sessions.
const LIVE_HTTP_TCP_KEEPALIVE: Duration = Duration::from_secs(30);
pub(super) use crate::{
auth::AuthCredentialModel,
ftp::{FtpConfigModel, FtpRequestModel, FtpResponseModel},
http::{
ChecksumSpec, ContentRangeSpec, HttpHeader, HttpRequestModel, HttpResponseHeaders,
HttpResponseModel, HttpTransferTaskModel, HttpVersion, RangeSpec, RangeUnit, ResponseBody,
},
metalink::MetalinkDocumentModel,
sftp::{SftpConfigModel, SftpRequestModel, SftpResponseModel},
torrent::TorrentMetadataModel,
transport::TransportError,
};
/// Transfer-facing traits shared by the downloader implementations.
mod contracts;
/// Core connector-backed downloader implementations and request normalization helpers.
mod core_downloader;
/// Fixture-backed downloader used by tests and local runtime smokes.
mod fixture_downloader;
/// Live reqwest-backed downloader connector implementation.
mod reqwest_connector;
pub use self::contracts::{
AuthProvider, ChecksumVerifier, Downloader, FtpConnector, HttpConnector, HttpsConnector,
MetalinkConnector, RetryStrategyProvider, SftpConnector, TorrentConnector,
};
pub use self::core_downloader::{
ConnectorBackedDownloader, HttpOnlyDownloader, NullHttpConnector, NullHttpsConnector,
};
pub use self::fixture_downloader::{FixtureHttpDownloader, FixtureStep, HttpFixtureResponseSpec};
pub use self::reqwest_connector::ReqwestHttpConnector;
#[cfg(test)]
fn execute_streamed_body(body: &[u8], checksum: Option<&ChecksumSpec>) -> ResponseBody {
fixture_downloader::execute_streamed_body(body, checksum)
}
#[cfg(test)]
fn temp_stream_sink_path() -> std::path::PathBuf {
fixture_downloader::temp_stream_sink_path()
}
#[cfg(test)]
fn request_body_bytes(body: &crate::http::HttpBody) -> Option<Vec<u8>> {
reqwest_connector::request_body_bytes(body)
}
#[cfg(test)]
mod downloader_tests;
@@ -0,0 +1,98 @@
use super::{
AuthCredentialModel, ChecksumSpec, FtpConfigModel, FtpRequestModel, FtpResponseModel,
HttpRequestModel, HttpResponseModel, HttpTransferTaskModel, MetalinkDocumentModel,
SftpConfigModel, SftpRequestModel, SftpResponseModel, TorrentMetadataModel, TransportError,
};
/// Verifies a checksum against downloaded payload bytes.
pub trait ChecksumVerifier {
/// Validates `payload` against `spec`.
fn verify_checksum(&self, spec: &ChecksumSpec, payload: &[u8]) -> Result<(), TransportError>;
}
/// Derives retry behavior for one HTTP request.
pub trait RetryStrategyProvider {
/// Returns the retry strategy that should apply to `request`.
fn retry_strategy(&self, request: &HttpRequestModel) -> crate::http::RetryStrategy;
}
/// Resolves credentials for one origin.
pub trait AuthProvider {
/// Returns the credential configured for `origin`, when one exists.
fn credential_for(&self, origin: &str) -> Option<AuthCredentialModel>;
}
/// Connects plain HTTP requests.
pub trait HttpConnector {
/// Executes one HTTP request and returns the normalized response model.
fn connect_http(&self, request: &HttpRequestModel)
-> Result<HttpResponseModel, TransportError>;
}
/// Connects HTTPS requests.
pub trait HttpsConnector {
/// Executes one HTTPS request and returns the normalized response model.
fn connect_https(
&self,
request: &HttpRequestModel,
) -> Result<HttpResponseModel, TransportError>;
}
/// Connects FTP requests.
pub trait FtpConnector {
/// Executes one FTP request with the supplied session config.
fn connect_ftp(
&self,
config: &FtpConfigModel,
request: &FtpRequestModel,
) -> Result<FtpResponseModel, TransportError>;
}
/// Connects SFTP requests.
pub trait SftpConnector {
/// Executes one SFTP request with the supplied session config.
fn connect_sftp(
&self,
config: &SftpConfigModel,
request: &SftpRequestModel,
) -> Result<SftpResponseModel, TransportError>;
}
/// Fetches Metalink documents through the transport layer.
pub trait MetalinkConnector {
/// Resolves one Metalink document into an HTTP-style response model.
fn connect_metalink(
&self,
document: &MetalinkDocumentModel,
) -> Result<HttpResponseModel, TransportError>;
}
/// Fetches torrent metadata through the transport layer.
pub trait TorrentConnector {
/// Resolves one torrent metadata document into an HTTP-style response model.
fn connect_torrent(
&self,
metadata: &TorrentMetadataModel,
) -> Result<HttpResponseModel, TransportError>;
}
/// High-level transfer runner used by the CLI and integration fixtures.
pub trait Downloader {
/// Starts one HTTP or HTTPS transfer.
fn start_http_transfer(
&self,
task: &HttpTransferTaskModel,
) -> Result<HttpResponseModel, TransportError>;
/// Starts one FTP transfer.
fn start_ftp_transfer(
&self,
config: &FtpConfigModel,
request: &FtpRequestModel,
) -> Result<FtpResponseModel, TransportError>;
/// Starts one SFTP transfer.
fn start_sftp_transfer(
&self,
config: &SftpConfigModel,
request: &SftpRequestModel,
) -> Result<SftpResponseModel, TransportError>;
}
@@ -0,0 +1,193 @@
use super::{
Downloader, FtpConfigModel, FtpRequestModel, FtpResponseModel, HttpConnector, HttpRequestModel,
HttpResponseModel, HttpTransferTaskModel, HttpsConnector, SftpConfigModel, SftpRequestModel,
SftpResponseModel, TransportError,
};
#[derive(Clone, Copy, Debug, Default)]
/// Placeholder HTTP connector that always reports that no connector is configured.
pub struct NullHttpConnector;
impl HttpConnector for NullHttpConnector {
fn connect_http(
&self,
request: &HttpRequestModel,
) -> Result<HttpResponseModel, TransportError> {
Err(TransportError {
kind: crate::transport::TransportErrorKind::NotConnected,
message: format!("no http connector configured for {}", request.url),
source: None,
context: None,
})
}
}
#[derive(Clone, Copy, Debug, Default)]
/// Placeholder HTTPS connector that always reports that no connector is configured.
pub struct NullHttpsConnector;
impl HttpsConnector for NullHttpsConnector {
fn connect_https(
&self,
request: &HttpRequestModel,
) -> Result<HttpResponseModel, TransportError> {
Err(TransportError {
kind: crate::transport::TransportErrorKind::NotConnected,
message: format!("no https connector configured for {}", request.url),
source: None,
context: None,
})
}
}
#[derive(Clone, Debug, Default)]
/// Downloader wrapper that routes HTTP and HTTPS requests through connector implementations.
pub struct ConnectorBackedDownloader<HC, HSC> {
/// Connector used for plain HTTP requests.
http: HC,
/// Connector used for HTTPS requests.
https: HSC,
}
impl<HC, HSC> ConnectorBackedDownloader<HC, HSC> {
#[must_use]
/// Builds a downloader from plain HTTP and HTTPS connector implementations.
pub const fn new(http: HC, https: HSC) -> Self {
Self { http, https }
}
}
#[derive(Clone, Debug, Default)]
/// Downloader that only supports HTTP(S) transfers.
pub struct HttpOnlyDownloader {
/// Connector-backed executor used by the HTTP-only wrapper.
inner: ConnectorBackedDownloader<NullHttpConnector, NullHttpsConnector>,
}
impl HttpOnlyDownloader {
#[must_use]
/// Builds the HTTP-only downloader.
pub fn new() -> Self {
Self {
inner: ConnectorBackedDownloader::new(NullHttpConnector, NullHttpsConnector),
}
}
}
impl Downloader for HttpOnlyDownloader {
fn start_http_transfer(
&self,
task: &HttpTransferTaskModel,
) -> Result<HttpResponseModel, TransportError> {
self.inner.start_http_transfer(task)
}
fn start_ftp_transfer(
&self,
_config: &FtpConfigModel,
_request: &FtpRequestModel,
) -> Result<FtpResponseModel, TransportError> {
Err(TransportError {
kind: crate::transport::TransportErrorKind::UnsupportedScheme,
message: "ftp transfer is not supported by the HTTP-only downloader".to_owned(),
source: None,
context: None,
})
}
fn start_sftp_transfer(
&self,
_config: &SftpConfigModel,
_request: &SftpRequestModel,
) -> Result<SftpResponseModel, TransportError> {
Err(TransportError {
kind: crate::transport::TransportErrorKind::UnsupportedScheme,
message: "sftp transfer is not supported by the HTTP-only downloader".to_owned(),
source: None,
context: None,
})
}
}
impl<HC, HSC> Downloader for ConnectorBackedDownloader<HC, HSC>
where
HC: HttpConnector,
HSC: HttpsConnector,
{
fn start_http_transfer(
&self,
task: &HttpTransferTaskModel,
) -> Result<HttpResponseModel, TransportError> {
match request_scheme(&task.request.url) {
Some("http") => self
.http
.connect_http(&task.request)
.map(|response| normalize_http_response_for_execution(task, response)),
Some("https") => self
.https
.connect_https(&task.request)
.map(|response| normalize_http_response_for_execution(task, response)),
Some(scheme) => Err(TransportError {
kind: crate::transport::TransportErrorKind::UnsupportedScheme,
message: format!("unsupported http transfer scheme: {scheme}"),
source: None,
context: None,
}),
None => Err(TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("request url has no scheme: {}", task.request.url),
source: None,
context: None,
}),
}
}
fn start_ftp_transfer(
&self,
_config: &FtpConfigModel,
_request: &FtpRequestModel,
) -> Result<FtpResponseModel, TransportError> {
Err(TransportError {
kind: crate::transport::TransportErrorKind::UnsupportedScheme,
message: "ftp transfer is not implemented".to_owned(),
source: None,
context: None,
})
}
fn start_sftp_transfer(
&self,
_config: &SftpConfigModel,
_request: &SftpRequestModel,
) -> Result<SftpResponseModel, TransportError> {
Err(TransportError {
kind: crate::transport::TransportErrorKind::UnsupportedScheme,
message: "sftp transfer is not implemented".to_owned(),
source: None,
context: None,
})
}
}
/// Injects transfer-task metadata such as checksum hooks into one HTTP response model.
pub(super) fn normalize_http_response_for_execution(
task: &HttpTransferTaskModel,
mut response: HttpResponseModel,
) -> HttpResponseModel {
if response.checksum.is_none()
&& let Some(checksum) = task
.checksum_hook
.as_ref()
.filter(|checksum| checksum.enabled)
.map(|checksum| checksum.spec.clone())
{
response.checksum = Some(checksum);
}
response
}
/// Extracts the lowercase URI scheme prefix from one request URL when present.
pub(super) fn request_scheme(url: &str) -> Option<&str> {
url.split_once("://").map(|(scheme, _)| scheme)
}
@@ -0,0 +1,953 @@
use std::{
collections::BTreeSet,
io::{Read, Write},
net::TcpListener,
thread,
};
use super::{
ConnectorBackedDownloader, Downloader, FixtureHttpDownloader, FixtureStep, HttpConnector,
HttpFixtureResponseSpec, HttpsConnector, ReqwestHttpConnector,
};
use crate::{
ftp::{FtpCommandModel, FtpResponseModel},
http::{
HttpBody, HttpMethod, HttpRequestHeaders, HttpRequestModel, HttpResponseHeaders,
HttpTransferTaskModel, HttpVersion, ProxyConfig, ResponseBody, RetryPolicy, RetryStrategy,
},
sftp::{SftpCommandModel, SftpResponseModel},
transport::{TransportError, TransportErrorKind},
};
#[derive(Clone, Debug, Default)]
struct HttpOkConnector;
impl HttpConnector for HttpOkConnector {
fn connect_http(
&self,
request: &HttpRequestModel,
) -> Result<crate::http::HttpResponseModel, TransportError> {
Ok(crate::http::HttpResponseModel {
status: 200,
reason: format!("HTTP {}", request.url),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Empty,
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
})
}
}
#[derive(Clone, Debug, Default)]
struct HttpsOkConnector;
impl HttpsConnector for HttpsOkConnector {
fn connect_https(
&self,
request: &HttpRequestModel,
) -> Result<crate::http::HttpResponseModel, TransportError> {
Ok(crate::http::HttpResponseModel {
status: 200,
reason: format!("HTTPS {}", request.url),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Empty,
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
})
}
}
fn retry() -> 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 {
let request = HttpRequestModel {
method: HttpMethod::Get,
url: url.to_owned(),
version: HttpVersion::Http11,
headers: HttpRequestHeaders {
headers: Vec::new(),
},
query: std::collections::HashMap::new(),
range: None,
body: HttpBody::Empty,
retry: retry(),
auth: None,
proxy: None,
response_sink: None,
};
HttpTransferTaskModel {
task_id: "gid".to_owned(),
request,
response_headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Empty,
resume_state: None,
retry_attempts: vec![],
checksum_hook: None,
max_connections: 1,
retry: retry(),
}
}
fn proxy_config(port: u16) -> ProxyConfig {
ProxyConfig {
scheme: "http".to_owned(),
host: "127.0.0.1".to_owned(),
port,
username: None,
password: None,
bypass_hosts: vec![],
no_proxy: false,
}
}
fn closed_loopback_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.expect("ephemeral port should bind")
.local_addr()
.expect("local addr should exist")
.port()
}
#[test]
fn connector_backed_downloader_routes_http_and_https() {
let downloader = ConnectorBackedDownloader::new(HttpOkConnector, HttpsOkConnector);
let http = downloader
.start_http_transfer(&task("http://example.org/file"))
.expect("http connector should be used");
let https = downloader
.start_http_transfer(&task("https://example.org/file"))
.expect("https connector should be used");
assert!(http.reason.starts_with("HTTP "));
assert!(https.reason.starts_with("HTTPS "));
}
#[derive(Clone, Debug, Default)]
struct InlineBodyConnector;
impl HttpConnector for InlineBodyConnector {
fn connect_http(
&self,
_request: &HttpRequestModel,
) -> Result<crate::http::HttpResponseModel, TransportError> {
Ok(crate::http::HttpResponseModel {
status: 200,
reason: "HTTP inline".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Inline(b"abc".to_vec()),
content_range: None,
partial_content: false,
checksum: Some(crate::http::ChecksumSpec {
algorithm: "sha-1".to_owned(),
expected_hex: "a9993e364706816aba3e25717850c26c9cd0d89d".to_owned(),
actual_hex: None,
}),
redirected_from: None,
})
}
}
#[test]
fn connector_backed_downloader_keeps_inline_body_when_no_stream_sink_is_needed() {
let downloader = ConnectorBackedDownloader::new(InlineBodyConnector, HttpsOkConnector);
let response = downloader
.start_http_transfer(&task("http://example.org/inline"))
.expect("http connector should be normalized");
match &response.body {
ResponseBody::Inline(bytes) => assert_eq!(bytes, b"abc"),
other => panic!("expected inline body after normalization, got {other:?}"),
}
assert_eq!(
response.completion_model().state,
crate::http::HttpCompletionState::Verified
);
}
#[test]
fn connector_backed_downloader_injects_checksum_hook_when_response_omits_checksum() {
let downloader = ConnectorBackedDownloader::new(InlineBodyConnector, HttpsOkConnector);
let mut task = task("http://example.org/inline");
task.checksum_hook = Some(crate::http::ChecksumHookModel {
spec: crate::http::ChecksumSpec {
algorithm: "sha-1".to_owned(),
expected_hex: "a9993e364706816aba3e25717850c26c9cd0d89d".to_owned(),
actual_hex: None,
},
enabled: true,
});
let response = downloader
.start_http_transfer(&task)
.expect("http connector should be normalized");
assert_eq!(
response
.checksum
.as_ref()
.map(|checksum| checksum.expected_hex.as_str()),
Some("a9993e364706816aba3e25717850c26c9cd0d89d")
);
assert!(response.completion_model().checksum_verified);
}
#[test]
fn reqwest_connector_fetches_real_http_body_into_streamed_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; 1024];
let _ = stream.read(&mut request);
let response = b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabc";
stream.write_all(response).expect("response should write");
});
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let downloader = ConnectorBackedDownloader::new(connector.clone(), connector);
let response = downloader
.start_http_transfer(&task(&format!("http://{addr}/live")))
.expect("live request should succeed");
match &response.body {
ResponseBody::Streamed {
expected_len,
observed_len,
observed_digest,
temp_path,
} => {
assert_eq!(*expected_len, Some(3));
assert_eq!(*observed_len, Some(3));
assert!(observed_digest.is_none());
assert!(temp_path.is_some());
}
other => panic!("expected streamed body from live connector, got {other:?}"),
}
assert_eq!(response.status, 200);
assert_eq!(response.completion_model().completed_length, 3);
handle.join().expect("server thread should join");
}
#[test]
fn reqwest_connector_can_stream_live_http_body_directly_to_target_file() {
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; 1024];
let _ = stream.read(&mut request);
let response = b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabc";
stream.write_all(response).expect("response should write");
});
let target_path = super::temp_stream_sink_path();
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let downloader = ConnectorBackedDownloader::new(connector.clone(), connector);
let mut direct_task = task(&format!("http://{addr}/live-direct"));
direct_task.request.response_sink = Some(crate::http::HttpResponseSinkTarget {
target_path: target_path.clone(),
});
let response = downloader
.start_http_transfer(&direct_task)
.expect("live request should succeed");
match &response.body {
ResponseBody::Streamed {
expected_len,
observed_len,
observed_digest,
temp_path,
} => {
assert_eq!(*expected_len, Some(3));
assert_eq!(*observed_len, Some(3));
assert!(observed_digest.is_none());
assert!(temp_path.is_none());
}
other => panic!("expected streamed body from live connector, got {other:?}"),
}
assert_eq!(
std::fs::read(&target_path).expect("target bytes should read"),
b"abc"
);
let _ = std::fs::remove_file(&target_path);
handle.join().expect("server thread should join");
}
#[test]
fn reqwest_connector_preserves_range_request_and_parses_416_total_length() {
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 buf = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk).expect("socket should read");
if read == 0 {
break;
}
buf.extend_from_slice(&chunk[..read]);
if buf.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let request_text = String::from_utf8_lossy(&buf).to_lowercase();
assert!(request_text.contains("range: bytes=4096-"));
let response =
b"HTTP/1.1 416 Range Not Satisfiable\r\nContent-Range: bytes */4096\r\nContent-Length: 0\r\n\r\n";
stream.write_all(response).expect("response should write");
});
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let mut request = task(&format!("http://{addr}/range-reject")).request;
request.range = Some(crate::http::RangeSpec {
start: 4096,
end_inclusive: None,
unit: crate::http::RangeUnit::Bytes,
});
let response = connector
.connect_http(&request)
.expect("416 response should still be modeled");
assert_eq!(response.status, 416);
assert_eq!(response.total_length(), Some(4096));
assert_eq!(response.completed_length(), 0);
assert!(
response
.content_range
.as_ref()
.expect("content-range should parse")
.unsatisfied
);
handle.join().expect("server thread should join");
}
#[test]
fn reqwest_connector_sends_text_body_and_headers() {
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 buf = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk).expect("socket should read");
if read == 0 {
break;
}
buf.extend_from_slice(&chunk[..read]);
if buf.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let header_end = buf
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| index + 4)
.expect("headers should terminate");
let request_text = String::from_utf8_lossy(&buf[..header_end]).to_lowercase();
assert!(request_text.contains("post /submit http/1.1"));
assert!(request_text.contains("x-test: alpha"));
assert!(request_text.contains("content-length: 4"));
while buf.len() < header_end + 4 {
let read = stream.read(&mut chunk).expect("socket should read body");
if read == 0 {
break;
}
buf.extend_from_slice(&chunk[..read]);
}
assert_eq!(&buf[header_end..header_end + 4], b"ping");
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
.expect("response should write");
});
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let mut request = task(&format!("http://{addr}/submit")).request;
request.method = HttpMethod::Post;
request.body = HttpBody::Text("ping".to_owned());
request.headers.headers.push(crate::http::HttpHeader {
name: "x-test".to_owned(),
value: "alpha".to_owned(),
kind: crate::http::HeaderKind::Request,
});
let response = connector
.connect_http(&request)
.expect("live post should succeed");
assert_eq!(response.status, 200);
assert_eq!(response.completed_length(), 2);
handle.join().expect("server thread should join");
}
#[test]
fn request_body_bytes_does_not_allocate_placeholder_payload_for_stream_body() {
assert!(
super::request_body_bytes(&HttpBody::Stream {
expected_len: Some(1024 * 1024)
})
.is_none()
);
}
#[test]
fn reqwest_connector_applies_sorted_query_parameters_to_request_url() {
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 buf = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk).expect("socket should read");
if read == 0 {
break;
}
buf.extend_from_slice(&chunk[..read]);
if buf.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let request_text = String::from_utf8_lossy(&buf).to_lowercase();
assert!(request_text.contains("get /search?alpha=1&beta=2 http/1.1"));
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
.expect("response should write");
});
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let mut request = task(&format!("http://{addr}/search")).request;
request.query.insert("beta".to_owned(), "2".to_owned());
request.query.insert("alpha".to_owned(), "1".to_owned());
let response = connector
.connect_http(&request)
.expect("live request with query should succeed");
assert_eq!(response.status, 200);
handle.join().expect("server thread should join");
}
#[test]
fn reqwest_connector_tracks_redirect_origin_after_following_redirect() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let first_location = format!("http://{addr}/final");
let handle = thread::spawn(move || {
let (mut first, _) = listener.accept().expect("first client should connect");
let mut first_buf = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = first.read(&mut chunk).expect("first socket should read");
if read == 0 {
break;
}
first_buf.extend_from_slice(&chunk[..read]);
if first_buf.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let first_request = String::from_utf8_lossy(&first_buf).to_lowercase();
assert!(first_request.contains("get /redirect http/1.1"));
let redirect = format!(
"HTTP/1.1 302 Found\r\nLocation: {first_location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
first
.write_all(redirect.as_bytes())
.expect("redirect response should write");
let (mut second, _) = listener.accept().expect("second client should connect");
let mut second_buf = Vec::new();
loop {
let read = second.read(&mut chunk).expect("second socket should read");
if read == 0 {
break;
}
second_buf.extend_from_slice(&chunk[..read]);
if second_buf.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let second_request = String::from_utf8_lossy(&second_buf).to_lowercase();
assert!(second_request.contains("get /final http/1.1"));
second
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nfinal")
.expect("final response should write");
});
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let expected_redirect = format!("http://{addr}/redirect");
let request = task(&expected_redirect).request;
let response = connector
.connect_http(&request)
.expect("redirected request should succeed");
assert_eq!(response.status, 200);
assert_eq!(
response.redirected_from.as_deref(),
Some(expected_redirect.as_str())
);
handle.join().expect("server thread should join");
}
#[test]
fn reqwest_connector_maps_http10_responses_to_http10_model_version() {
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; 1024];
let _ = stream.read(&mut request);
let response = b"HTTP/1.0 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nold";
stream.write_all(response).expect("response should write");
});
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let response = connector
.connect_http(&task(&format!("http://{addr}/http10")).request)
.expect("http/1.0 request should succeed");
assert_eq!(response.status, 200);
assert_eq!(response.version, HttpVersion::Http10);
handle.join().expect("server thread should join");
}
#[test]
fn reqwest_connector_uses_request_proxy_for_http_requests() {
let proxy_listener = TcpListener::bind("127.0.0.1:0").expect("proxy should bind");
let proxy_addr = proxy_listener
.local_addr()
.expect("proxy addr should exist");
let target_port = closed_loopback_port();
let handle = thread::spawn(move || {
let (mut stream, _) = proxy_listener
.accept()
.expect("proxy client should connect");
let mut buf = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk).expect("proxy socket should read");
if read == 0 {
break;
}
buf.extend_from_slice(&chunk[..read]);
if buf.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let header_end = buf
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| index + 4)
.expect("proxy request should have headers");
let request_text = String::from_utf8_lossy(&buf[..header_end]).to_lowercase();
assert!(request_text.contains(&format!(
"get http://127.0.0.1:{target_port}/proxied http/1.1"
)));
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 9\r\n\r\nproxied-ok")
.expect("proxy response should write");
});
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let mut request = task(&format!("http://127.0.0.1:{target_port}/proxied")).request;
request.proxy = Some(proxy_config(proxy_addr.port()));
let response = connector
.connect_http(&request)
.expect("request should route through proxy");
assert_eq!(response.status, 200);
assert_eq!(response.completed_length(), 9);
handle.join().expect("proxy thread should join");
}
#[test]
fn reqwest_connector_reuses_proxy_specific_clients_for_matching_proxy_config() {
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let mut request = task("http://127.0.0.1:9/proxy-cache").request;
request.proxy = Some(proxy_config(closed_loopback_port()));
let _first = connector
.client_for_request(&request)
.expect("first proxy client should build");
let _second = connector
.client_for_request(&request)
.expect("second proxy client should reuse cache");
assert_eq!(connector.cached_proxy_client_count(), 1);
}
#[test]
fn reqwest_connector_prepares_request_without_waiting_for_cache_lock() {
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let request = task("http://example.org/prepared-cache").request;
let _prepared_cache_guard = connector
.prepared_requests
.lock()
.expect("prepared request cache lock should succeed");
let prepared = connector.prepared_live_request_for(&request);
assert!(prepared.is_some());
}
#[test]
fn reqwest_connector_maps_proxy_connect_failure_to_proxy_failed() {
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let mut request = task("http://127.0.0.1:9/proxy-failure").request;
request.proxy = Some(proxy_config(closed_loopback_port()));
let error = connector
.connect_http(&request)
.expect_err("proxy connect should fail");
assert_eq!(error.kind, TransportErrorKind::ProxyFailed);
}
#[test]
fn reqwest_connector_maps_dns_resolution_failure_to_dns_failed() {
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let request = task("http://no-such-host.invalid/dns-failure").request;
let error = connector
.connect_http(&request)
.expect_err("dns lookup should fail");
assert_eq!(error.kind, TransportErrorKind::DnsFailed);
}
#[test]
fn fixture_downloader_supports_https_too() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("https://example.org/file", b"payload");
let response = downloader
.start_http_transfer(&task("https://example.org/file"))
.expect("https fixture should resolve");
assert_eq!(response.status, 200);
}
#[test]
fn connector_backed_downloader_rejects_missing_scheme() {
let downloader = ConnectorBackedDownloader::new(HttpOkConnector, HttpsOkConnector);
let error = downloader
.start_http_transfer(&task("example.org/file"))
.expect_err("missing scheme should fail");
assert_eq!(error.kind, TransportErrorKind::ProtocolViolation);
}
#[test]
fn fixture_script_can_model_206_with_content_range() {
let downloader = FixtureHttpDownloader::new();
downloader.register_partial_content("https://example.org/resume.bin", b"cdef", 2, 5, 8);
let response = downloader
.start_http_transfer(&task("https://example.org/resume.bin"))
.expect("partial fixture should resolve");
assert_eq!(response.status, 206);
assert_eq!(response.reason, "Partial Content");
assert!(
response
.headers
.headers
.iter()
.any(|h| h.name == "content-range" && h.value == "bytes 2-5/8")
);
}
#[test]
fn fixture_script_can_model_416_with_unsatisfied_content_range() {
let downloader = FixtureHttpDownloader::new();
downloader.register_script(
"https://example.org/range-reject.bin",
[FixtureStep::ok(HttpFixtureResponseSpec {
status: 416,
reason: "Range Not Satisfiable".to_owned(),
headers: vec![
crate::http::HttpHeader {
name: "content-range".to_owned(),
value: "bytes */8192".to_owned(),
kind: crate::http::HeaderKind::Response,
},
crate::http::HttpHeader {
name: "content-length".to_owned(),
value: "0".to_owned(),
kind: crate::http::HeaderKind::Response,
},
],
body: Vec::new(),
checksum: None,
streamed: false,
})],
);
let response = downloader
.start_http_transfer(&task("https://example.org/range-reject.bin"))
.expect("416 fixture should resolve");
assert_eq!(response.status, 416);
assert_eq!(response.total_length(), Some(8192));
assert_eq!(response.completed_length(), 0);
assert!(
response
.content_range
.as_ref()
.expect("content-range should parse")
.unsatisfied
);
}
#[test]
fn fixture_script_supports_transient_failure_then_success() {
let downloader = FixtureHttpDownloader::new();
downloader.register_transient_failure_then_ok(
"https://example.org/retry.bin",
TransportErrorKind::Timeout,
"transient timeout",
b"ok-after-retry",
);
let first = downloader.start_http_transfer(&task("https://example.org/retry.bin"));
let first_err = first.expect_err("first attempt should fail");
assert_eq!(first_err.kind, TransportErrorKind::Timeout);
let second = downloader
.start_http_transfer(&task("https://example.org/retry.bin"))
.expect("second attempt should succeed");
assert_eq!(second.status, 200);
}
#[test]
fn fixture_script_can_emit_streamed_observed_checksum_truth() {
let downloader = FixtureHttpDownloader::new();
downloader.register_streamed_ok_with_checksum(
"https://example.org/streamed.bin",
b"abc",
"md5",
"900150983cd24fb0d6963f7d28e17f72",
);
let response = downloader
.start_http_transfer(&task("https://example.org/streamed.bin"))
.expect("streamed fixture should resolve");
match response.body {
ResponseBody::Streamed {
expected_len,
observed_len,
ref observed_digest,
ref temp_path,
} => {
assert_eq!(expected_len, Some(3));
assert_eq!(observed_len, Some(3));
assert_eq!(
observed_digest.as_deref(),
Some("900150983cd24fb0d6963f7d28e17f72")
);
assert!(temp_path.is_some());
}
other => panic!("expected streamed body, got {other:?}"),
}
assert_eq!(
response.completion_model().state,
crate::http::HttpCompletionState::Verified
);
}
#[test]
fn streamed_execution_truth_comes_from_sink_writes_not_declared_body_len() {
let checksum = crate::http::ChecksumSpec {
algorithm: "sha1".to_owned(),
expected_hex: String::new(),
actual_hex: None,
};
let response_body = super::execute_streamed_body(b"hello-sink", Some(&checksum));
match response_body {
ResponseBody::Streamed {
expected_len,
observed_len,
observed_digest,
ref temp_path,
} => {
assert_eq!(expected_len, Some(10));
assert_eq!(observed_len, Some(10));
assert_eq!(
observed_digest.as_deref(),
Some("381cf617458c906e12825ac22e9c621e7bba2390")
);
assert!(temp_path.is_some());
}
other => panic!("expected streamed body, got {other:?}"),
}
}
#[test]
fn temp_stream_sink_path_stays_unique_across_rapid_calls() {
let paths = (0..512)
.map(|_| super::temp_stream_sink_path())
.collect::<Vec<_>>();
let unique = paths.iter().cloned().collect::<BTreeSet<_>>();
assert_eq!(unique.len(), paths.len());
}
#[test]
fn fixture_script_can_pin_last_step_for_additional_attempts() {
let downloader = FixtureHttpDownloader::new();
downloader.register_script(
"https://example.org/retry-stable.bin",
[
FixtureStep::err(TransportErrorKind::ConnectionReset, "reset once"),
FixtureStep::ok(HttpFixtureResponseSpec::ok(b"stable".to_vec())),
],
);
let _ = downloader.start_http_transfer(&task("https://example.org/retry-stable.bin"));
let second = downloader
.start_http_transfer(&task("https://example.org/retry-stable.bin"))
.expect("second attempt should pass");
let third = downloader
.start_http_transfer(&task("https://example.org/retry-stable.bin"))
.expect("third attempt should still pass");
assert_eq!(second.status, 200);
assert_eq!(third.status, 200);
}
#[test]
fn fixture_downloader_can_serve_ftp_transfer() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register_ftp(
"ftp://example.org:21/file.bin",
FtpResponseModel {
code: 226,
message: "transfer complete".to_owned(),
data: Some(b"ftp-body".to_vec()),
path: Some("/file.bin".to_owned()),
transferable: true,
},
);
let response = downloader
.start_ftp_transfer(
&crate::ftp::FtpConfigModel {
host: "example.org".to_owned(),
port: 21,
username: None,
password: None,
secure: false,
mode: crate::ftp::FtpMode::Passive,
initial_cwd: None,
proxy: None,
tls: None,
retry: retry(),
},
&crate::ftp::FtpRequestModel {
command: FtpCommandModel::Retr("/file.bin".to_owned()),
path: Some("/file.bin".to_owned()),
headers: Vec::new(),
},
)
.expect("ftp fixture should resolve");
assert_eq!(response.code, 226);
assert_eq!(response.data.as_deref(), Some(&b"ftp-body"[..]));
}
#[test]
fn fixture_downloader_can_serve_sftp_transfer() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register_sftp(
"sftp://example.org:22/file.bin",
SftpResponseModel {
ok: true,
message: "sftp ok".to_owned(),
payload: Some(b"sftp-body".to_vec()),
path: Some("/file.bin".to_owned()),
transferable: true,
},
);
let response = downloader
.start_sftp_transfer(
&crate::sftp::SftpConfigModel {
host: "example.org".to_owned(),
port: 22,
username: None,
password: None,
private_key_path: None,
known_hosts_path: None,
strict_host_key_checking: true,
proxy: None,
tls: None,
retry: retry(),
},
&crate::sftp::SftpRequestModel {
command: SftpCommandModel::Read {
path: "/file.bin".to_owned(),
offset: 0,
length: 1024,
},
path: Some("/file.bin".to_owned()),
headers: Vec::new(),
},
)
.expect("sftp fixture should resolve");
assert!(response.ok);
assert_eq!(response.payload.as_deref(), Some(&b"sftp-body"[..]));
}
@@ -0,0 +1,665 @@
use super::{
BTreeMap, ByteSink, ChecksumSpec, ContentRangeSpec, Downloader, FtpConfigModel,
FtpRequestModel, FtpResponseModel, HttpConnector, HttpHeader, HttpRequestModel,
HttpResponseHeaders, HttpResponseModel, HttpTransferTaskModel, HttpVersion, HttpsConnector,
Mutex, NEXT_TEMP_STREAM_SINK_ID, ObservedByteSink, ObservedFileSink, Ordering, RangeSpec,
RangeUnit, ResponseBody, SftpConfigModel, SftpRequestModel, SftpResponseModel, SystemTime,
TransportError, UNIX_EPOCH,
core_downloader::{normalize_http_response_for_execution, request_scheme},
env,
};
#[derive(Debug, Default)]
/// Fixture-backed downloader used by tests and local runtime smokes.
pub struct FixtureHttpDownloader {
/// Inline HTTP and HTTPS fixtures keyed by URL.
fixtures: BTreeMap<String, Vec<u8>>,
/// FTP fixtures keyed by URL.
ftp_fixtures: BTreeMap<String, FtpResponseModel>,
/// SFTP fixtures keyed by URL.
sftp_fixtures: BTreeMap<String, SftpResponseModel>,
/// Scripted HTTP fixture responses keyed by URL.
scripts: Mutex<BTreeMap<String, FixtureScript>>,
}
impl FixtureHttpDownloader {
#[must_use]
/// Builds an empty fixture registry.
pub fn new() -> Self {
Self {
fixtures: BTreeMap::new(),
ftp_fixtures: BTreeMap::new(),
sftp_fixtures: BTreeMap::new(),
scripts: Mutex::new(BTreeMap::new()),
}
}
/// Registers a simple inline HTTP fixture for `url`.
pub fn register(&mut self, url: impl Into<String>, body: impl AsRef<[u8]>) {
self.fixtures.insert(url.into(), body.as_ref().to_vec());
}
/// Registers an FTP fixture response for `url`.
pub fn register_ftp(&mut self, url: impl Into<String>, response: FtpResponseModel) {
self.ftp_fixtures.insert(url.into(), response);
}
/// Registers an SFTP fixture response for `url`.
pub fn register_sftp(&mut self, url: impl Into<String>, response: SftpResponseModel) {
self.sftp_fixtures.insert(url.into(), response);
}
/// Registers a scripted sequence of responses for `url`.
pub fn register_script(
&self,
url: impl Into<String>,
steps: impl IntoIterator<Item = FixtureStep>,
) {
let script = FixtureScript::new(steps);
if let Ok(mut scripts) = self.scripts.lock() {
scripts.insert(url.into(), script);
}
}
/// Registers a single partial-content HTTP fixture for `url`.
pub fn register_partial_content(
&self,
url: impl Into<String>,
body: impl AsRef<[u8]>,
start: u64,
end_inclusive: u64,
total: u64,
) {
self.register_script(
url,
[FixtureStep::ok(HttpFixtureResponseSpec::partial_content(
body.as_ref().to_vec(),
start,
end_inclusive,
total,
))],
);
}
/// Registers a successful inline HTTP fixture with explicit checksum metadata.
pub fn register_ok_with_checksum(
&self,
url: impl Into<String>,
body: impl AsRef<[u8]>,
algorithm: impl Into<String>,
expected_hex: impl Into<String>,
actual_hex: Option<impl Into<String>>,
) {
self.register_script(
url,
[FixtureStep::ok(
HttpFixtureResponseSpec::ok(body.as_ref().to_vec()).with_checksum(ChecksumSpec {
algorithm: algorithm.into(),
expected_hex: expected_hex.into(),
actual_hex: actual_hex.map(Into::into),
}),
)],
);
}
/// Registers a successful streamed HTTP fixture with explicit checksum metadata.
pub fn register_streamed_ok_with_checksum(
&self,
url: impl Into<String>,
body: impl AsRef<[u8]>,
algorithm: impl Into<String>,
expected_hex: impl Into<String>,
) {
self.register_script(
url,
[FixtureStep::ok(
HttpFixtureResponseSpec::streamed_ok(body.as_ref().to_vec()).with_checksum(
ChecksumSpec {
algorithm: algorithm.into(),
expected_hex: expected_hex.into(),
actual_hex: None,
},
),
)],
);
}
/// Registers a transient failure followed by a successful inline response.
pub fn register_transient_failure_then_ok(
&self,
url: impl Into<String>,
kind: crate::transport::TransportErrorKind,
message: impl Into<String>,
body: impl AsRef<[u8]>,
) {
self.register_script(
url,
[
FixtureStep::err(kind, message),
FixtureStep::ok(HttpFixtureResponseSpec::ok(body.as_ref().to_vec())),
],
);
}
/// Resolves a registered fixture body or scripted response for one HTTP transfer task.
fn fixture_response(
&self,
task: &HttpTransferTaskModel,
) -> Result<HttpResponseModel, TransportError> {
let url = &task.request.url;
if let Ok(mut scripts) = self.scripts.lock()
&& let Some(script) = scripts.get_mut(url)
{
return script.next_response();
}
let Some(body) = self.fixtures.get(url) else {
return Err(TransportError {
kind: crate::transport::TransportErrorKind::NotConnected,
message: format!("no fixture registered for {url}"),
source: None,
context: None,
});
};
if let Some(range) = task.request.range.as_ref() {
return response_for_range(url, body, range);
}
Ok(HttpResponseModel {
status: 200,
reason: "OK".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![HttpHeader {
name: "content-length".to_owned(),
value: body.len().to_string(),
kind: crate::http::HeaderKind::Response,
}],
},
body: ResponseBody::Inline(body.clone()),
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
})
}
}
/// Slices fixture bytes according to an optional HTTP range request.
fn response_for_range(
url: &str,
body: &[u8],
range: &RangeSpec,
) -> Result<HttpResponseModel, TransportError> {
if !matches!(range.unit, RangeUnit::Bytes) {
return Err(TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!(
"fixture range unit not supported for {url}: {:?}",
range.unit
),
source: None,
context: None,
});
}
if body.is_empty() {
return Ok(HttpResponseModel {
status: 200,
reason: "OK".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![HttpHeader {
name: "content-length".to_owned(),
value: "0".to_owned(),
kind: crate::http::HeaderKind::Response,
}],
},
body: ResponseBody::Empty,
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
});
}
let start = usize::try_from(range.start).map_err(|_| TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!(
"fixture range starts past addressable memory for {url}: {}",
range.start
),
source: None,
context: None,
})?;
if start >= body.len() {
return Err(TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("fixture range starts past body for {url}: {start}"),
source: None,
context: None,
});
}
let last_index = body.len().saturating_sub(1);
let end_inclusive = range
.end_inclusive
.and_then(|end| usize::try_from(end).ok())
.unwrap_or(last_index)
.min(last_index);
let end_inclusive = end_inclusive.max(start);
let slice = body
.get(start..=end_inclusive)
.ok_or_else(|| TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("fixture range slice is invalid for {url}: {start}..={end_inclusive}"),
source: None,
context: None,
})?
.to_vec();
let total = u64::try_from(body.len()).unwrap_or(u64::MAX);
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
HttpHeader {
name: "content-length".to_owned(),
value: slice.len().to_string(),
kind: crate::http::HeaderKind::Response,
},
HttpHeader {
name: "content-range".to_owned(),
value: format!("bytes {start}-{end_inclusive}/{total}"),
kind: crate::http::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(slice),
content_range: Some(ContentRangeSpec {
unit: RangeUnit::Bytes,
start: u64::try_from(start).unwrap_or(u64::MAX),
end_inclusive: u64::try_from(end_inclusive).unwrap_or(u64::MAX),
total_size: Some(total),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
})
}
#[derive(Clone, Debug)]
/// Ordered script that can emit fixture responses across repeated attempts.
struct FixtureScript {
/// Ordered scripted fixture steps.
steps: Vec<FixtureStep>,
/// Cursor pointing at the next step to emit.
cursor: usize,
}
impl FixtureScript {
/// Builds a fixture script from an ordered step sequence.
fn new(steps: impl IntoIterator<Item = FixtureStep>) -> Self {
Self {
steps: steps.into_iter().collect(),
cursor: 0,
}
}
/// Returns the next scripted response, pinning to the final step once exhausted.
fn next_response(&mut self) -> Result<HttpResponseModel, TransportError> {
if self.steps.is_empty() {
return Err(TransportError {
kind: crate::transport::TransportErrorKind::NotConnected,
message: "fixture script is empty".to_owned(),
source: None,
context: None,
});
}
let idx = self.cursor.min(self.steps.len() - 1);
if self.cursor < self.steps.len() - 1 {
self.cursor += 1;
}
self.steps[idx].to_result()
}
}
#[derive(Clone, Debug)]
/// One scripted fixture step for the HTTP fixture downloader.
pub enum FixtureStep {
/// Emits a successful HTTP response described by the fixture spec.
Response(HttpFixtureResponseSpec),
/// Emits a transport error with the provided kind and message.
Error {
/// Error kind surfaced by the scripted step.
kind: crate::transport::TransportErrorKind,
/// Human-readable error message surfaced by the scripted step.
message: String,
},
}
impl FixtureStep {
#[must_use]
/// Builds a successful fixture step from a response spec.
pub const fn ok(spec: HttpFixtureResponseSpec) -> Self {
Self::Response(spec)
}
#[must_use]
/// Builds an error fixture step from a transport error kind and message.
pub fn err(kind: crate::transport::TransportErrorKind, message: impl Into<String>) -> Self {
Self::Error {
kind,
message: message.into(),
}
}
/// Converts one scripted step into the response or error it represents.
fn to_result(&self) -> Result<HttpResponseModel, TransportError> {
match self {
Self::Response(spec) => Ok(spec.to_http_response()),
Self::Error { kind, message } => Err(TransportError {
kind: *kind,
message: message.clone(),
source: None,
context: None,
}),
}
}
}
#[derive(Clone, Debug)]
/// Declarative HTTP response fixture used by `FixtureHttpDownloader`.
pub struct HttpFixtureResponseSpec {
/// HTTP status code emitted by the fixture.
pub(super) status: u16,
/// HTTP reason phrase emitted by the fixture.
pub(super) reason: String,
/// Response headers emitted by the fixture.
pub(super) headers: Vec<HttpHeader>,
/// Inline payload bytes used by the fixture.
pub(super) body: Vec<u8>,
/// Optional checksum metadata attached to the fixture response.
pub(super) checksum: Option<ChecksumSpec>,
/// Whether the fixture should materialize a streamed response body.
pub(super) streamed: bool,
}
impl HttpFixtureResponseSpec {
#[must_use]
/// Builds a successful inline-body HTTP fixture.
pub fn ok(body: Vec<u8>) -> Self {
Self {
status: 200,
reason: "OK".to_owned(),
headers: vec![HttpHeader {
name: "content-length".to_owned(),
value: body.len().to_string(),
kind: crate::http::HeaderKind::Response,
}],
body,
checksum: None,
streamed: false,
}
}
#[must_use]
/// Builds a successful streamed-body HTTP fixture.
pub fn streamed_ok(body: Vec<u8>) -> Self {
Self {
status: 200,
reason: "OK".to_owned(),
headers: vec![HttpHeader {
name: "content-length".to_owned(),
value: body.len().to_string(),
kind: crate::http::HeaderKind::Response,
}],
body,
checksum: None,
streamed: true,
}
}
#[must_use]
/// Builds a `206 Partial Content` HTTP fixture.
pub fn partial_content(body: Vec<u8>, start: u64, end_inclusive: u64, total: u64) -> Self {
Self {
status: 206,
reason: "Partial Content".to_owned(),
headers: vec![
HttpHeader {
name: "content-length".to_owned(),
value: body.len().to_string(),
kind: crate::http::HeaderKind::Response,
},
HttpHeader {
name: "content-range".to_owned(),
value: format!("bytes {start}-{end_inclusive}/{total}"),
kind: crate::http::HeaderKind::Response,
},
],
body,
checksum: None,
streamed: false,
}
}
#[must_use]
/// Attaches checksum metadata to the fixture response.
pub fn with_checksum(mut self, checksum: ChecksumSpec) -> Self {
self.checksum = Some(checksum);
self
}
/// Converts the declarative fixture into a concrete HTTP response model.
fn to_http_response(&self) -> HttpResponseModel {
let body = if self.streamed {
execute_streamed_body(&self.body, self.checksum.as_ref())
} else {
ResponseBody::Inline(self.body.clone())
};
HttpResponseModel {
status: self.status,
reason: self.reason.clone(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: self.headers.clone(),
},
body,
content_range: parse_content_range(&self.headers),
partial_content: self.status == 206,
checksum: self.checksum.clone(),
redirected_from: None,
}
}
}
/// Materializes fixture bytes into an observed streamed body representation.
pub(super) fn execute_streamed_body(body: &[u8], checksum: Option<&ChecksumSpec>) -> ResponseBody {
let temp_path = temp_stream_sink_path();
let streamed = ObservedFileSink::create(&temp_path).map_or_else(
|_| {
let mut sink = ObservedByteSink::with_unbounded_retention();
ByteSink::write(&mut sink, body).expect("observed sink write is infallible");
let observed_len = sink.observed_len();
let observed_digest =
checksum.and_then(|spec| spec.compute_actual_hex(sink.retained()));
(observed_len, observed_digest, None)
},
|mut sink| {
ByteSink::write(&mut sink, body).expect("observed file sink write is infallible");
let observed_len = sink.observed_len();
let observed_digest =
checksum.and_then(|spec| spec.compute_actual_hex(sink.retained()));
(observed_len, observed_digest, Some(temp_path))
},
);
ResponseBody::Streamed {
expected_len: Some(u64::try_from(body.len()).unwrap_or(u64::MAX)),
observed_len: Some(streamed.0),
observed_digest: streamed.1,
temp_path: streamed.2,
}
}
/// Allocates a best-effort temporary file path for streamed fixture bodies.
pub(super) fn temp_stream_sink_path() -> std::path::PathBuf {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or_default();
let sequence = NEXT_TEMP_STREAM_SINK_ID.fetch_add(1, Ordering::Relaxed);
env::temp_dir().join(format!(
"aria2-rust-pro-streamed-{}-{}-{}.bin",
std::process::id(),
stamp,
sequence
))
}
/// Parses a `Content-Range` response header into the protocol-layer range model.
pub(super) fn parse_content_range(headers: &[HttpHeader]) -> Option<ContentRangeSpec> {
let value = headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case("content-range"))?
.value
.trim();
let rest = value.strip_prefix("bytes ")?;
let (range_part, total_part) = rest.split_once('/')?;
let total_size = if total_part == "*" {
None
} else {
Some(total_part.parse::<u64>().ok()?)
};
if range_part == "*" {
return Some(ContentRangeSpec {
unit: RangeUnit::Bytes,
start: 0,
end_inclusive: 0,
total_size,
unsatisfied: true,
});
}
let (start, end) = range_part.split_once('-')?;
let start = start.parse::<u64>().ok()?;
let end_inclusive = end.parse::<u64>().ok()?;
Some(ContentRangeSpec {
unit: RangeUnit::Bytes,
start,
end_inclusive,
total_size,
unsatisfied: false,
})
}
impl HttpConnector for FixtureHttpDownloader {
fn connect_http(
&self,
request: &HttpRequestModel,
) -> Result<HttpResponseModel, TransportError> {
let retry = request.retry;
self.fixture_response(&HttpTransferTaskModel {
task_id: String::new(),
request: request.clone(),
response_headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Empty,
resume_state: None,
retry_attempts: Vec::new(),
checksum_hook: None,
max_connections: 1,
retry,
})
}
}
impl HttpsConnector for FixtureHttpDownloader {
fn connect_https(
&self,
request: &HttpRequestModel,
) -> Result<HttpResponseModel, TransportError> {
let retry = request.retry;
self.fixture_response(&HttpTransferTaskModel {
task_id: String::new(),
request: request.clone(),
response_headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Empty,
resume_state: None,
retry_attempts: Vec::new(),
checksum_hook: None,
max_connections: 1,
retry,
})
}
}
impl Downloader for FixtureHttpDownloader {
fn start_http_transfer(
&self,
task: &HttpTransferTaskModel,
) -> Result<HttpResponseModel, TransportError> {
match request_scheme(&task.request.url) {
Some("http" | "https") => self
.fixture_response(task)
.map(|response| normalize_http_response_for_execution(task, response)),
Some(scheme) => Err(TransportError {
kind: crate::transport::TransportErrorKind::UnsupportedScheme,
message: format!("fixture downloader does not support scheme: {scheme}"),
source: None,
context: None,
}),
None => Err(TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("request url has no scheme: {}", task.request.url),
source: None,
context: None,
}),
}
}
fn start_ftp_transfer(
&self,
config: &FtpConfigModel,
request: &FtpRequestModel,
) -> Result<FtpResponseModel, TransportError> {
let path = request.path.as_deref().unwrap_or_default();
let url = format!("ftp://{}:{}{}", config.host, config.port, path);
self.ftp_fixtures
.get(&url)
.cloned()
.ok_or_else(|| TransportError {
kind: crate::transport::TransportErrorKind::UnsupportedScheme,
message: format!("ftp fixture not registered for {url}"),
source: None,
context: None,
})
}
fn start_sftp_transfer(
&self,
config: &SftpConfigModel,
request: &SftpRequestModel,
) -> Result<SftpResponseModel, TransportError> {
let path = request.path.as_deref().unwrap_or_default();
let url = format!("sftp://{}:{}{}", config.host, config.port, path);
self.sftp_fixtures
.get(&url)
.cloned()
.ok_or_else(|| TransportError {
kind: crate::transport::TransportErrorKind::UnsupportedScheme,
message: format!("sftp fixture not registered for {url}"),
source: None,
context: None,
})
}
}
@@ -0,0 +1,641 @@
use super::{
Arc, Client, File, HTTP_TIMING_PROBE_ENABLED, HashMap, HeaderMap, HeaderName, HeaderValue,
HttpConnector, HttpHeader, HttpRequestModel, HttpResponseHeaders, HttpResponseModel,
HttpVersion, HttpsConnector, LIVE_HTTP_POOL_IDLE_TIMEOUT, LIVE_HTTP_POOL_MAX_IDLE_PER_HOST,
LIVE_HTTP_TCP_KEEPALIVE, MAX_PREPARED_REQUEST_CACHE_ENTRIES, MAX_PROXY_CLIENT_CACHE_ENTRIES,
Mutex, NoProxy, OpenOptions, Proxy, RANGE, Response, ResponseBody, SeekFrom, StdError,
TransportError, env,
fixture_downloader::{parse_content_range, temp_stream_sink_path},
};
use std::io::Seek as _;
#[derive(Clone, Debug)]
/// Live reqwest-backed connector for HTTP and HTTPS requests.
pub struct ReqwestHttpConnector {
/// Reused client for the common no-proxy request path.
default_client: Client,
/// Reused clients for proxy-specific request paths.
proxy_clients: Arc<Mutex<HashMap<ProxyClientCacheKey, Client>>>,
/// Reused reqwest URL/header preparation keyed by immutable request shape.
pub(super) prepared_requests:
Arc<Mutex<HashMap<PreparedLiveHttpRequestKey, Arc<PreparedLiveHttpRequest>>>>,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
/// Stable cache key for proxy-specific reqwest clients.
struct ProxyClientCacheKey {
/// Proxy URL scheme.
scheme: String,
/// Proxy host name or address.
host: String,
/// Proxy TCP port.
port: u16,
/// Optional proxy username.
username: Option<String>,
/// Optional proxy password.
password: Option<String>,
/// Hosts bypassed by this proxy.
bypass_hosts: Vec<String>,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
/// Stable cache key for normalized live HTTP request preparation.
pub(super) struct PreparedLiveHttpRequestKey {
/// Base request URL before query map application.
url: String,
/// Stable sorted query parameters.
query: Vec<(String, String)>,
/// Stable sorted request headers.
headers: Vec<(String, String)>,
}
#[derive(Clone, Debug)]
/// Prepared reqwest request pieces reusable across repeated equivalent requests.
pub(super) struct PreparedLiveHttpRequest {
/// Parsed request URL with stable query parameters applied.
requested_url: reqwest::Url,
/// Validated reqwest header map.
headers: HeaderMap,
}
impl ReqwestHttpConnector {
/// Builds a connector after validating that a reqwest client can be created.
///
/// # Errors
///
/// Returns an error when the underlying reqwest client cannot be built.
pub fn new() -> Result<Self, TransportError> {
let default_client = build_reqwest_client(None)?;
Ok(Self {
default_client,
proxy_clients: Arc::new(Mutex::new(HashMap::new())),
prepared_requests: Arc::new(Mutex::new(HashMap::new())),
})
}
/// Selects either the shared default client or a proxy-specific client.
pub(super) fn client_for_request(
&self,
request: &HttpRequestModel,
) -> Result<Client, TransportError> {
if let Some(proxy) = request.proxy.as_ref().filter(|proxy| !proxy.no_proxy) {
return self.proxy_client_for(proxy);
}
Ok(self.default_client.clone())
}
/// Returns a cached or newly built client for one proxy configuration.
fn proxy_client_for(&self, proxy: &crate::http::ProxyConfig) -> Result<Client, TransportError> {
let key = ProxyClientCacheKey::from_config(proxy);
if let Some(client) = self
.proxy_clients
.lock()
.ok()
.and_then(|proxy_clients| proxy_clients.get(&key).cloned())
{
return Ok(client);
}
let client = build_reqwest_client(Some(proxy))?;
if let Ok(mut proxy_clients) = self.proxy_clients.lock() {
if proxy_clients.len() >= MAX_PROXY_CLIENT_CACHE_ENTRIES {
if let Some(cached) = proxy_clients.get(&key) {
return Ok(cached.clone());
}
proxy_clients.clear();
}
let cached = proxy_clients.entry(key).or_insert_with(|| client.clone());
return Ok(cached.clone());
}
Ok(client)
}
#[cfg(test)]
/// Returns the number of cached proxy-specific clients for cache tests.
pub(super) fn cached_proxy_client_count(&self) -> usize {
self.proxy_clients
.lock()
.map(|proxy_clients| proxy_clients.len())
.unwrap_or_default()
}
/// Returns cached parsed URL/header state for repeated equivalent requests.
pub(super) fn prepared_live_request_for(
&self,
request: &HttpRequestModel,
) -> Option<Arc<PreparedLiveHttpRequest>> {
let key = match self.prepared_requests.try_lock() {
Ok(prepared_requests) => {
let key = PreparedLiveHttpRequestKey::from_request(request);
if let Some(prepared) = prepared_requests.get(&key).cloned() {
return Some(prepared);
}
key
}
Err(_) => {
return PreparedLiveHttpRequest::from_request(request).map(Arc::new);
}
};
let prepared = Arc::new(PreparedLiveHttpRequest::from_request(request)?);
if let Ok(mut prepared_requests) = self.prepared_requests.try_lock() {
if prepared_requests.len() >= MAX_PREPARED_REQUEST_CACHE_ENTRIES {
if let Some(cached) = prepared_requests.get(&key) {
return Some(cached.clone());
}
prepared_requests.clear();
}
if let Some(cached) = prepared_requests.get(&key) {
return Some(cached.clone());
}
prepared_requests.insert(key, prepared.clone());
}
Some(prepared)
}
}
impl ProxyClientCacheKey {
/// Builds a stable key from protocol-layer proxy configuration.
fn from_config(proxy: &crate::http::ProxyConfig) -> Self {
Self {
scheme: proxy.scheme.clone(),
host: proxy.host.clone(),
port: proxy.port,
username: proxy.username.clone(),
password: proxy.password.clone(),
bypass_hosts: proxy.bypass_hosts.clone(),
}
}
}
impl PreparedLiveHttpRequestKey {
/// Builds a stable key from immutable request URL/query/header data.
fn from_request(request: &HttpRequestModel) -> Self {
let mut query = request
.query
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect::<Vec<_>>();
if query.len() > 1 {
query.sort_unstable();
}
let mut headers = request
.headers
.headers
.iter()
.map(|header| (header.name.clone(), header.value.clone()))
.collect::<Vec<_>>();
if headers.len() > 1 {
headers.sort_unstable();
}
Self {
url: request.url.clone(),
query,
headers,
}
}
}
impl PreparedLiveHttpRequest {
/// Parses and validates reusable URL/header state from one request.
fn from_request(request: &HttpRequestModel) -> Option<Self> {
let requested_url = request_url_with_query(request).ok()?;
let headers = http_headers_from_request(&request.headers.headers).ok()?;
Some(Self {
requested_url,
headers,
})
}
}
impl Default for ReqwestHttpConnector {
fn default() -> Self {
Self::new().expect("reqwest client should build")
}
}
impl HttpConnector for ReqwestHttpConnector {
fn connect_http(
&self,
request: &HttpRequestModel,
) -> Result<HttpResponseModel, TransportError> {
let prepared = self.prepared_live_request_for(request);
live_http_response(
self.client_for_request(request)?,
request,
prepared.as_deref(),
)
}
}
impl HttpsConnector for ReqwestHttpConnector {
fn connect_https(
&self,
request: &HttpRequestModel,
) -> Result<HttpResponseModel, TransportError> {
let prepared = self.prepared_live_request_for(request);
live_http_response(
self.client_for_request(request)?,
request,
prepared.as_deref(),
)
}
}
/// Builds one reqwest client with the protocol-layer defaults and optional proxy.
///
/// # Errors
///
/// Returns an error when reqwest rejects the configured client or proxy settings.
fn build_reqwest_client(
proxy: Option<&crate::http::ProxyConfig>,
) -> Result<Client, TransportError> {
let mut builder = Client::builder()
.no_proxy()
.tcp_nodelay(true)
.tcp_keepalive(LIVE_HTTP_TCP_KEEPALIVE)
.pool_max_idle_per_host(LIVE_HTTP_POOL_MAX_IDLE_PER_HOST)
.pool_idle_timeout(LIVE_HTTP_POOL_IDLE_TIMEOUT)
.redirect(reqwest::redirect::Policy::limited(10));
if let Some(proxy) = proxy.filter(|proxy| !proxy.no_proxy) {
builder = builder.proxy(reqwest_proxy_from_config(proxy)?);
}
builder.build().map_err(|error| TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("failed to build reqwest client: {error}"),
source: Some(error.to_string()),
context: None,
})
}
/// Converts the protocol-layer proxy model into a reqwest proxy configuration.
fn reqwest_proxy_from_config(proxy: &crate::http::ProxyConfig) -> Result<Proxy, TransportError> {
let proxy_url = format!("{}://{}:{}", proxy.scheme, proxy.host, proxy.port);
let mut reqwest_proxy = match proxy.scheme.as_str() {
"http" => Proxy::http(&proxy_url).map_err(|error| TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("invalid http proxy config for {proxy_url}: {error}"),
source: Some(error.to_string()),
context: None,
})?,
"https" => Proxy::https(&proxy_url).map_err(|error| TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("invalid https proxy config for {proxy_url}: {error}"),
source: Some(error.to_string()),
context: None,
})?,
other => {
return Err(TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("unsupported proxy scheme: {other}"),
source: None,
context: None,
});
}
};
if !proxy.bypass_hosts.is_empty() {
reqwest_proxy = reqwest_proxy.no_proxy(NoProxy::from_string(&proxy.bypass_hosts.join(",")));
}
if let Some(username) = proxy.username.as_deref() {
reqwest_proxy =
reqwest_proxy.basic_auth(username, proxy.password.as_deref().unwrap_or_default());
}
Ok(reqwest_proxy)
}
/// Executes one live HTTP request through reqwest and normalizes the response model.
fn live_http_response(
client: Client,
request: &HttpRequestModel,
prepared_live_request: Option<&PreparedLiveHttpRequest>,
) -> Result<HttpResponseModel, TransportError> {
let timing_probe = *HTTP_TIMING_PROBE_ENABLED
.get_or_init(|| env::var_os("ARIA2_RUST_PRO_HTTP_TIMING").is_some());
let overall_started = timing_probe.then(std::time::Instant::now);
let client_started = timing_probe.then(std::time::Instant::now);
let client_elapsed_ms = client_started
.as_ref()
.map(|started| started.elapsed().as_millis())
.unwrap_or_default();
let requested_url = if let Some(prepared) = prepared_live_request {
prepared.requested_url.clone()
} else {
request_url_with_query(request)?
};
let method = match request.method {
crate::http::HttpMethod::Get => reqwest::Method::GET,
crate::http::HttpMethod::Head => reqwest::Method::HEAD,
crate::http::HttpMethod::Post => reqwest::Method::POST,
crate::http::HttpMethod::Put => reqwest::Method::PUT,
crate::http::HttpMethod::Delete => reqwest::Method::DELETE,
};
let mut builder = client.request(method, requested_url.clone());
builder = builder.headers(if let Some(prepared) = prepared_live_request {
prepared.headers.clone()
} else {
http_headers_from_request(&request.headers.headers)?
});
if let Some(body) = request_body_bytes(&request.body) {
builder = builder.body(body);
}
if let Some(range) = &request.range {
let range_value = range.end_inclusive.map_or_else(
|| format!("bytes={}-", range.start),
|end| format!("bytes={}-{}", range.start, end),
);
builder = builder.header(RANGE, range_value);
}
if let Some(auth) = &request.auth
&& let Some(username) = auth.username.as_deref()
{
builder = builder.basic_auth(username, auth.password.as_deref());
}
let send_started = timing_probe.then(std::time::Instant::now);
let response = builder
.send()
.map_err(|error| transport_error_from_reqwest(error, request))?;
let send_elapsed_ms = send_started
.as_ref()
.map(|started| started.elapsed().as_millis())
.unwrap_or_default();
let normalize_started = timing_probe.then(std::time::Instant::now);
let response = http_response_from_reqwest(response, request, &requested_url)?;
if let Some(total_started) = overall_started.as_ref() {
eprintln!(
"http connector timing url={} client_ms={} send_ms={} normalize_ms={} total_ms={}",
request.url,
client_elapsed_ms,
send_elapsed_ms,
normalize_started
.as_ref()
.map(|started| started.elapsed().as_millis())
.unwrap_or_default(),
total_started.elapsed().as_millis(),
);
}
Ok(response)
}
/// Rebuilds the request URL with its query map in stable key order.
fn request_url_with_query(request: &HttpRequestModel) -> Result<reqwest::Url, TransportError> {
let mut url = reqwest::Url::parse(&request.url).map_err(|error| TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("invalid request url {}: {error}", request.url),
source: Some(error.to_string()),
context: None,
})?;
if !request.query.is_empty() {
let mut query_pairs = request
.query
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect::<Vec<_>>();
query_pairs.sort_unstable();
{
let mut pairs = url.query_pairs_mut();
for (key, value) in query_pairs {
pairs.append_pair(key, value);
}
}
}
Ok(url)
}
/// Converts protocol-layer request headers into a reqwest header map.
fn http_headers_from_request(headers: &[HttpHeader]) -> Result<HeaderMap, TransportError> {
let mut map = HeaderMap::with_capacity(headers.len());
for header in headers {
let name =
HeaderName::from_bytes(header.name.as_bytes()).map_err(|error| TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("invalid request header name {}: {error}", header.name),
source: Some(error.to_string()),
context: None,
})?;
let value = HeaderValue::from_str(&header.value).map_err(|error| TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("invalid request header value for {}: {error}", header.name),
source: Some(error.to_string()),
context: None,
})?;
map.append(name, value);
}
Ok(map)
}
/// Extracts an owned request payload when the body model is inline or textual.
pub(super) fn request_body_bytes(body: &crate::http::HttpBody) -> Option<Vec<u8>> {
match body {
crate::http::HttpBody::Empty | crate::http::HttpBody::Stream { .. } => None,
crate::http::HttpBody::Text(text) => Some(text.clone().into_bytes()),
crate::http::HttpBody::Binary(bytes) => Some(bytes.clone()),
}
}
/// Maps a reqwest failure into the transport error model with request context.
fn transport_error_from_reqwest(
error: reqwest::Error,
request: &HttpRequestModel,
) -> TransportError {
let kind = transport_error_kind_from_reqwest(&error, request.proxy.as_ref());
TransportError {
kind,
message: format!("http request failed for {}: {error}", request.url),
source: Some(error.to_string()),
context: None,
}
}
/// Classifies a reqwest failure into the closest transport error kind.
fn transport_error_kind_from_reqwest(
error: &reqwest::Error,
proxy: Option<&crate::http::ProxyConfig>,
) -> crate::transport::TransportErrorKind {
let chain_text = reqwest_error_chain_text(error);
let is_proxy_configured = proxy.is_some_and(|proxy| !proxy.no_proxy);
if error.is_timeout() {
crate::transport::TransportErrorKind::Timeout
} else if contains_any(
&chain_text,
&[
"tls",
"certificate",
"handshake",
"unknown ca",
"invalid peer",
],
) {
crate::transport::TransportErrorKind::TlsFailed
} else if contains_any(
&chain_text,
&[
"dns",
"resolve",
"lookup address",
"name or service not known",
"no such host",
],
) {
crate::transport::TransportErrorKind::DnsFailed
} else if is_proxy_configured
&& (error.is_connect() || contains_any(&chain_text, &["proxy", "tunnel", "socks"]))
{
crate::transport::TransportErrorKind::ProxyFailed
} else if error.is_connect() {
crate::transport::TransportErrorKind::ConnectionReset
} else if error.is_builder() {
crate::transport::TransportErrorKind::ProtocolViolation
} else {
crate::transport::TransportErrorKind::Io
}
}
/// Flattens one reqwest/std-error chain into a lowercased diagnostic string.
fn reqwest_error_chain_text(error: &dyn StdError) -> String {
let mut text = error.to_string().to_lowercase();
let mut source = error.source();
while let Some(err) = source {
text.push_str(" | ");
text.push_str(&err.to_string().to_lowercase());
source = err.source();
}
text
}
/// Returns whether the haystack contains any candidate substring.
fn contains_any(text: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| text.contains(needle))
}
/// Normalizes a reqwest response into the protocol-layer HTTP response model.
fn http_response_from_reqwest(
mut response: Response,
request: &HttpRequestModel,
requested_url: &reqwest::Url,
) -> Result<HttpResponseModel, TransportError> {
let status = response.status();
let reason = status
.canonical_reason()
.unwrap_or("HTTP response")
.to_owned();
let mut headers = Vec::with_capacity(response.headers().len());
for (name, value) in response.headers() {
if let Ok(text) = value.to_str() {
headers.push(HttpHeader {
name: name.to_string(),
value: text.to_owned(),
kind: crate::http::HeaderKind::Response,
});
}
}
let expected_len = response.content_length();
let content_range = parse_content_range(&headers);
let partial_content = status.as_u16() == 206;
let direct_write_offset = content_range
.as_ref()
.map(|range| range.start)
.or_else(|| request.range.as_ref().map(|range| range.start))
.unwrap_or(0);
let (observed_len, temp_path) = if let Some(response_sink) = request.response_sink.as_ref() {
let mut sink = if direct_write_offset == 0 {
OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&response_sink.target_path)
} else {
OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&response_sink.target_path)
}
.map_err(|error| TransportError {
kind: crate::transport::TransportErrorKind::Io,
message: format!(
"failed to open direct response sink for {} at {}: {error}",
request.url,
response_sink.target_path.display()
),
source: Some(error.to_string()),
context: None,
})?;
sink.seek(SeekFrom::Start(direct_write_offset))
.map_err(|error| TransportError {
kind: crate::transport::TransportErrorKind::Io,
message: format!(
"failed to seek direct response sink for {} at {}: {error}",
request.url,
response_sink.target_path.display()
),
source: Some(error.to_string()),
context: None,
})?;
let observed_len = response
.copy_to(&mut sink)
.map_err(|error| transport_error_from_reqwest(error, request))?;
(observed_len, None)
} else {
let temp_path = temp_stream_sink_path();
let mut sink = File::create(&temp_path).map_err(|error| TransportError {
kind: crate::transport::TransportErrorKind::Io,
message: format!(
"failed to create streamed sink for {}: {error}",
request.url
),
source: Some(error.to_string()),
context: None,
})?;
let observed_len = response
.copy_to(&mut sink)
.map_err(|error| transport_error_from_reqwest(error, request))?;
(observed_len, Some(temp_path))
};
Ok(HttpResponseModel {
status: status.as_u16(),
reason,
version: http_version_from_reqwest(response.version()),
headers: HttpResponseHeaders { headers },
body: ResponseBody::Streamed {
expected_len,
observed_len: Some(observed_len),
observed_digest: None,
temp_path,
},
content_range,
partial_content,
checksum: None,
redirected_from: (response.url().as_str() != requested_url.as_str())
.then(|| requested_url.as_str().to_owned()),
})
}
/// Maps reqwest's HTTP version enum into the protocol-layer version model.
fn http_version_from_reqwest(version: reqwest::Version) -> HttpVersion {
match version {
reqwest::Version::HTTP_09 | reqwest::Version::HTTP_10 => HttpVersion::Http10,
reqwest::Version::HTTP_2 => HttpVersion::Http2,
reqwest::Version::HTTP_3 => HttpVersion::Http3,
_ => HttpVersion::Http11,
}
}
+106
View File
@@ -0,0 +1,106 @@
//! FTP request, response, and session models.
#![forbid(unsafe_code)]
use crate::{
auth::AuthCredentialModel,
http::{HttpHeader, ProxyConfig, RetryStrategy, TlsConfig},
};
/// Transfer mode used by an FTP session.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FtpMode {
/// Passive mode where the server accepts the data connection.
Passive,
/// Active mode where the client accepts the data connection.
Active,
}
/// Connection and retry settings for an FTP endpoint.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FtpConfigModel {
/// Remote host name or IP.
pub host: String,
/// Remote control-port number.
pub port: u16,
/// Optional username for login.
pub username: Option<String>,
/// Optional password for login.
pub password: Option<String>,
/// Whether FTPS or other secure transport is expected.
pub secure: bool,
/// Active or passive data-channel mode.
pub mode: FtpMode,
/// Initial working directory after login.
pub initial_cwd: Option<String>,
/// Optional proxy configuration.
pub proxy: Option<ProxyConfig>,
/// Optional TLS tuning parameters.
pub tls: Option<TlsConfig>,
/// Retry strategy for failed requests.
pub retry: RetryStrategy,
}
/// FTP command issued within a request.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum FtpCommandModel {
/// `USER <name>`.
User(String),
/// `PASS <secret>`.
Pass(String),
/// `PWD`.
Pwd,
/// `CWD <path>`.
Cwd(String),
/// `LIST [path]`.
List(Option<String>),
/// `SIZE <path>`.
Size(String),
/// `REST <offset>`.
Rest(u64),
/// `RETR <path>`.
Retr(String),
/// `QUIT`.
Quit,
/// Caller-supplied custom FTP command text.
Custom(String),
}
/// FTP session state captured by the protocol layer.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FtpSessionModel {
/// Stable session identifier.
pub session_id: String,
/// Resolved endpoint configuration.
pub config: FtpConfigModel,
/// Optional authenticated credential.
pub auth: Option<AuthCredentialModel>,
/// Default headers propagated into requests.
pub default_headers: Vec<HttpHeader>,
}
/// FTP request envelope passed into a connector.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FtpRequestModel {
/// Command to execute.
pub command: FtpCommandModel,
/// Optional path or target associated with the command.
pub path: Option<String>,
/// Additional logical headers attached to the request.
pub headers: Vec<HttpHeader>,
}
/// FTP response material returned by a connector.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FtpResponseModel {
/// Numeric FTP status code.
pub code: u16,
/// Human-readable server message.
pub message: String,
/// Optional payload bytes such as directory listings or file contents.
pub data: Option<Vec<u8>>,
/// Optional path associated with the response.
pub path: Option<String>,
/// Whether the response can carry transferable data.
pub transferable: bool,
}
@@ -0,0 +1,22 @@
//! HTTP protocol models, transfer state, and checksum helpers.
#![forbid(unsafe_code)]
/// Checksum parsing and validation helpers for HTTP transfers.
mod checksum;
/// Shared HTTP request and response data models.
mod model;
/// Transfer-progress tracking and aggregation helpers.
mod progress;
#[cfg(test)]
mod tests;
pub use self::model::{
AuthChallenge, AuthCredential, AuthScheme, ChecksumHookModel, ChecksumSpec, ContentRangeSpec,
Cookie, HeaderKind, HttpBody, HttpCompletionModel, HttpCompletionState, HttpHeader, HttpMethod,
HttpRequestHeaders, HttpRequestModel, HttpResponseHeaders, HttpResponseModel,
HttpResponseSinkTarget, HttpRetryAttemptDetailModel, HttpSegmentProgressModel,
HttpSessionModel, HttpTransferProgressModel, HttpTransferTaskModel, HttpVersion, ProxyConfig,
RangeSpec, RangeUnit, ResponseBody, ResumeState, RetryAttempt, RetryPolicy, RetryReason,
RetryStrategy, TlsConfig,
};
@@ -0,0 +1,110 @@
use adler2::Adler32;
use crc32fast::Hasher as Crc32Hasher;
use md5::Md5;
use sha1::Sha1;
use sha2::{Digest, Sha224, Sha256, Sha384, Sha512};
use super::model::{ChecksumSpec, ResponseBody};
impl ChecksumSpec {
#[must_use]
/// Returns whether the observed and expected digests match.
pub fn is_verified(&self) -> bool {
self.actual_hex
.as_deref()
.is_some_and(|actual| actual.eq_ignore_ascii_case(&self.expected_hex))
}
#[must_use]
/// Computes the payload digest using the configured algorithm.
pub fn compute_actual_hex(&self, payload: &[u8]) -> Option<String> {
checksum_hex(&self.algorithm, payload)
}
#[must_use]
/// Returns whether `payload` matches the expected digest when supported.
pub fn verify_payload(&self, payload: &[u8]) -> Option<bool> {
self.compute_actual_hex(payload)
.map(|actual| actual.eq_ignore_ascii_case(&self.expected_hex))
}
}
/// Compares a streamed body's observed digest with the expected checksum.
pub(super) fn streamed_checksum_verification(
checksum: &ChecksumSpec,
body: &ResponseBody,
) -> Option<bool> {
match body {
ResponseBody::Streamed {
observed_digest: Some(actual),
..
} => Some(actual.eq_ignore_ascii_case(&checksum.expected_hex)),
_ => None,
}
}
/// Computes a lowercase hexadecimal digest for the requested checksum algorithm.
#[must_use]
pub(super) fn checksum_hex(algorithm: &str, payload: &[u8]) -> Option<String> {
let algorithm = algorithm.trim();
let digest = if matches_checksum_algorithm(algorithm, &["sha1", "sha-1", "sha"]) {
let mut hasher = Sha1::new();
hasher.update(payload);
hasher.finalize().to_vec()
} else if matches_checksum_algorithm(algorithm, &["sha224", "sha-224"]) {
let mut hasher = Sha224::new();
hasher.update(payload);
hasher.finalize().to_vec()
} else if matches_checksum_algorithm(algorithm, &["sha256", "sha-256"]) {
let mut hasher = Sha256::new();
hasher.update(payload);
hasher.finalize().to_vec()
} else if matches_checksum_algorithm(algorithm, &["sha384", "sha-384"]) {
let mut hasher = Sha384::new();
hasher.update(payload);
hasher.finalize().to_vec()
} else if matches_checksum_algorithm(algorithm, &["sha512", "sha-512"]) {
let mut hasher = Sha512::new();
hasher.update(payload);
hasher.finalize().to_vec()
} else if algorithm.eq_ignore_ascii_case("md5") {
let mut hasher = Md5::new();
hasher.update(payload);
hasher.finalize().to_vec()
} else if algorithm.eq_ignore_ascii_case("adler32") {
let mut hasher = Adler32::new();
hasher.write_slice(payload);
hasher.checksum().to_be_bytes().to_vec()
} else if algorithm.eq_ignore_ascii_case("crc32") {
let mut hasher = Crc32Hasher::new();
hasher.update(payload);
hasher.finalize().to_be_bytes().to_vec()
} else {
return None;
};
Some(bytes_to_hex(&digest))
}
/// Returns whether a checksum algorithm matches any accepted spelling.
fn matches_checksum_algorithm(algorithm: &str, accepted: &[&str]) -> bool {
accepted
.iter()
.any(|candidate| algorithm.eq_ignore_ascii_case(candidate))
}
/// Hex-encodes digest bytes using lowercase hexadecimal.
#[must_use]
fn bytes_to_hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(char::from(HEX[usize::from(byte >> 4)]));
out.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
out
}
/// Saturates a `usize` length into `u64`.
pub(super) fn usize_to_u64(value: usize) -> u64 {
u64::try_from(value).unwrap_or(u64::MAX)
}
@@ -0,0 +1,509 @@
use std::{collections::HashMap, path::PathBuf};
pub use crate::auth::{
AuthChallengeModel as AuthChallenge, AuthCredentialModel as AuthCredential, AuthScheme,
};
/// Classifies how one header participates in an HTTP exchange.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HeaderKind {
/// Header belongs to the request.
Request,
/// Header belongs to the response.
Response,
/// Header is valid for both directions.
General,
}
/// One normalized HTTP header field.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpHeader {
/// Lower-level header name.
pub name: String,
/// Raw header value.
pub value: String,
/// Header classification within the exchange.
pub kind: HeaderKind,
}
/// HTTP methods supported by the protocol layer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HttpMethod {
/// `GET`
Get,
/// `HEAD`
Head,
/// `POST`
Post,
/// `PUT`
Put,
/// `DELETE`
Delete,
}
/// HTTP versions surfaced by the transport layer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HttpVersion {
/// HTTP/1.0
Http10,
/// HTTP/1.1
Http11,
/// HTTP/2
Http2,
/// HTTP/3
Http3,
}
/// One request byte or piece range.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RangeSpec {
/// Inclusive start offset.
pub start: u64,
/// Optional inclusive end offset.
pub end_inclusive: Option<u64>,
/// Unit used by the range.
pub unit: RangeUnit,
}
impl RangeSpec {
#[must_use]
/// Returns whether the range omits an explicit end bound.
pub const fn is_open_ended(&self) -> bool {
self.end_inclusive.is_none()
}
#[must_use]
/// Returns the requested length when the end bound is known.
pub fn length_hint(&self) -> Option<u64> {
self.end_inclusive
.map(|end| end.saturating_sub(self.start).saturating_add(1))
}
}
/// Units supported by HTTP-style range models.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RangeUnit {
/// Byte-oriented ranges.
Bytes,
/// Piece-oriented ranges used by higher-level scheduling.
Pieces,
}
/// Parsed `Content-Range` response metadata.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ContentRangeSpec {
/// Unit reported by the server.
pub unit: RangeUnit,
/// Inclusive start offset returned by the server.
pub start: u64,
/// Inclusive end offset returned by the server.
pub end_inclusive: u64,
/// Total object size when known.
pub total_size: Option<u64>,
/// Whether the response represents an unsatisfied range.
pub unsatisfied: bool,
}
impl ContentRangeSpec {
#[must_use]
/// Returns the completed length implied by the range payload.
pub const fn completed_length(&self) -> u64 {
if self.unsatisfied {
0
} else {
self.end_inclusive.saturating_add(1)
}
}
#[must_use]
/// Returns whether the range is explicitly unsatisfied.
pub const fn is_unsatisfied(&self) -> bool {
self.unsatisfied
}
}
/// Resume metadata carried into one HTTP transfer attempt.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ResumeState {
/// Requested starting offset for the retry or resumed request.
pub requested_offset: u64,
/// Offset actually accepted by the remote server.
pub accepted_offset: Option<u64>,
/// Whether the attempt truly resumed instead of restarting from zero.
pub resumed: bool,
}
/// Retry policy knobs applied to HTTP work.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RetryPolicy {
/// Maximum number of attempts.
pub max_attempts: u32,
/// Initial backoff delay in milliseconds.
pub initial_backoff_ms: u64,
/// Maximum backoff delay in milliseconds.
pub max_backoff_ms: u64,
/// Whether `3xx` responses are retryable.
pub retry_on_3xx: bool,
/// Whether `4xx` responses are retryable.
pub retry_on_4xx: bool,
/// Whether `5xx` responses are retryable.
pub retry_on_5xx: bool,
/// Whether transport-level network errors are retryable.
pub retry_on_network_error: bool,
/// Whether timeout failures are retryable.
pub retry_on_timeout: bool,
}
/// Fully-resolved retry behavior for one request.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RetryStrategy {
/// Base retry policy.
pub policy: RetryPolicy,
/// Optional jitter value in milliseconds.
pub jitter: Option<u64>,
/// Optional upper bound on total retry elapsed time in milliseconds.
pub max_elapsed_ms: Option<u64>,
}
/// Normalized reasons for retrying one transfer attempt.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RetryReason {
/// A transport-level network failure occurred.
NetworkError,
/// The request timed out.
Timeout,
/// The server responded with a retryable `3xx`.
Http3xx,
/// The server responded with a retryable `4xx`.
Http4xx,
/// The server responded with a retryable `5xx`.
Http5xx,
/// Partial-content semantics did not match the requested resume state.
PartialContentMismatch,
/// Another retryable condition occurred.
Other,
}
/// One recorded retry attempt.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RetryAttempt {
/// Attempt number starting at one.
pub attempt: u32,
/// Retry reason for the attempt.
pub reason: RetryReason,
/// Optional HTTP status observed during the attempt.
pub status: Option<u16>,
/// Optional backoff delay in milliseconds before the next attempt.
pub backoff_ms: Option<u64>,
}
/// Final or intermediate completion state for one HTTP response.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HttpCompletionState {
/// Response is not yet complete.
Incomplete,
/// Response is usable but only partial.
Partial,
/// Response is complete without checksum verification.
Complete,
/// Response is complete and checksum-verified.
Verified,
}
/// Derived completion summary for one HTTP response.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct HttpCompletionModel {
/// Overall completion state.
pub state: HttpCompletionState,
/// Total payload length when known.
pub total_length: Option<u64>,
/// Number of completed bytes.
pub completed_length: u64,
/// Whether the response used partial-content semantics.
pub partial_content: bool,
/// Whether the status code indicates terminal success.
pub terminal_success: bool,
/// Whether checksum metadata was present.
pub checksum_seen: bool,
/// Whether the checksum could be verified successfully.
pub checksum_verified: bool,
}
/// Segment-level progress view for one transfer snapshot.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct HttpSegmentProgressModel {
/// Range originally requested from the server.
pub requested_range: Option<RangeSpec>,
/// Requested starting offset.
pub requested_offset: u64,
/// Offset accepted by the server when present.
pub accepted_offset: Option<u64>,
/// Completed offset derived from the current response.
pub completed_offset: Option<u64>,
/// Whether the transfer is actively resuming instead of restarting.
pub resumed: bool,
}
/// Retry-attempt detail enriched with segment and resume context.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct HttpRetryAttemptDetailModel {
/// Base retry-attempt data.
pub base: RetryAttempt,
/// Range requested for the attempt.
pub requested_range: Option<RangeSpec>,
/// Requested starting offset for the attempt.
pub requested_offset: u64,
/// Offset accepted by the server when present.
pub accepted_offset: Option<u64>,
/// Resume metadata captured for the attempt.
pub resume_state: Option<ResumeState>,
}
/// Snapshot of one in-flight or completed HTTP transfer.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpTransferProgressModel {
/// Stable task identifier.
pub task_id: String,
/// Request model associated with the transfer.
pub request: HttpRequestModel,
/// Segment-level progress details.
pub segment: HttpSegmentProgressModel,
/// Retry-attempt history with contextual detail.
pub retry_attempts: Vec<HttpRetryAttemptDetailModel>,
/// Maximum number of concurrent connections permitted for the task.
pub max_connections: u16,
/// Optional checksum hook attached to the transfer.
pub checksum_hook: Option<ChecksumHookModel>,
/// Derived completion summary when a response exists.
pub completion: Option<HttpCompletionModel>,
}
/// Proxy configuration projected into HTTP requests.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProxyConfig {
/// Proxy scheme such as `http` or `socks5`.
pub scheme: String,
/// Proxy host name or IP.
pub host: String,
/// Proxy port.
pub port: u16,
/// Optional proxy username.
pub username: Option<String>,
/// Optional proxy password.
pub password: Option<String>,
/// Hosts that should bypass the proxy.
pub bypass_hosts: Vec<String>,
/// Whether proxying is disabled for the request.
pub no_proxy: bool,
}
/// One normalized HTTP cookie.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Cookie {
/// Cookie name.
pub name: String,
/// Cookie value.
pub value: String,
/// Optional domain constraint.
pub domain: Option<String>,
/// Optional path constraint.
pub path: Option<String>,
/// Whether the cookie requires a secure transport.
pub secure: bool,
/// Whether the cookie is `HttpOnly`.
pub http_only: bool,
/// Optional same-site policy marker.
pub same_site: Option<String>,
/// Expiration time as a Unix timestamp when present.
pub expires_unix_epoch: Option<u64>,
}
/// TLS behavior attached to one HTTP session.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TlsConfig {
/// Whether peer certificates must be verified.
pub verify_peer: bool,
/// Whether host name verification is enabled.
pub verify_host: bool,
/// Minimum TLS version when constrained.
pub min_version: Option<String>,
/// Maximum TLS version when constrained.
pub max_version: Option<String>,
/// Optional CA bundle path.
pub ca_file: Option<String>,
/// Optional client certificate path.
pub cert_file: Option<String>,
/// Optional client key path.
pub key_file: Option<String>,
}
/// Ordered collection of request headers.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpRequestHeaders {
/// Stored request headers.
pub headers: Vec<HttpHeader>,
}
/// Ordered collection of response headers.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpResponseHeaders {
/// Stored response headers.
pub headers: Vec<HttpHeader>,
}
/// Request-body representation for HTTP transfers.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum HttpBody {
/// No request body.
Empty,
/// UTF-8 text request body.
Text(String),
/// Arbitrary binary request body.
Binary(Vec<u8>),
/// Streaming body with an optional declared length.
Stream {
/// Declared body length when the caller knows it.
expected_len: Option<u64>,
},
}
/// Expected and observed checksum metadata for one payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ChecksumSpec {
/// Hash algorithm name.
pub algorithm: String,
/// Expected digest hex string.
pub expected_hex: String,
/// Observed digest hex string when known.
pub actual_hex: Option<String>,
}
/// Optional checksum hook attached to a transfer.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ChecksumHookModel {
/// Checksum specification to evaluate.
pub spec: ChecksumSpec,
/// Whether the hook is enabled.
pub enabled: bool,
}
/// Optional direct-write target for one live HTTP response body.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpResponseSinkTarget {
/// Final output path that should receive the response body directly.
pub target_path: PathBuf,
}
/// Fully normalized HTTP request model.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpRequestModel {
/// HTTP method.
pub method: HttpMethod,
/// Fully-qualified request URL.
pub url: String,
/// Requested HTTP version.
pub version: HttpVersion,
/// Explicit request headers.
pub headers: HttpRequestHeaders,
/// Query parameters to attach to the URL.
pub query: HashMap<String, String>,
/// Optional range metadata.
pub range: Option<RangeSpec>,
/// Request body.
pub body: HttpBody,
/// Retry strategy for the request.
pub retry: RetryStrategy,
/// Optional origin credential.
pub auth: Option<AuthCredential>,
/// Optional proxy configuration.
pub proxy: Option<ProxyConfig>,
/// Optional direct-write sink for live response persistence.
pub response_sink: Option<HttpResponseSinkTarget>,
}
/// Session-scoped defaults that shape HTTP execution.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpSessionModel {
/// Stable session identifier.
pub session_id: String,
/// Optional user-agent string.
pub user_agent: Option<String>,
/// Default headers applied to requests.
pub default_headers: Vec<HttpHeader>,
/// Cookies carried by the session.
pub cookies: Vec<Cookie>,
/// Optional default credential.
pub auth: Option<AuthCredential>,
/// Optional default proxy configuration.
pub proxy: Option<ProxyConfig>,
/// Optional TLS behavior for the session.
pub tls: Option<TlsConfig>,
/// Default retry strategy for the session.
pub retry: RetryStrategy,
}
/// Response-body representation used by the protocol layer.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ResponseBody {
/// No response payload.
Empty,
/// Inline retained payload bytes.
Inline(Vec<u8>),
/// Streamed payload metadata with optional retained artifacts.
Streamed {
/// Declared content length when known.
expected_len: Option<u64>,
/// Observed byte count written through the sink.
observed_len: Option<u64>,
/// Observed digest when computed by the sink.
observed_digest: Option<String>,
/// Optional temporary file path holding the streamed body.
temp_path: Option<PathBuf>,
},
}
/// Executable HTTP transfer task passed into downloaders.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpTransferTaskModel {
/// Stable task identifier.
pub task_id: String,
/// Request model for the task.
pub request: HttpRequestModel,
/// Response headers already associated with the task.
pub response_headers: HttpResponseHeaders,
/// Current response body state.
pub body: ResponseBody,
/// Resume metadata when resuming is in play.
pub resume_state: Option<ResumeState>,
/// Retry-attempt history.
pub retry_attempts: Vec<RetryAttempt>,
/// Optional checksum hook.
pub checksum_hook: Option<ChecksumHookModel>,
/// Maximum allowed concurrent connections.
pub max_connections: u16,
/// Retry strategy for the task.
pub retry: RetryStrategy,
}
/// Normalized HTTP response model produced by connectors and fixtures.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpResponseModel {
/// Numeric HTTP status code.
pub status: u16,
/// Human-readable reason phrase.
pub reason: String,
/// Negotiated HTTP version.
pub version: HttpVersion,
/// Response headers.
pub headers: HttpResponseHeaders,
/// Response body representation.
pub body: ResponseBody,
/// Parsed `Content-Range` metadata when present.
pub content_range: Option<ContentRangeSpec>,
/// Whether the response used partial-content semantics.
pub partial_content: bool,
/// Optional checksum metadata.
pub checksum: Option<ChecksumSpec>,
/// Original URL before redirects when one occurred.
pub redirected_from: Option<String>,
}
@@ -0,0 +1,177 @@
use super::{
checksum::{streamed_checksum_verification, usize_to_u64},
model::{
HttpCompletionModel, HttpCompletionState, HttpResponseModel, HttpRetryAttemptDetailModel,
HttpSegmentProgressModel, HttpTransferProgressModel, HttpTransferTaskModel, ResponseBody,
},
};
impl HttpTransferTaskModel {
#[must_use]
/// Returns the effective requested offset for the task.
pub fn requested_offset(&self) -> u64 {
self.resume_state
.as_ref()
.map(|state| state.requested_offset)
.or_else(|| self.request.range.as_ref().map(|range| range.start))
.unwrap_or_default()
}
#[must_use]
/// Returns retry attempts enriched with resume and range context.
pub fn retry_attempt_details(&self) -> Vec<HttpRetryAttemptDetailModel> {
let requested_offset = self.requested_offset();
let requested_range = self.request.range;
let accepted_offset = self
.resume_state
.as_ref()
.and_then(|state| state.accepted_offset);
let resume_state = self.resume_state;
self.retry_attempts
.iter()
.copied()
.map(|base| HttpRetryAttemptDetailModel {
base,
requested_range,
requested_offset,
accepted_offset,
resume_state,
})
.collect()
}
#[must_use]
/// Builds a progress snapshot from the current task and optional response.
pub fn progress_snapshot(
&self,
response: Option<&HttpResponseModel>,
) -> HttpTransferProgressModel {
let completion = response.map(HttpResponseModel::completion_model);
let completed_offset = response.map(HttpResponseModel::completed_length);
let (accepted_offset, resumed) = response.map_or_else(
|| {
(
self.resume_state
.as_ref()
.and_then(|state| state.accepted_offset),
self.resume_state
.as_ref()
.is_some_and(|state| state.resumed),
)
},
|response| {
if response.status == 206 {
let accepted_offset = response
.content_range
.as_ref()
.and_then(|range| (!range.is_unsatisfied()).then_some(range.start));
(
accepted_offset,
accepted_offset.is_some()
|| self
.resume_state
.as_ref()
.is_some_and(|state| state.resumed),
)
} else {
(None, false)
}
},
);
HttpTransferProgressModel {
task_id: self.task_id.clone(),
request: self.request.clone(),
segment: HttpSegmentProgressModel {
requested_range: self.request.range,
requested_offset: self.requested_offset(),
accepted_offset,
completed_offset,
resumed,
},
retry_attempts: self.retry_attempt_details(),
max_connections: self.max_connections,
checksum_hook: self.checksum_hook.clone(),
completion,
}
}
}
impl HttpResponseModel {
#[must_use]
/// Returns the total payload length when the response exposes it.
pub fn total_length(&self) -> Option<u64> {
self.content_range
.as_ref()
.and_then(|range| range.total_size)
.or_else(|| match &self.body {
ResponseBody::Inline(bytes) => Some(usize_to_u64(bytes.len())),
ResponseBody::Streamed {
expected_len,
observed_len,
..
} => expected_len.or(*observed_len),
ResponseBody::Empty => None,
})
}
#[must_use]
/// Returns the completed payload length represented by the response.
pub fn completed_length(&self) -> u64 {
self.content_range
.as_ref()
.map(super::model::ContentRangeSpec::completed_length)
.or_else(|| match &self.body {
ResponseBody::Inline(bytes) => Some(usize_to_u64(bytes.len())),
ResponseBody::Streamed { observed_len, .. } => *observed_len,
ResponseBody::Empty => Some(0),
})
.unwrap_or_default()
}
#[must_use]
/// Returns inline body bytes when they are retained in memory.
pub fn body_bytes(&self) -> Option<&[u8]> {
match &self.body {
ResponseBody::Empty => Some(&[]),
ResponseBody::Inline(bytes) => Some(bytes),
ResponseBody::Streamed { .. } => None,
}
}
#[must_use]
/// Derives a completion summary from the response payload and metadata.
pub fn completion_model(&self) -> HttpCompletionModel {
let total_length = self.total_length();
let completed_length = self.completed_length();
let checksum_seen = self.checksum.is_some();
let checksum_verified = self.checksum.as_ref().is_some_and(|checksum| {
self.body_bytes()
.and_then(|bytes| checksum.verify_payload(bytes))
.or_else(|| streamed_checksum_verification(checksum, &self.body))
.unwrap_or_else(|| checksum.is_verified())
});
let terminal_success = (200..300).contains(&self.status);
let complete_enough = total_length.is_none_or(|total| completed_length >= total);
let state = if !terminal_success {
HttpCompletionState::Incomplete
} else if checksum_verified && complete_enough {
HttpCompletionState::Verified
} else if complete_enough {
HttpCompletionState::Complete
} else {
HttpCompletionState::Partial
};
HttpCompletionModel {
state,
total_length,
completed_length,
partial_content: self.partial_content,
terminal_success,
checksum_seen,
checksum_verified,
}
}
}
@@ -0,0 +1,489 @@
use std::collections::HashMap;
use super::*;
fn sample_retry_strategy() -> RetryStrategy {
RetryStrategy {
policy: RetryPolicy {
max_attempts: 5,
initial_backoff_ms: 100,
max_backoff_ms: 5_000,
retry_on_3xx: false,
retry_on_4xx: false,
retry_on_5xx: true,
retry_on_network_error: true,
retry_on_timeout: true,
},
jitter: Some(25),
max_elapsed_ms: Some(60_000),
}
}
fn sample_request() -> HttpRequestModel {
HttpRequestModel {
method: HttpMethod::Get,
url: "https://example.invalid/file.bin".to_string(),
version: HttpVersion::Http11,
headers: HttpRequestHeaders { headers: vec![] },
query: HashMap::new(),
range: Some(RangeSpec {
start: 4096,
end_inclusive: None,
unit: RangeUnit::Bytes,
}),
body: HttpBody::Empty,
retry: sample_retry_strategy(),
auth: None,
proxy: None,
response_sink: None,
}
}
#[test]
fn response_model_carries_partial_content_and_content_range() {
let response = HttpResponseModel {
status: 206,
reason: "Partial Content".to_string(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Streamed {
expected_len: Some(1024),
observed_len: Some(1024),
observed_digest: None,
temp_path: None,
},
content_range: Some(ContentRangeSpec {
unit: RangeUnit::Bytes,
start: 4096,
end_inclusive: 5119,
total_size: Some(10_000),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
};
assert!(response.partial_content);
assert_eq!(
response.content_range,
Some(ContentRangeSpec {
unit: RangeUnit::Bytes,
start: 4096,
end_inclusive: 5119,
total_size: Some(10_000),
unsatisfied: false,
})
);
}
#[test]
fn response_completion_model_distinguishes_partial_and_verified() {
let partial = HttpResponseModel {
status: 206,
reason: "Partial Content".to_string(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Inline(b"12345".to_vec()),
content_range: Some(ContentRangeSpec {
unit: RangeUnit::Bytes,
start: 0,
end_inclusive: 4,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
};
let verified = HttpResponseModel {
status: 200,
reason: "OK".to_string(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Inline(b"abc".to_vec()),
content_range: None,
partial_content: false,
checksum: Some(ChecksumSpec {
algorithm: "sha-1".to_string(),
expected_hex: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string(),
actual_hex: None,
}),
redirected_from: None,
};
assert_eq!(
partial.completion_model().state,
HttpCompletionState::Partial
);
assert_eq!(partial.completion_model().completed_length, 5);
assert_eq!(
verified.completion_model().state,
HttpCompletionState::Verified
);
assert!(verified.completion_model().checksum_verified);
}
#[test]
fn range_rejection_keeps_total_length_truth_without_reporting_progress() {
let response = HttpResponseModel {
status: 416,
reason: "Range Not Satisfiable".to_string(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Empty,
content_range: Some(ContentRangeSpec {
unit: RangeUnit::Bytes,
start: 0,
end_inclusive: 0,
total_size: Some(8192),
unsatisfied: true,
}),
partial_content: false,
checksum: None,
redirected_from: None,
};
let completion = response.completion_model();
assert_eq!(completion.total_length, Some(8192));
assert_eq!(completion.completed_length, 0);
assert_eq!(completion.state, HttpCompletionState::Incomplete);
}
#[test]
fn checksum_verification_uses_inline_payload_bytes() {
let checksum = ChecksumSpec {
algorithm: "sha-256".to_string(),
expected_hex: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
.to_string(),
actual_hex: None,
};
assert_eq!(checksum.verify_payload(b"abc"), Some(true));
assert_eq!(checksum.verify_payload(b"abcd"), Some(false));
}
#[test]
fn streamed_response_completion_uses_observed_length_and_digest() {
let response = HttpResponseModel {
status: 200,
reason: "OK".to_string(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Streamed {
expected_len: Some(12),
observed_len: Some(12),
observed_digest: Some("9251ad9cddb52f55d2c6b96280c781e7".to_string()),
temp_path: None,
},
content_range: None,
partial_content: false,
checksum: Some(ChecksumSpec {
algorithm: "md5".to_string(),
expected_hex: "9251ad9cddb52f55d2c6b96280c781e7".to_string(),
actual_hex: None,
}),
redirected_from: None,
};
let completion = response.completion_model();
assert_eq!(completion.completed_length, 12);
assert_eq!(completion.total_length, Some(12));
assert_eq!(completion.state, HttpCompletionState::Verified);
assert!(completion.checksum_verified);
}
#[test]
fn streamed_completion_uses_observed_len_without_expected_len() {
let response = HttpResponseModel {
status: 200,
reason: "OK".to_string(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Streamed {
expected_len: None,
observed_len: Some(4096),
observed_digest: None,
temp_path: None,
},
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
};
let completion = response.completion_model();
assert_eq!(completion.total_length, Some(4096));
assert_eq!(completion.completed_length, 4096);
assert_eq!(completion.state, HttpCompletionState::Complete);
}
#[test]
fn streamed_completion_does_not_treat_expected_len_as_completed_len() {
let response = HttpResponseModel {
status: 200,
reason: "OK".to_string(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Streamed {
expected_len: Some(4096),
observed_len: None,
observed_digest: None,
temp_path: None,
},
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
};
let completion = response.completion_model();
assert_eq!(completion.total_length, Some(4096));
assert_eq!(completion.completed_length, 0);
assert_eq!(completion.state, HttpCompletionState::Partial);
}
#[test]
fn streamed_checksum_verifies_from_observed_digest_without_inline_body() {
let response = HttpResponseModel {
status: 200,
reason: "OK".to_string(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Streamed {
expected_len: None,
observed_len: Some(128),
observed_digest: Some(
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad".to_string(),
),
temp_path: None,
},
content_range: None,
partial_content: false,
checksum: Some(ChecksumSpec {
algorithm: "sha-256".to_string(),
expected_hex: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
.to_string(),
actual_hex: None,
}),
redirected_from: None,
};
let completion = response.completion_model();
assert!(completion.checksum_seen);
assert!(completion.checksum_verified);
assert_eq!(completion.state, HttpCompletionState::Verified);
}
#[test]
fn transfer_task_tracks_resume_offsets() {
let task = HttpTransferTaskModel {
task_id: "task-resume-1".to_string(),
request: sample_request(),
response_headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Streamed {
expected_len: None,
observed_len: None,
observed_digest: None,
temp_path: None,
},
resume_state: Some(ResumeState {
requested_offset: 8192,
accepted_offset: Some(8192),
resumed: true,
}),
retry_attempts: vec![],
checksum_hook: None,
max_connections: 4,
retry: sample_retry_strategy(),
};
let resume_state = task.resume_state.expect("resume state should exist");
assert!(resume_state.resumed);
assert_eq!(resume_state.requested_offset, 8192);
assert_eq!(resume_state.accepted_offset, Some(8192));
}
#[test]
fn transfer_task_tracks_retry_attempt_history() {
let task = HttpTransferTaskModel {
task_id: "task-retry-1".to_string(),
request: sample_request(),
response_headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Streamed {
expected_len: None,
observed_len: None,
observed_digest: None,
temp_path: None,
},
resume_state: None,
retry_attempts: vec![
RetryAttempt {
attempt: 1,
reason: RetryReason::Timeout,
status: None,
backoff_ms: Some(100),
},
RetryAttempt {
attempt: 2,
reason: RetryReason::Http5xx,
status: Some(503),
backoff_ms: Some(250),
},
],
checksum_hook: None,
max_connections: 4,
retry: sample_retry_strategy(),
};
assert_eq!(task.retry_attempts.len(), 2);
assert_eq!(task.retry_attempts[0].reason, RetryReason::Timeout);
assert_eq!(task.retry_attempts[1].status, Some(503));
assert_eq!(task.retry_attempts[1].backoff_ms, Some(250));
}
#[test]
fn transfer_task_progress_snapshot_tracks_segment_progress_and_retry_context() {
let request = HttpRequestModel {
method: HttpMethod::Get,
url: "https://example.invalid/segment.bin".to_string(),
version: HttpVersion::Http11,
headers: HttpRequestHeaders { headers: vec![] },
query: HashMap::new(),
range: Some(RangeSpec {
start: 8192,
end_inclusive: Some(12_287),
unit: RangeUnit::Bytes,
}),
body: HttpBody::Empty,
retry: sample_retry_strategy(),
auth: None,
proxy: None,
response_sink: None,
};
let task = HttpTransferTaskModel {
task_id: "task-progress-1".to_string(),
request,
response_headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Streamed {
expected_len: None,
observed_len: None,
observed_digest: None,
temp_path: None,
},
resume_state: Some(ResumeState {
requested_offset: 8192,
accepted_offset: Some(8192),
resumed: true,
}),
retry_attempts: vec![
RetryAttempt {
attempt: 1,
reason: RetryReason::Timeout,
status: None,
backoff_ms: Some(100),
},
RetryAttempt {
attempt: 2,
reason: RetryReason::Http5xx,
status: Some(503),
backoff_ms: Some(250),
},
],
checksum_hook: Some(ChecksumHookModel {
spec: ChecksumSpec {
algorithm: "sha-256".to_string(),
expected_hex: "abc123".to_string(),
actual_hex: None,
},
enabled: true,
}),
max_connections: 4,
retry: sample_retry_strategy(),
};
let response = HttpResponseModel {
status: 206,
reason: "Partial Content".to_string(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Inline(b"abcdefghijkl".to_vec()),
content_range: Some(ContentRangeSpec {
unit: RangeUnit::Bytes,
start: 8192,
end_inclusive: 12_203,
total_size: Some(16_384),
unsatisfied: false,
}),
partial_content: true,
checksum: Some(ChecksumSpec {
algorithm: "sha-256".to_string(),
expected_hex: "abc123".to_string(),
actual_hex: Some("abc123".to_string()),
}),
redirected_from: None,
};
let progress = task.progress_snapshot(Some(&response));
assert_eq!(progress.task_id, "task-progress-1");
assert_eq!(progress.segment.requested_offset, 8192);
assert_eq!(progress.segment.accepted_offset, Some(8192));
assert_eq!(progress.segment.completed_offset, Some(12_204));
assert!(progress.segment.resumed);
assert_eq!(progress.retry_attempts.len(), 2);
assert_eq!(progress.retry_attempts[0].requested_offset, 8192);
assert_eq!(progress.retry_attempts[1].accepted_offset, Some(8192));
assert_eq!(
progress
.completion
.as_ref()
.expect("completion should be present")
.state,
HttpCompletionState::Partial
);
}
#[test]
fn progress_snapshot_clears_resume_truth_when_server_ignores_requested_range() {
let task = HttpTransferTaskModel {
task_id: "task-progress-range-ignored".to_string(),
request: sample_request(),
response_headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Empty,
resume_state: Some(ResumeState {
requested_offset: 4096,
accepted_offset: Some(4096),
resumed: true,
}),
retry_attempts: vec![],
checksum_hook: None,
max_connections: 1,
retry: sample_retry_strategy(),
};
let response = HttpResponseModel {
status: 200,
reason: "OK".to_string(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Inline(vec![b'x'; 16_384]),
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
};
let progress = task.progress_snapshot(Some(&response));
assert_eq!(progress.segment.requested_offset, 4096);
assert_eq!(progress.segment.accepted_offset, None);
assert_eq!(progress.segment.completed_offset, Some(16_384));
assert!(!progress.segment.resumed);
assert_eq!(
progress
.completion
.as_ref()
.expect("completion should exist")
.state,
HttpCompletionState::Complete
);
}
+134
View File
@@ -0,0 +1,134 @@
//! Protocol-layer models and helpers for the `aria2-rust-pro` workspace.
//!
//! This crate centralizes transport-facing request/response types plus parser
//! and serialization helpers shared by higher-level crates.
#![forbid(unsafe_code)]
#![expect(
clippy::arithmetic_side_effects,
clippy::indexing_slicing,
clippy::integer_division,
clippy::missing_const_for_fn,
clippy::missing_errors_doc,
clippy::module_name_repetitions,
clippy::multiple_crate_versions,
clippy::needless_pass_by_value,
clippy::result_large_err,
clippy::struct_excessive_bools,
reason = "protocol models intentionally mirror aria2 wire/config semantics where strict style lints obscure compatibility"
)]
/// Authentication challenge and credential models.
pub mod auth;
/// BitTorrent-facing re-exports and compatibility aliases.
pub mod bt;
/// Compatibility wrappers that bridge BitTorrent, magnet, and Metalink models.
pub mod bt_metalink;
/// Downloader traits and transport-backed implementations.
pub mod downloader;
/// FTP protocol request, response, and configuration models.
pub mod ftp;
/// HTTP protocol models, transfer state, and checksum helpers.
pub mod http;
/// Magnet URI parsing and serialization helpers.
pub mod magnet;
/// Metalink document parsing and resource selection helpers.
pub mod metalink;
/// Session-scoped transport and preference models.
pub mod session;
/// SFTP protocol request, response, and configuration models.
pub mod sftp;
/// Torrent metadata, peer-wire, and DHT message models.
pub mod torrent;
/// Tracker and DHT request parsing plus transport helpers.
pub mod tracker;
/// Generic transport connector abstractions and error models.
pub mod transport;
/// Logical protocol families recognized by the protocol layer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Protocol {
/// Plain HTTP transfers.
Http,
/// HTTP transfers over TLS.
Https,
/// FTP transfers.
Ftp,
/// SFTP transfers over SSH.
Sftp,
/// Metalink document processing.
Metalink,
/// `.torrent`-backed `BitTorrent` transfers.
BitTorrent,
/// Magnet URI bootstraps for `BitTorrent` transfers.
Magnet,
/// Local file inputs.
File,
}
impl Protocol {
/// Returns the canonical lowercase protocol name used in serialized forms.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Http => "http",
Self::Https => "https",
Self::Ftp => "ftp",
Self::Sftp => "sftp",
Self::Metalink => "metalink",
Self::BitTorrent => "bittorrent",
Self::Magnet => "magnet",
Self::File => "file",
}
}
}
pub use auth::{AuthChallengeModel, AuthCredentialModel, AuthScheme};
pub use bt_metalink::{
BtMetalinkError, MagnetUri, MetalinkDocument, ParserStatus, ProtocolSupportMatrix,
ProtocolSupportState, TorrentMetadata, TorrentMetadataError, protocol_support_matrix,
};
pub use downloader::{
AuthProvider, ChecksumVerifier, Downloader, FixtureHttpDownloader, FtpConnector, HttpConnector,
HttpOnlyDownloader, HttpsConnector, MetalinkConnector, ReqwestHttpConnector,
RetryStrategyProvider, SftpConnector, TorrentConnector,
};
pub use ftp::{
FtpCommandModel, FtpConfigModel, FtpMode, FtpRequestModel, FtpResponseModel, FtpSessionModel,
};
pub use http::{
ChecksumHookModel, ChecksumSpec, ContentRangeSpec, Cookie, HeaderKind, HttpBody,
HttpCompletionState, HttpHeader, HttpMethod, HttpRequestHeaders, HttpRequestModel,
HttpResponseHeaders, HttpResponseModel, HttpSessionModel, HttpTransferTaskModel, HttpVersion,
ProxyConfig, RangeSpec, RangeUnit, ResponseBody, ResumeState, RetryAttempt, RetryPolicy,
RetryReason, RetryStrategy, TlsConfig,
};
pub use magnet::{MagnetMetadataModel, MagnetUriModel};
pub use metalink::{
MetalinkChecksumModel, MetalinkDocumentModel, MetalinkFileModel, MetalinkParseResult,
MetalinkParserModel, MetalinkResourceModel, metalink_download_plan, parse_metalink_document,
preferred_download_candidate, preferred_resource_for_file,
};
pub use session::{
ClientModel, ServerModel, ServerSessionModel, SessionLimits, SessionModel, SessionScope,
SessionState, SessionTransportPreference,
};
pub use sftp::{
SftpCommandModel, SftpConfigModel, SftpRequestModel, SftpResponseModel, SftpSessionModel,
};
pub use torrent::{
DhtMessageModel, PeerWireMessageModel, TorrentFileEntryModel, TorrentHashModel,
TorrentInfoModel, TorrentMessageModel, TorrentMetadataModel, TorrentPeerModel,
TorrentPieceModel, TorrentTrackerModel, parse_torrent_metadata,
};
pub use tracker::{
DhtNodeModel, DhtTransport, ReqwestTrackerTransport, TrackerParseError, TrackerPeerListModel,
TrackerRequestModel, TrackerResponseModel, TrackerScrapeFileModel, TrackerScrapeModel,
TrackerTransport,
};
pub use transport::{
PeerWireTransportConnector, PeerWireTransportRequest, PeerWireTransportResponse,
StdDhtTransport, StdTcpPeerWireTransportConnector, StdUdpTransportConnector, TransportBody,
TransportConnector, TransportEndpoint, TransportError, TransportErrorContext,
TransportErrorKind, TransportRequest, TransportResponse, TransportResult, TransportScheme,
TransportStream, UdpTransportConnector, UdpTransportRequest, UdpTransportResponse,
};
@@ -0,0 +1,584 @@
//! Magnet URI parsing and serialization helpers.
#![forbid(unsafe_code)]
use crate::bt_metalink::BtMetalinkError;
use crate::{
torrent::{PeerWireExtensionHandshakeModel, TorrentPeerModel, TorrentTrackerModel},
tracker::DhtNodeModel,
};
/// Parsed representation of a magnet URI.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MagnetUriModel {
/// `BitTorrent` info-hash extracted from `xt=urn:btih:...`.
pub info_hash: String,
/// Optional display name from `dn=`.
pub display_name: Option<String>,
/// Tracker URLs from `tr=`.
pub trackers: Vec<String>,
/// Web-seed URLs from `ws=`.
pub web_seeds: Vec<String>,
/// Optional exact-topic or keyword field from `kt=`/`x.pe=`.
pub exact_topic: Option<String>,
}
/// Higher-level magnet metadata used by callers that already know payload size.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MagnetMetadataModel {
/// Canonical parsed URI.
pub uri: MagnetUriModel,
/// Known payload length when available.
pub known_length: Option<u64>,
/// Additional origin or source descriptors.
pub sources: Vec<String>,
}
/// Fully-shaped magnet bootstrap data ready for CLI or dispatcher orchestration.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MagnetBootstrapModel {
/// Canonical parsed magnet URI model.
pub uri: MagnetUriModel,
/// Lowercase hexadecimal 20-byte `BitTorrent` info-hash.
pub info_hash_hex: String,
/// Raw 20-byte `BitTorrent` info-hash.
pub info_hash_bytes: [u8; 20],
/// Tracker rows shaped with stable tier indices.
pub trackers: Vec<TorrentTrackerModel>,
/// Parsed peer endpoints from repeated `x.pe=` hints.
pub peer_hints: Vec<TorrentPeerModel>,
/// The same `x.pe=` hints re-shaped as DHT/bootstrap nodes.
pub peer_hint_nodes: Vec<DhtNodeModel>,
}
impl MagnetUriModel {
/// Parses a magnet URI into the protocol model.
///
/// # Errors
///
/// Returns an error when the input is not a valid magnet URI.
pub fn from_uri(input: &str) -> Result<Self, BtMetalinkError> {
parse_magnet_uri(input)
}
/// Returns the number of embedded tracker URLs.
#[must_use]
pub fn tracker_count(&self) -> usize {
self.trackers.len()
}
/// Decodes the magnet `btih` token into its raw 20-byte info-hash.
///
/// # Errors
///
/// Returns an error when the magnet does not contain a valid hexadecimal or base32 BTIH.
pub fn info_hash_bytes(&self) -> Result<[u8; 20], BtMetalinkError> {
decode_btih_token(&self.info_hash)
}
/// Returns the info-hash normalized to lowercase hexadecimal.
///
/// # Errors
///
/// Returns an error when the stored BTIH token is not a valid `BitTorrent` info-hash.
pub fn canonical_info_hash_hex(&self) -> Result<String, BtMetalinkError> {
self.info_hash_bytes().map(|hash| hex_encode_lower(&hash))
}
/// Shapes tracker URLs into stable tier-indexed tracker rows.
#[must_use]
pub fn tracker_models(&self) -> Vec<TorrentTrackerModel> {
self.trackers
.iter()
.enumerate()
.map(|(index, tracker)| TorrentTrackerModel {
url: tracker.clone(),
tier: Some(u32::try_from(index).unwrap_or(u32::MAX)),
id: None,
seeders: None,
leechers: None,
})
.collect()
}
/// Builds orchestration-oriented bootstrap data from the parsed magnet model.
///
/// This variant only uses data preserved by [`MagnetUriModel`], so peer hints are empty.
///
/// # Errors
///
/// Returns an error when the stored BTIH token is invalid.
pub fn bootstrap(&self) -> Result<MagnetBootstrapModel, BtMetalinkError> {
Ok(MagnetBootstrapModel {
uri: self.clone(),
info_hash_hex: self.canonical_info_hash_hex()?,
info_hash_bytes: self.info_hash_bytes()?,
trackers: self.tracker_models(),
peer_hints: Vec::new(),
peer_hint_nodes: Vec::new(),
})
}
/// Serializes the model back into a magnet URI string.
#[must_use]
pub fn to_uri(&self) -> String {
let mut query = Vec::new();
query.push(format!(
"xt={}",
percent_encode_query_value(&format!("urn:btih:{}", self.info_hash))
));
if let Some(display_name) = &self.display_name {
query.push(format!("dn={}", percent_encode_query_value(display_name)));
}
for tracker in &self.trackers {
query.push(format!("tr={}", percent_encode_query_value(tracker)));
}
for web_seed in &self.web_seeds {
query.push(format!("ws={}", percent_encode_query_value(web_seed)));
}
if let Some(exact_topic) = &self.exact_topic {
query.push(format!("kt={}", percent_encode_query_value(exact_topic)));
}
format!("magnet:?{}", query.join("&"))
}
}
impl MagnetMetadataModel {
/// Wraps a parsed URI together with an optional known payload length.
#[must_use]
pub fn from_uri(uri: MagnetUriModel, known_length: Option<u64>) -> Self {
Self {
uri,
known_length,
sources: Vec::new(),
}
}
/// Updates the known metadata length from an extended handshake when advertised.
pub fn apply_extension_handshake(&mut self, handshake: &PeerWireExtensionHandshakeModel) {
if let Some(metadata_size) = handshake.metadata_size {
self.known_length = Some(u64::from(metadata_size));
}
}
/// Serializes the wrapped URI back into a magnet string.
#[must_use]
pub fn to_uri(&self) -> String {
self.uri.to_uri()
}
}
/// Parses a magnet URI string into a [`MagnetUriModel`].
///
/// # Errors
///
/// Returns an error when the URI is missing the `magnet:?` prefix or a valid
/// `xt=urn:btih:<hash>` entry.
pub fn parse_magnet_uri(input: &str) -> Result<MagnetUriModel, BtMetalinkError> {
parse_magnet_fields(input).map(|fields| MagnetUriModel {
info_hash: fields.info_hash,
display_name: fields.display_name,
trackers: fields.trackers,
web_seeds: fields.web_seeds,
exact_topic: fields
.keyword_topic
.or_else(|| fields.peer_hints.last().cloned()),
})
}
/// Parses a magnet URI into a richer bootstrap model for live BT orchestration.
///
/// # Errors
///
/// Returns an error when the magnet URI is malformed or the BTIH / peer hints are invalid.
pub fn parse_magnet_bootstrap(input: &str) -> Result<MagnetBootstrapModel, BtMetalinkError> {
let fields = parse_magnet_fields(input)?;
let uri = MagnetUriModel {
info_hash: fields.info_hash,
display_name: fields.display_name,
trackers: fields.trackers,
web_seeds: fields.web_seeds,
exact_topic: fields
.keyword_topic
.or_else(|| fields.peer_hints.last().cloned()),
};
let mut bootstrap = uri.bootstrap()?;
let peer_hints = fields
.peer_hints
.iter()
.map(|raw| {
TorrentPeerModel::from_endpoint(raw).map_err(|reason| BtMetalinkError::InvalidMagnet {
reason: format!("invalid x.pe peer hint {raw:?}: {reason}"),
})
})
.collect::<Result<Vec<_>, _>>()?;
let peer_hint_nodes = peer_hints
.iter()
.map(TorrentPeerModel::to_dht_node)
.collect::<Vec<_>>();
bootstrap.peer_hints = peer_hints;
bootstrap.peer_hint_nodes = peer_hint_nodes;
Ok(bootstrap)
}
/// Decodes a magnet query fragment using percent-decoding plus `+` as space.
fn percent_decode(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let bytes = input.as_bytes();
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' && index + 2 < bytes.len() {
let hi = bytes[index + 1];
let lo = bytes[index + 2];
if let (Some(hi), Some(lo)) = (hex_value(hi), hex_value(lo)) {
output.push(char::from((hi << 4) | lo));
index += 3;
continue;
}
}
if bytes[index] == b'+' {
output.push(' ');
index += 1;
continue;
}
output.push(char::from(bytes[index]));
index += 1;
}
output
}
/// Percent-encodes one magnet query value while preserving URL-safe delimiters.
fn percent_encode_query_value(input: &str) -> String {
let mut output = String::with_capacity(input.len());
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b':' | b'/' => {
output.push(char::from(byte));
}
b' ' => output.push_str("%20"),
_ => {
output.push('%');
output.push(char::from(HEX[usize::from(byte >> 4)]));
output.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
}
}
output
}
/// Uppercase hexadecimal digits used by the percent encoder.
const HEX: &[u8; 16] = b"0123456789ABCDEF";
/// Converts one ASCII hex digit into its numeric nibble value.
const fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
/// Parsed magnet query fields before they are shaped into public models.
struct MagnetParseFields {
/// Raw `btih` token captured from the `xt=` field.
info_hash: String,
/// Optional display name decoded from `dn=`.
display_name: Option<String>,
/// Tracker URLs collected from repeated `tr=` fields.
trackers: Vec<String>,
/// Web-seed URLs collected from repeated `ws=` fields.
web_seeds: Vec<String>,
/// Optional keyword topic decoded from `kt=`.
keyword_topic: Option<String>,
/// Peer bootstrap hints collected from repeated `x.pe=` fields.
peer_hints: Vec<String>,
}
/// Parses raw magnet query fields while preserving repeated `x.pe=` entries.
fn parse_magnet_fields(input: &str) -> Result<MagnetParseFields, BtMetalinkError> {
let payload = input
.strip_prefix("magnet:?")
.ok_or_else(|| BtMetalinkError::InvalidMagnet {
reason: "missing magnet:? prefix".to_owned(),
})?;
let mut info_hash = None;
let mut display_name = None;
let mut trackers = Vec::new();
let mut web_seeds = Vec::new();
let mut keyword_topic = None;
let mut peer_hints = Vec::new();
for pair in payload.split('&') {
let Some((key, value)) = pair.split_once('=') else {
continue;
};
match key {
"xt" if value.starts_with("urn:btih:") => {
info_hash = Some(value.trim_start_matches("urn:btih:").to_owned());
}
"dn" => display_name = Some(percent_decode(value)),
"tr" => trackers.push(percent_decode(value)),
"ws" => web_seeds.push(percent_decode(value)),
"kt" => keyword_topic = Some(percent_decode(value)),
"x.pe" => peer_hints.push(percent_decode(value)),
_ => {}
}
}
let info_hash = info_hash.ok_or_else(|| BtMetalinkError::InvalidMagnet {
reason: "missing xt=urn:btih:<hash>".to_owned(),
})?;
Ok(MagnetParseFields {
info_hash,
display_name,
trackers,
web_seeds,
keyword_topic,
peer_hints,
})
}
/// Decodes one BTIH token into its 20-byte info-hash form.
fn decode_btih_token(input: &str) -> Result<[u8; 20], BtMetalinkError> {
let normalized = input
.chars()
.filter(char::is_ascii_alphanumeric)
.collect::<String>();
if normalized.len() == 40 && normalized.chars().all(|ch| ch.is_ascii_hexdigit()) {
let mut out = [0_u8; 20];
for (index, chunk) in normalized.as_bytes().chunks_exact(2).enumerate() {
let hi = hex_value(chunk[0]).ok_or_else(|| BtMetalinkError::InvalidMagnet {
reason: format!("invalid hex digit in btih token: {input}"),
})?;
let lo = hex_value(chunk[1]).ok_or_else(|| BtMetalinkError::InvalidMagnet {
reason: format!("invalid hex digit in btih token: {input}"),
})?;
out[index] = (hi << 4) | lo;
}
return Ok(out);
}
if normalized.len() == 32 {
return decode_base32_btih(&normalized);
}
Err(BtMetalinkError::InvalidMagnet {
reason: format!("btih token must be 40 hex or 32 base32 characters, got {input}"),
})
}
/// Decodes an RFC 4648 base32 BTIH token into raw bytes.
fn decode_base32_btih(input: &str) -> Result<[u8; 20], BtMetalinkError> {
let mut out = [0_u8; 20];
let mut accumulator = 0_u64;
let mut bits = 0_u32;
let mut written = 0_usize;
for byte in input.bytes() {
let value = match byte {
b'A'..=b'Z' => byte - b'A',
b'a'..=b'z' => byte - b'a',
b'2'..=b'7' => byte - b'2' + 26,
_ => {
return Err(BtMetalinkError::InvalidMagnet {
reason: format!("invalid base32 digit in btih token: {input}"),
});
}
};
accumulator = (accumulator << 5) | u64::from(value);
bits += 5;
while bits >= 8 {
bits -= 8;
if written >= out.len() {
return Err(BtMetalinkError::InvalidMagnet {
reason: format!("base32 btih token decoded longer than 20 bytes: {input}"),
});
}
out[written] = u8::try_from((accumulator >> bits) & 0xff)
.expect("masked base32 byte must fit into u8");
written += 1;
}
}
if written != out.len() {
return Err(BtMetalinkError::InvalidMagnet {
reason: format!("base32 btih token decoded to {written} bytes instead of 20"),
});
}
Ok(out)
}
/// Encodes bytes as lowercase hexadecimal text.
fn hex_encode_lower(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(nibble_to_hex(byte >> 4));
out.push(nibble_to_hex(byte & 0x0f));
}
out
}
/// Formats one nibble as a lowercase hexadecimal digit.
fn nibble_to_hex(nibble: u8) -> char {
match nibble {
0..=9 => char::from(b'0' + nibble),
10..=15 => char::from(b'a' + (nibble - 10)),
_ => '?',
}
}
#[cfg(test)]
mod tests {
use super::{MagnetMetadataModel, MagnetUriModel, parse_magnet_bootstrap, parse_magnet_uri};
#[test]
fn parses_magnet_uri_into_model() {
let uri = parse_magnet_uri(
"magnet:?xt=urn:btih:0123456789abcdef&dn=Ubuntu%2024.04&tr=http%3A%2F%2Ftracker.example.org%2Fannounce&ws=https%3A%2F%2Fcdn.example.org%2Fubuntu.iso",
)
.expect("magnet uri should parse");
assert_eq!(uri.info_hash, "0123456789abcdef");
assert_eq!(uri.display_name.as_deref(), Some("Ubuntu 24.04"));
assert_eq!(uri.trackers.len(), 1);
assert_eq!(uri.web_seeds.len(), 1);
}
#[test]
fn model_constructor_tracks_torrent_count() {
let model = MagnetUriModel {
info_hash: "deadbeef".to_owned(),
display_name: None,
trackers: vec!["http://tracker.example.org/announce".to_owned()],
web_seeds: Vec::new(),
exact_topic: Some("urn:btih:deadbeef".to_owned()),
};
assert_eq!(model.tracker_count(), 1);
assert_eq!(
model.to_uri(),
"magnet:?xt=urn:btih:deadbeef&tr=http://tracker.example.org/announce&kt=urn:btih:deadbeef"
);
}
#[test]
fn magnet_metadata_wraps_uri() {
let uri = MagnetUriModel {
info_hash: "0123456789abcdef".to_owned(),
display_name: Some("Ubuntu".to_owned()),
trackers: Vec::new(),
web_seeds: Vec::new(),
exact_topic: None,
};
let metadata = MagnetMetadataModel::from_uri(uri.clone(), Some(123));
assert_eq!(metadata.uri, uri);
assert_eq!(metadata.known_length, Some(123));
assert_eq!(
metadata.to_uri(),
"magnet:?xt=urn:btih:0123456789abcdef&dn=Ubuntu"
);
}
#[test]
fn magnet_metadata_can_apply_known_length_from_extended_handshake() {
let uri = MagnetUriModel {
info_hash: "0123456789abcdef0123456789abcdef01234567".to_owned(),
display_name: Some("Ubuntu".to_owned()),
trackers: vec!["udp://tracker.example.org:6969".to_owned()],
web_seeds: Vec::new(),
exact_topic: None,
};
let mut metadata = MagnetMetadataModel::from_uri(uri, None);
let handshake = crate::torrent::PeerWireExtensionHandshakeModel {
extensions: std::collections::BTreeMap::from([("ut_metadata".to_owned(), 3_u8)]),
client_name: Some("aria2-rust-pro".to_owned()),
metadata_size: Some(48_321),
request_queue: Some(64),
};
metadata.apply_extension_handshake(&handshake);
assert_eq!(metadata.known_length, Some(48_321));
}
#[test]
fn parses_realistic_uri_with_multiple_trackers_and_keyword_topic() {
let text = "magnet:?xt=urn:btih:0123456789ABCDEF0123456789ABCDEF01234567&dn=Arch+Linux+ISO&tr=udp%3A%2F%2Ftracker.one.example%3A1337%2Fannounce&tr=https%3A%2F%2Ftracker.two.example%2Fannounce&ws=https%3A%2F%2Fcdn.example.org%2Farch.iso&kt=linux+iso";
let model = parse_magnet_uri(text).expect("realistic magnet should parse");
assert_eq!(model.info_hash, "0123456789ABCDEF0123456789ABCDEF01234567");
assert_eq!(model.display_name.as_deref(), Some("Arch Linux ISO"));
assert_eq!(
model.trackers,
vec![
"udp://tracker.one.example:1337/announce".to_owned(),
"https://tracker.two.example/announce".to_owned(),
]
);
assert_eq!(model.web_seeds, vec!["https://cdn.example.org/arch.iso"]);
assert_eq!(model.exact_topic.as_deref(), Some("linux iso"));
}
#[test]
fn parser_accepts_x_pe_and_roundtrips_as_kt() {
let parsed = parse_magnet_uri(
"magnet:?xt=urn:btih:89abcdef0123456789abcdef0123456789abcdef&dn=Ubuntu%2026.04&tr=http%3A%2F%2Ft1.example%2Fa&tr=http%3A%2F%2Ft2.example%2Fa&ws=https%3A%2F%2Fseed.example%2Fubuntu.iso&x.pe=ubuntu%20lts",
)
.expect("x.pe should parse");
assert_eq!(parsed.exact_topic.as_deref(), Some("ubuntu lts"));
assert_eq!(parsed.trackers.len(), 2);
let roundtrip = parse_magnet_uri(&parsed.to_uri()).expect("roundtrip should parse");
assert_eq!(roundtrip, parsed);
}
#[test]
fn magnet_bootstrap_normalizes_info_hash_and_extracts_peer_hints() {
let bootstrap = parse_magnet_bootstrap(
"magnet:?xt=urn:btih:00112233445566778899AABBCCDDEEFF00112233&dn=magnet-bootstrap.iso&tr=http%3A%2F%2Ftracker-a.example.org%2Fannounce&tr=udp%3A%2F%2Ftracker-b.example.org%3A6969&x.pe=198.51.100.9%3A51413&x.pe=%5B2001%3Adb8%3A%3A9%5D%3A51413",
)
.expect("bootstrap magnet should parse");
assert_eq!(
bootstrap.info_hash_hex,
"00112233445566778899aabbccddeeff00112233"
);
assert_eq!(bootstrap.info_hash_bytes[0], 0x00);
assert_eq!(bootstrap.info_hash_bytes[19], 0x33);
assert_eq!(bootstrap.trackers.len(), 2);
assert_eq!(bootstrap.trackers[0].tier, Some(0));
assert_eq!(bootstrap.trackers[1].tier, Some(1));
assert_eq!(bootstrap.peer_hints.len(), 2);
assert_eq!(bootstrap.peer_hints[0].ip, "198.51.100.9");
assert_eq!(bootstrap.peer_hints[0].port, 51413);
assert_eq!(bootstrap.peer_hints[1].ip, "2001:db8::9");
assert_eq!(bootstrap.peer_hints[1].port, 51413);
assert_eq!(bootstrap.peer_hint_nodes[0].to_spec(), "198.51.100.9:51413");
assert_eq!(
bootstrap.peer_hint_nodes[1].to_spec(),
"[2001:db8::9]:51413"
);
}
#[test]
fn magnet_info_hash_helper_decodes_base32_btih() {
let parsed =
parse_magnet_uri("magnet:?xt=urn:btih:AERUKZ4JVPG66AJDIVTYTK6N54ASGRLH&dn=base32.iso")
.expect("base32 magnet should parse");
assert_eq!(
parsed
.canonical_info_hash_hex()
.expect("base32 btih should normalize"),
"0123456789abcdef0123456789abcdef01234567"
);
assert_eq!(
parsed.info_hash_bytes().expect("base32 btih should decode"),
[
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab,
0xcd, 0xef, 0x01, 0x23, 0x45, 0x67,
]
);
}
}
@@ -0,0 +1,23 @@
//! Metalink document parsing and download-candidate selection helpers.
#![forbid(unsafe_code)]
/// Shared Metalink document and resource data models.
mod model;
/// URL and metadata normalization helpers for parsed Metalink files.
mod normalization;
/// XML parsing entry points for Metalink documents.
mod parser;
/// Download-candidate planning and ranking helpers.
mod planner;
#[cfg(test)]
mod tests;
pub use self::model::{
MetalinkChecksumModel, MetalinkDocumentModel, MetalinkDownloadPlanEntry, MetalinkFileModel,
MetalinkParseResult, MetalinkParserModel, MetalinkResourceModel,
};
pub use self::parser::parse_metalink_document;
pub use self::planner::{
metalink_download_plan, preferred_download_candidate, preferred_resource_for_file,
};
@@ -0,0 +1,137 @@
use crate::http::ChecksumSpec;
use super::parser::parse_metalink_document;
/// Checksum entry parsed from a Metalink file description.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MetalinkChecksumModel {
/// Checksum algorithm name normalized for downstream use.
pub algorithm: String,
/// Expected checksum value as provided by the document.
pub value: String,
/// Whether the checksum has already been verified by another stage.
pub verified: bool,
}
/// Mirror or source URI candidate parsed from a Metalink file description.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MetalinkResourceModel {
/// Download URI.
pub url: String,
/// Optional location hint such as a country or region code.
pub location: Option<String>,
/// Optional mirror priority where lower values are preferred.
pub priority: Option<u32>,
/// Optional maximum per-resource connection count.
pub max_connections: Option<u32>,
/// Whether the resource is marked private.
pub private: bool,
/// Optional resource type hint such as `http` or `ftp`.
pub type_hint: Option<String>,
/// Optional language hint.
pub language: Option<String>,
}
/// File entry parsed from a Metalink document.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MetalinkFileModel {
/// Output filename suggested by the document.
pub name: String,
/// Optional declared file size in bytes.
pub size: Option<u64>,
/// Checksums associated with the file.
pub checksums: Vec<MetalinkChecksumModel>,
/// Candidate download resources for the file.
pub resources: Vec<MetalinkResourceModel>,
/// Detached signature payloads or references.
pub signatures: Vec<String>,
/// Optional identity field scoped to this file.
pub identifier: Option<String>,
/// Optional human-readable description.
pub description: Option<String>,
}
/// Parsed Metalink document model.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MetalinkDocumentModel {
/// Metalink version attribute from the root element.
pub version: Option<String>,
/// Files declared by the document.
pub files: Vec<MetalinkFileModel>,
/// Optional document-wide identity.
pub identity: Option<String>,
/// Optional publisher string.
pub publisher: Option<String>,
/// Optional generator string.
pub generator: Option<String>,
/// Optional publication timestamp.
pub published_at: Option<String>,
}
/// Parser settings and last-known error state for Metalink parsing.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MetalinkParserModel {
/// Whether downstream callers expect strict validation.
pub strict: bool,
/// Last parse error captured by the parser facade.
pub last_error: Option<String>,
/// Whether partial models may be accepted by callers.
pub allow_partial: bool,
}
/// Result wrapper returned by the parser facade.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MetalinkParseResult {
/// Parsed document when successful.
pub document: Option<MetalinkDocumentModel>,
/// Parser state after the attempted parse.
pub parser: MetalinkParserModel,
}
/// Single-file download plan derived from a Metalink document.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MetalinkDownloadPlanEntry {
/// Target file name.
pub file_name: String,
/// Optional declared size in bytes.
pub size: Option<u64>,
/// Preferred checksum supported by the protocol layer.
pub checksum: Option<ChecksumSpec>,
/// Ordered list of candidate URIs.
pub uris: Vec<String>,
/// Optional identity field carried into the plan.
pub identifier: Option<String>,
/// Optional description carried into the plan.
pub description: Option<String>,
}
impl MetalinkParserModel {
/// Creates a parser facade with the requested strictness settings.
#[must_use]
pub const fn new(strict: bool, allow_partial: bool) -> Self {
Self {
strict,
last_error: None,
allow_partial,
}
}
/// Parses a Metalink document while capturing the last parse error on failure.
#[must_use]
pub fn parse(&self, input: &str) -> MetalinkParseResult {
let mut parser = self.clone();
match parse_metalink_document(input) {
Ok(document) => MetalinkParseResult {
document: Some(document),
parser,
},
Err(error) => {
parser.last_error = Some(error);
MetalinkParseResult {
document: None,
parser,
}
}
}
}
}
@@ -0,0 +1,96 @@
/// Decodes one XML local-name byte slice into owned UTF-8-lossy text.
pub(super) fn decode_local_name(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
/// Decodes arbitrary XML text bytes into owned UTF-8-lossy text.
pub(super) fn decode_bytes(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
/// Trims surrounding whitespace from parser text content.
pub(super) fn normalize_text(value: &str) -> String {
value.trim().to_owned()
}
/// Normalizes an optional resource location hint.
pub(super) fn normalize_location(value: &str) -> Option<String> {
let value = normalize_text(value);
if value.is_empty() {
None
} else {
Some(value.to_ascii_lowercase())
}
}
/// Normalizes an optional resource language hint.
pub(super) fn normalize_language(value: &str) -> Option<String> {
let value = normalize_text(value);
if value.is_empty() {
None
} else {
Some(value.to_ascii_lowercase())
}
}
/// Normalizes an optional explicit resource type hint.
pub(super) fn normalize_resource_type(value: &str) -> Option<String> {
let value = normalize_text(value);
if value.is_empty() {
None
} else {
Some(value.to_ascii_lowercase())
}
}
/// Normalizes Metalink checksum algorithm names to stable downstream forms.
pub(super) fn normalize_checksum_algorithm(value: &str) -> Option<String> {
let normalized = normalize_text(value)
.replace(['_', ' '], "")
.to_ascii_lowercase();
match normalized.as_str() {
"" => None,
"sha1" => Some("sha-1".to_owned()),
"sha256" => Some("sha-256".to_owned()),
"sha512" => Some("sha-512".to_owned()),
other if other.starts_with("sha-") => Some(other.to_owned()),
other => Some(other.to_owned()),
}
}
/// Removes separators and lowercases a checksum payload.
pub(super) fn normalize_checksum_value(value: &str) -> String {
value
.chars()
.filter(|ch| !ch.is_ascii_whitespace())
.collect::<String>()
.to_ascii_lowercase()
}
/// Infers a resource type hint from a resource URL scheme.
pub(super) fn infer_resource_type_from_url(url: &str) -> Option<String> {
let scheme = url.split(':').next()?.trim();
if scheme.is_empty() {
None
} else {
Some(scheme.to_ascii_lowercase())
}
}
/// Returns whether a Metalink boolean attribute should be treated as enabled.
pub(super) fn is_truthy(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes"
)
}
/// Returns whether the current XML element stack contains `target`.
pub(super) fn stack_contains(stack: &[String], target: &str) -> bool {
stack.iter().any(|entry| entry == target)
}
/// Returns whether an optional string is absent or only whitespace.
pub(super) fn is_blank_opt(value: Option<&str>) -> bool {
value.is_none_or(str::is_empty)
}

Some files were not shown because too many files have changed in this diff Show More