chore: initial sanitized public snapshot

This commit is contained in:
Aria2 Rust Pro Contributors
2026-07-18 16:01:12 +08:00
commit 7c6b6a3746
321 changed files with 76896 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "aria2-rust-pro-rpc"
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_rpc"
path = "src/lib.rs"
[dependencies]
aria2-rust-pro-compat.workspace = true
aria2-rust-pro-core.workspace = true
aria2-rust-pro-protocol.workspace = true
base64 = "0.22"
serde_json = "1"
sha1 = "0.10"
[dev-dependencies]
aria2-rust-pro-storage.workspace = true
[lints]
workspace = true
+419
View File
@@ -0,0 +1,419 @@
//! In-process RPC dispatcher backed by the download engine.
#![expect(
clippy::arithmetic_side_effects,
clippy::assigning_clones,
clippy::as_conversions,
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
clippy::clone_on_copy,
clippy::cognitive_complexity,
clippy::doc_markdown,
clippy::float_arithmetic,
clippy::format_collect,
clippy::format_push_string,
clippy::if_not_else,
clippy::indexing_slicing,
clippy::integer_division,
clippy::into_iter_on_ref,
clippy::map_unwrap_or,
clippy::manual_clamp,
clippy::match_same_arms,
clippy::missing_const_for_fn,
clippy::missing_errors_doc,
clippy::needless_collect,
clippy::needless_pass_by_value,
clippy::option_if_let_else,
clippy::redundant_clone,
clippy::redundant_closure_for_method_calls,
clippy::too_many_arguments,
clippy::too_many_lines,
clippy::trivially_copy_pass_by_ref,
clippy::unused_self,
unused_imports,
reason = "the aria2-compatible dispatcher is a monolithic compatibility and test surface where these lints add large volumes of noise without changing validated behavior"
)]
use std::{
collections::{BTreeMap, BTreeSet},
sync::atomic::{AtomicU64, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
use aria2_rust_pro_core::{
BtFileInfo, BtPeerInfo, BtPieceAvailabilityUpdate, BtRuntimeState, BtTrackerInfo, CoreError,
DownloadEngine, DownloadId, DownloadStatus, OptionKey, OptionPatch, OptionValue, PieceId,
PieceMap, PieceState, QueuePositionMode, RequestGroup, RuntimeConfig, SaveSessionTarget,
};
use aria2_rust_pro_protocol::{
DhtMessageModel, DhtNodeModel, DhtTransport, HeaderKind, HttpResponseModel, MagnetUriModel,
ResponseBody, TorrentMetadataModel, TrackerRequestModel, TrackerScrapeModel, TrackerTransport,
magnet::MagnetBootstrapModel,
parse_torrent_metadata,
torrent::{
DhtMessageBody, DhtResponseModel, PeerWireBlockRequestModel,
PeerWireExtensionHandshakeModel, PeerWireHandshakeModel, PeerWireMessageKind,
PeerWireMetadataMessageModel, PeerWireMetadataMessageType, PeerWirePieceBlockModel,
TorrentMessageModel,
},
transport::{
PeerWireTransportConnector, PeerWireTransportRequest, PeerWireTransportResponse,
TransportEndpoint, TransportScheme,
},
};
use sha1::{Digest, Sha1};
use crate::{
handlers::RpcHandlerContext,
jsonrpc::{JsonRpcRequest, JsonRpcResponse},
model::{BT_STATUS_FIELDS, RpcAuthContext, RpcError, RpcMeta, RpcValue},
router::{RpcDispatchRequest, RpcDispatchResult, RpcRouter},
xmlrpc::{XmlRpcMember, XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, XmlRpcValue},
};
/// In-process dispatcher that routes aria2-compatible RPC calls into the core engine.
pub struct InProcessRpcDispatcher {
/// Core download engine that owns the live download registry and runtime state.
engine: DownloadEngine,
/// Extension router for non-core RPC methods layered on top of aria2 compatibility.
router: RpcRouter,
/// Stable session identifier returned by `aria2.getSessionInfo`.
session_id: String,
}
/// Lightweight per-download state used by internal callers that do not need a
/// full `aria2.tellStatus` payload.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RpcStatusSummary {
/// Current user-visible lifecycle state.
pub status: DownloadStatus,
/// Expected payload length in bytes.
pub total_length: u64,
/// Completed payload length in bytes.
pub completed_length: u64,
/// Active connection count tracked for the download.
pub connections: u32,
/// Whether the download currently carries BitTorrent runtime state.
pub is_bt: bool,
}
/// BitTorrent runtime orchestration helpers used by RPC compatibility methods.
mod bt_runtime;
/// Shared compatibility-side runtime helpers for BitTorrent and related RPC shaping.
mod compat_support;
/// Top-level JSON-RPC and XML-RPC request ingress helpers.
mod dispatch_surface;
/// Shared JSON-RPC and XML-RPC fault shaping helpers.
mod faults;
/// Shared parser and conversion helpers for dispatcher methods.
mod helpers;
/// Mutating aria2-compatible RPC method implementations.
mod mutations;
/// Status/view payload builders for aria2-compatible RPC responses.
mod payloads;
/// Read-only aria2-compatible RPC method implementations.
mod queries;
/// Transfer registration and writeback helpers for add* RPC methods.
mod transfer_runtime;
use self::bt_runtime::{
BtRuntimeCoordinatorAction, BtRuntimeCoordinatorReport, BtRuntimeCoordinatorSnapshot,
BtRuntimeCoordinatorStepReport, BtRuntimeCoordinatorStepStatus,
};
use self::compat_support::{
BT_METADATA_PIECE_LENGTH, PeerWireExchangePlan, apply_bt_select_file_option,
bt_metadata_piece_count, bt_metadata_piece_span, bt_peer_is_connectable, bt_peer_metadata_key,
bt_piece_count, bt_piece_span_bytes, bt_runtime_total_length, bt_verified_length,
build_bt_runtime_state, build_bt_runtime_state_from_magnet, build_dht_announce_peer_request,
build_dht_find_node_request, build_dht_get_peers_request, build_dht_ping_request,
build_peer_wire_exchange_plan, build_tracker_request, decode_hex_nibble,
decode_hex_string_exact, default_bt_dht_nodes, generate_session_id, hex_string,
initial_bt_dht_nodes, merge_bt_dht_nodes, merge_bt_peers, merge_bt_trackers,
parse_bt_select_file_indexes, parse_dht_compact_nodes, parse_dht_compact_peers,
parse_dht_node_spec, parse_peer_wire_exchange_response, peer_wire_bitfield_is_complete,
pick_bt_dht_node, promote_bt_dht_node, push_bt_runtime_coordinator_result,
resolve_bt_info_hash, rpc_bt_info_hash, rpc_bt_local_node_id, rpc_enabled_features,
rpc_share_ratio_text, rpc_share_time_text, skipped_bt_runtime_coordinator_step,
try_promote_bt_metadata, verified_length_for_range,
};
use self::{
faults::{rpc_error_value, xmlrpc_error_value, xmlrpc_fault_from_error, xmlrpc_fault_value},
helpers::{
apply_group_options, decode_metalink_payload, filter_status_payload,
first_forbidden_change_global_option_key, first_forbidden_change_option_key,
i64_from_usize, is_retry_relevant_status, is_rpc_uri_candidate, metalink_default_options,
option_specs_for_global_view, parse_content_range_completed_length,
parse_optional_option_object, parse_optional_position, parse_optional_status_keys,
parse_optional_uri_array, parse_required_file_index, parse_uri_array_allow_empty,
parse_uri_list_param, rpc_uri_file_name, rpc_uri_has_ascii_prefix,
rpc_uri_has_ascii_suffix, slice_handles_by_offset, u32_from_usize, u64_from_usize,
usize_from_i64, usize_from_u64, xmlrpc_member_value,
},
};
impl std::fmt::Debug for InProcessRpcDispatcher {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("InProcessRpcDispatcher")
.field("tracked_download_count", &self.tracked_download_count())
.field("session_id", &self.session_id)
.finish_non_exhaustive()
}
}
impl Default for InProcessRpcDispatcher {
fn default() -> Self {
Self::new()
}
}
impl InProcessRpcDispatcher {
#[must_use]
/// Creates a dispatcher using the default runtime configuration.
pub fn new() -> Self {
Self::with_runtime(RuntimeConfig::default())
}
#[must_use]
/// Creates a dispatcher using an explicit runtime configuration.
pub fn with_runtime(runtime: RuntimeConfig) -> Self {
Self {
engine: DownloadEngine::with_runtime(runtime),
router: RpcRouter::new(),
session_id: generate_session_id(),
}
}
#[must_use]
/// Returns the number of tracked downloads currently known to the engine.
pub fn tracked_download_count(&self) -> usize {
self.engine.task_count()
}
/// Returns a lightweight summary for one tracked download without shaping a
/// full RPC payload.
pub fn status_summary_for_gid(&self, gid: &str) -> Result<RpcStatusSummary, RpcError> {
let gid = parse_gid_text(gid)?;
let group = self
.engine
.registry()
.get(gid)
.ok_or_else(|| missing_download_error(gid))?;
Ok(RpcStatusSummary {
status: *group.status(),
total_length: group.total_length(),
completed_length: group.completed_length(),
connections: group.num_connections(),
is_bt: group.bt().is_some(),
})
}
/// Registers a runtime listener with the underlying download engine.
pub fn register_runtime_listener(
&mut self,
listener: impl aria2_rust_pro_core::EventListener + 'static,
) {
self.engine.register_listener(listener);
}
}
/// Maps core state-transition failures into aria2-compatible RPC errors.
fn state_transition_rpc_error(
method: &'static str,
gid: DownloadId,
error: &CoreError,
) -> RpcError {
match method {
"aria2.pause" | "aria2.forcePause" => {
RpcError::unsupported(&format!("GID#{gid} cannot be paused now"))
}
"aria2.unpause" => RpcError::unsupported(&format!("GID#{gid} cannot be unpaused now")),
"aria2.remove" | "aria2.forceRemove" => match error {
CoreError::UnknownDownloadId(_) => {
RpcError::unsupported(&format!("Active Download not found for GID#{gid}"))
}
_ => RpcError::unsupported(&format!("GID#{gid} cannot be removed now")),
},
_ => RpcError::unsupported(&error.to_string()),
}
}
/// Parses an RPC GID string into the engine download identifier.
fn parse_gid_text(gid: &str) -> Result<DownloadId, RpcError> {
DownloadId::parse_hex(gid).ok_or_else(|| RpcError::unsupported(&format!("Invalid GID {gid}")))
}
/// Builds the upstream-style missing-download error for a GID.
fn missing_download_error(gid: DownloadId) -> RpcError {
RpcError::unsupported(&format!("No such download for GID#{gid}"))
}
/// Infers the effective transfer length from an HTTP response snapshot.
fn http_response_length(response: &HttpResponseModel) -> Option<u64> {
response
.content_range
.as_ref()
.and_then(|range| range.total_size)
.or_else(|| {
response
.headers
.headers
.iter()
.find(|header| {
header.kind == HeaderKind::Response
&& header.name.eq_ignore_ascii_case("content-length")
})
.and_then(|header| header.value.parse::<u64>().ok())
})
.or_else(|| match &response.body {
ResponseBody::Inline(bytes) => Some(bytes.len() as u64),
ResponseBody::Streamed {
expected_len,
observed_len,
..
} => observed_len.or(*expected_len),
ResponseBody::Empty => Some(0),
})
}
/// Infers the bytes transferred during the latest HTTP response chunk.
fn http_response_delta_length(response: &HttpResponseModel) -> Option<u64> {
match &response.body {
ResponseBody::Inline(bytes) => Some(bytes.len() as u64),
ResponseBody::Streamed { observed_len, .. } => *observed_len,
ResponseBody::Empty => Some(0),
}
}
/// Infers the cumulative completed length represented by an HTTP response.
fn http_response_completed_length(response: &HttpResponseModel) -> Option<u64> {
let observed_len = match &response.body {
ResponseBody::Inline(bytes) => Some(bytes.len() as u64),
ResponseBody::Streamed { observed_len, .. } => *observed_len,
ResponseBody::Empty => Some(0),
};
response
.content_range
.as_ref()
.map(|range| {
observed_len
.map(|len| range.start.saturating_add(len))
.unwrap_or_else(|| range.end_inclusive.saturating_add(1))
})
.or_else(|| {
response
.headers
.headers
.iter()
.find(|header| {
header.kind == HeaderKind::Response
&& header.name.eq_ignore_ascii_case("content-range")
})
.and_then(|header| parse_content_range_completed_length(&header.value))
})
.or(observed_len)
}
#[cfg(test)]
mod response_length_tests {
use super::{http_response_completed_length, http_response_delta_length};
use aria2_rust_pro_protocol::{
ChecksumSpec, ContentRangeSpec, HeaderKind, HttpHeader, HttpResponseHeaders,
HttpResponseModel, HttpVersion, RangeUnit, ResponseBody,
};
#[test]
fn streamed_lengths_prefer_observed_bytes_over_declared_content_length() {
let response = HttpResponseModel {
status: 200,
reason: "OK".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![HttpHeader {
name: "Content-Length".to_owned(),
value: "10".to_owned(),
kind: HeaderKind::Response,
}],
},
body: ResponseBody::Streamed {
expected_len: Some(10),
observed_len: Some(0),
observed_digest: None,
temp_path: None,
},
content_range: None,
partial_content: false,
checksum: Some(ChecksumSpec {
algorithm: "sha-256".to_owned(),
expected_hex: String::new(),
actual_hex: None,
}),
redirected_from: None,
};
assert_eq!(http_response_delta_length(&response), Some(0));
assert_eq!(http_response_completed_length(&response), Some(0));
}
#[test]
fn streamed_partial_completion_uses_observed_span_not_declared_range_tail() {
let response = 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: None,
temp_path: None,
},
content_range: Some(ContentRangeSpec {
unit: RangeUnit::Bytes,
start: 0,
end_inclusive: 9,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
};
assert_eq!(http_response_delta_length(&response), Some(5));
assert_eq!(http_response_completed_length(&response), Some(5));
}
#[test]
fn streamed_lengths_without_observed_bytes_do_not_claim_progress() {
let response = HttpResponseModel {
status: 200,
reason: "OK".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![HttpHeader {
name: "Content-Length".to_owned(),
value: "4096".to_owned(),
kind: HeaderKind::Response,
}],
},
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,
};
assert_eq!(http_response_delta_length(&response), None);
assert_eq!(http_response_completed_length(&response), None);
}
}
#[cfg(test)]
/// Regression coverage for JSON-RPC, XML-RPC, and queue mutation dispatcher behavior.
mod tests;
@@ -0,0 +1,31 @@
//! Shared `BitTorrent` runtime coordination helpers for dispatcher entrypoints.
pub(super) use self::reporting::{
BtRuntimeCoordinatorAction, BtRuntimeCoordinatorReport, BtRuntimeCoordinatorSnapshot,
BtRuntimeCoordinatorStepReport, BtRuntimeCoordinatorStepStatus,
};
use super::{
BtPeerInfo, BtPieceAvailabilityUpdate, DhtMessageBody, DhtMessageModel, DhtNodeModel,
DhtResponseModel, DhtTransport, Digest, DownloadStatus, InProcessRpcDispatcher,
PeerWireMetadataMessageType, PeerWireTransportConnector, PeerWireTransportResponse, PieceId,
PieceState, RpcError, TrackerScrapeModel, TrackerTransport, bt_metadata_piece_span,
bt_peer_is_connectable, bt_peer_metadata_key, bt_piece_count, bt_piece_span_bytes,
bt_runtime_total_length, bt_verified_length, build_dht_announce_peer_request,
build_dht_find_node_request, build_dht_get_peers_request, build_dht_ping_request,
build_tracker_request, hex_string, merge_bt_dht_nodes, missing_download_error,
parse_dht_compact_nodes, parse_dht_compact_peers, parse_dht_node_spec, parse_gid_text,
peer_wire_bitfield_is_complete, promote_bt_dht_node, push_bt_runtime_coordinator_result,
rpc_bt_info_hash, skipped_bt_runtime_coordinator_step, try_promote_bt_metadata, u32_from_usize,
u64_from_usize,
};
/// DHT runtime coordinator operations.
mod dht;
/// Peer-wire runtime coordinator operations.
mod peer_wire;
/// Coordinator report types shared with dispatcher tests and RPC payload shaping.
mod reporting;
/// Runtime snapshot and coordinator orchestration methods.
mod runtime_state;
/// Tracker runtime coordinator operations.
mod tracker;
@@ -0,0 +1,327 @@
use super::{
DhtMessageBody, DhtMessageModel, DhtNodeModel, DhtResponseModel, DhtTransport,
InProcessRpcDispatcher, RpcError, build_dht_announce_peer_request, build_dht_find_node_request,
build_dht_get_peers_request, build_dht_ping_request, merge_bt_dht_nodes,
missing_download_error, parse_dht_compact_nodes, parse_dht_compact_peers, parse_gid_text,
promote_bt_dht_node, u32_from_usize,
};
impl InProcessRpcDispatcher {
/// Applies a DHT get-peers response to a tracked download.
///
/// # Errors
///
/// Returns an error when `gid` is invalid or the DHT payload cannot be applied.
pub fn apply_dht_get_peers_result(
&mut self,
gid: &str,
node: &DhtNodeModel,
response: &DhtMessageModel,
) -> Result<(), RpcError> {
let gid = parse_gid_text(gid)?;
let get_peers = match &response.body {
DhtMessageBody::Response(DhtResponseModel::GetPeers(model)) => model,
DhtMessageBody::Error(error) => {
return Err(RpcError::unsupported(&format!(
"dht get_peers returned error {}: {}",
error.code, error.message
)));
}
_ => {
return Err(RpcError::unsupported(
"dht get_peers requires a get_peers response",
));
}
};
let peers = parse_dht_compact_peers(&get_peers.values)
.map_err(|error| RpcError::unsupported(&format!("invalid dht peer values: {error}")))?;
let discovered_nodes = parse_dht_compact_nodes(get_peers.nodes.as_deref())
.map_err(|error| RpcError::unsupported(&format!("invalid dht nodes: {error}")))?;
if !peers.is_empty() {
self.engine
.apply_bt_peer_snapshot(gid, peers)
.map_err(|error| RpcError::unsupported(&error.to_string()))?;
}
{
let group = self
.engine
.handle_mut(gid)
.ok_or_else(|| missing_download_error(gid))?;
group.set_dht_token(get_peers.token.clone());
}
let peer_count = self
.engine
.registry()
.get(gid)
.and_then(|group| group.bt())
.map(|bt| u32_from_usize(bt.peers.len()))
.unwrap_or(0);
let group = self
.engine
.handle_mut(gid)
.ok_or_else(|| missing_download_error(gid))?;
group.set_num_connections(peer_count);
let bt = group
.bt_mut()
.ok_or_else(|| RpcError::unsupported("dht apply requires bt runtime state"))?;
merge_bt_dht_nodes(
&mut bt.dht_nodes,
std::iter::once(format!("{}:{}", node.address, node.port)).chain(discovered_nodes),
);
Ok(())
}
/// Applies a DHT ping response to a tracked download.
///
/// # Errors
///
/// Returns an error when `gid` is invalid or the DHT payload cannot be applied.
pub fn apply_dht_ping_result(
&mut self,
gid: &str,
node: &DhtNodeModel,
response: &DhtMessageModel,
) -> Result<(), RpcError> {
let gid = parse_gid_text(gid)?;
let ping = match &response.body {
DhtMessageBody::Response(DhtResponseModel::Ping(model)) => model,
DhtMessageBody::Error(error) => {
return Err(RpcError::unsupported(&format!(
"dht ping returned error {}: {}",
error.code, error.message
)));
}
_ => return Err(RpcError::unsupported("dht ping requires a ping response")),
};
if ping.node_id.len() != 20 {
return Err(RpcError::unsupported(
"dht ping response node id must be 20 bytes",
));
}
let group = self
.engine
.handle_mut(gid)
.ok_or_else(|| missing_download_error(gid))?;
let bt = group
.bt_mut()
.ok_or_else(|| RpcError::unsupported("dht ping requires bt runtime state"))?;
promote_bt_dht_node(&mut bt.dht_nodes, node);
Ok(())
}
/// Applies a DHT find-node response to a tracked download.
///
/// # Errors
///
/// Returns an error when `gid` is invalid or the DHT payload cannot be applied.
pub fn apply_dht_find_node_result(
&mut self,
gid: &str,
node: &DhtNodeModel,
response: &DhtMessageModel,
) -> Result<(), RpcError> {
let gid = parse_gid_text(gid)?;
let find_node = match &response.body {
DhtMessageBody::Response(DhtResponseModel::FindNode(model)) => model,
DhtMessageBody::Error(error) => {
return Err(RpcError::unsupported(&format!(
"dht find_node returned error {}: {}",
error.code, error.message
)));
}
_ => {
return Err(RpcError::unsupported(
"dht find_node requires a find_node response",
));
}
};
if find_node.node_id.len() != 20 {
return Err(RpcError::unsupported(
"dht find_node response node id must be 20 bytes",
));
}
let discovered_nodes = find_node
.nodes
.iter()
.map(|discovered| {
format!(
"{}.{}.{}.{}:{}",
discovered.address[0],
discovered.address[1],
discovered.address[2],
discovered.address[3],
discovered.port
)
})
.collect::<Vec<_>>();
let group = self
.engine
.handle_mut(gid)
.ok_or_else(|| missing_download_error(gid))?;
let bt = group
.bt_mut()
.ok_or_else(|| RpcError::unsupported("dht find_node requires bt runtime state"))?;
promote_bt_dht_node(&mut bt.dht_nodes, node);
merge_bt_dht_nodes(&mut bt.dht_nodes, discovered_nodes);
Ok(())
}
/// Applies a DHT announce-peer response to a tracked download.
///
/// # Errors
///
/// Returns an error when `gid` is invalid or the DHT payload cannot be applied.
pub fn apply_dht_announce_peer_result(
&mut self,
gid: &str,
node: &DhtNodeModel,
response: &DhtMessageModel,
) -> Result<(), RpcError> {
let gid = parse_gid_text(gid)?;
let announce_peer = match &response.body {
DhtMessageBody::Response(DhtResponseModel::Ping(model)) => model,
DhtMessageBody::Error(error) => {
return Err(RpcError::unsupported(&format!(
"dht announce_peer returned error {}: {}",
error.code, error.message
)));
}
_ => {
return Err(RpcError::unsupported(
"dht announce_peer requires a ping-like response",
));
}
};
if announce_peer.node_id.len() != 20 {
return Err(RpcError::unsupported(
"dht announce_peer response node id must be 20 bytes",
));
}
let group = self
.engine
.handle_mut(gid)
.ok_or_else(|| missing_download_error(gid))?;
let bt = group
.bt_mut()
.ok_or_else(|| RpcError::unsupported("dht announce_peer requires bt runtime state"))?;
promote_bt_dht_node(&mut bt.dht_nodes, node);
Ok(())
}
/// Executes a DHT ping using the provided transport.
///
/// # Errors
///
/// Returns an error when `gid` is invalid, the DHT request cannot be built, or transport execution fails.
pub fn execute_dht_ping<T: DhtTransport + ?Sized>(
&mut self,
gid: &str,
transport: &T,
) -> Result<(), RpcError> {
let download_id = parse_gid_text(gid)?;
let (node, request) = {
let group = self
.engine
.registry()
.get(download_id)
.ok_or_else(|| missing_download_error(download_id))?;
build_dht_ping_request(group)?
};
let response = transport
.send_message(&node, &request)
.map_err(|error| RpcError::unsupported(&format!("dht ping failed: {error}")))?;
self.apply_dht_ping_result(gid, &node, &response)
}
/// Executes a DHT find-node query using the provided transport.
///
/// # Errors
///
/// Returns an error when `gid` is invalid, the DHT request cannot be built, or transport execution fails.
pub fn execute_dht_find_node<T: DhtTransport + ?Sized>(
&mut self,
gid: &str,
transport: &T,
) -> Result<(), RpcError> {
let download_id = parse_gid_text(gid)?;
let (node, request) = {
let group = self
.engine
.registry()
.get(download_id)
.ok_or_else(|| missing_download_error(download_id))?;
build_dht_find_node_request(group)?
};
let response = transport
.send_message(&node, &request)
.map_err(|error| RpcError::unsupported(&format!("dht find_node failed: {error}")))?;
self.apply_dht_find_node_result(gid, &node, &response)
}
/// Executes a DHT announce-peer query using the provided transport.
///
/// # Errors
///
/// Returns an error when `gid` is invalid, the DHT request cannot be built, or transport execution fails.
pub fn execute_dht_announce_peer<T: DhtTransport + ?Sized>(
&mut self,
gid: &str,
transport: &T,
) -> Result<(), RpcError> {
let download_id = parse_gid_text(gid)?;
let (node, request) = {
let group = self
.engine
.registry()
.get(download_id)
.ok_or_else(|| missing_download_error(download_id))?;
build_dht_announce_peer_request(group)?
};
let response = transport.send_message(&node, &request).map_err(|error| {
RpcError::unsupported(&format!("dht announce_peer failed: {error}"))
})?;
self.apply_dht_announce_peer_result(gid, &node, &response)
}
/// Executes a DHT get-peers query using the provided transport.
///
/// # Errors
///
/// Returns an error when `gid` is invalid, the DHT request cannot be built, or transport execution fails.
pub fn execute_dht_get_peers<T: DhtTransport + ?Sized>(
&mut self,
gid: &str,
transport: &T,
) -> Result<(), RpcError> {
let download_id = parse_gid_text(gid)?;
let (node, request) = {
let group = self
.engine
.registry()
.get(download_id)
.ok_or_else(|| missing_download_error(download_id))?;
build_dht_get_peers_request(group)?
};
let response = transport
.send_message(&node, &request)
.map_err(|error| RpcError::unsupported(&format!("dht get_peers failed: {error}")))?;
self.apply_dht_get_peers_result(gid, &node, &response)
}
}
@@ -0,0 +1,245 @@
use super::{
BtPieceAvailabilityUpdate, DownloadStatus, InProcessRpcDispatcher, PeerWireMetadataMessageType,
PeerWireTransportConnector, PeerWireTransportResponse, PieceId, PieceState, RpcError,
bt_metadata_piece_span, bt_peer_metadata_key, bt_piece_count, bt_piece_span_bytes,
bt_runtime_total_length, bt_verified_length, missing_download_error, parse_gid_text,
peer_wire_bitfield_is_complete, try_promote_bt_metadata, u32_from_usize, u64_from_usize,
};
use crate::dispatcher::compat_support::{
PeerWireExchangePlan, build_peer_wire_exchange_plan, parse_peer_wire_exchange_response,
};
impl InProcessRpcDispatcher {
/// Executes a peer-wire exchange using the provided transport connector.
///
/// # Errors
///
/// Returns an error when `gid` is invalid, the exchange plan cannot be built, or transport execution fails.
pub fn execute_peer_wire_exchange<T: PeerWireTransportConnector + ?Sized>(
&mut self,
gid: &str,
transport: &T,
) -> Result<(), RpcError> {
let download_id = parse_gid_text(gid)?;
let plan = {
let group = self
.engine
.registry()
.get(download_id)
.ok_or_else(|| missing_download_error(download_id))?;
build_peer_wire_exchange_plan(group)?
};
let response = transport
.connect_peer_wire(&plan.request)
.map_err(|error| {
RpcError::unsupported(&format!("peer-wire exchange failed: {error}"))
})?;
self.apply_peer_wire_exchange_result(gid, &plan, &response)
}
/// Applies a peer-wire exchange result to BitTorrent runtime state and piece progress.
fn apply_peer_wire_exchange_result(
&mut self,
gid: &str,
plan: &PeerWireExchangePlan,
response: &PeerWireTransportResponse,
) -> Result<(), RpcError> {
let gid = parse_gid_text(gid)?;
let parsed = parse_peer_wire_exchange_response(
response,
&plan.info_hash,
plan.metadata_extension_id,
)?;
let should_complete;
{
let group = self
.engine
.handle_mut(gid)
.ok_or_else(|| missing_download_error(gid))?;
if group.bt().is_none() {
return Err(RpcError::unsupported(
"peer-wire apply requires bt runtime state",
));
}
let mut runtime_metadata_only = group.bt().is_some_and(|bt| bt.metadata_only);
{
let bt = group.bt_mut().ok_or_else(|| {
RpcError::unsupported("peer-wire apply requires bt runtime state")
})?;
let peer = bt.peers.get_mut(plan.peer_index).ok_or_else(|| {
RpcError::unsupported(
"peer-wire apply target peer disappeared from runtime state",
)
})?;
let peer_key = bt_peer_metadata_key(peer);
if let Some(peer_id) = parsed.peer_id.clone() {
peer.peer_id = Some(peer_id);
}
if let Some(choked) = parsed.peer_choked {
peer.choked = choked;
}
if let Some(interested) = parsed.peer_interested {
peer.interested = interested;
}
if let Some(extension_handshake) = &parsed.extension_handshake {
if let Some(client_name) = &extension_handshake.client_name {
peer.client_name = Some(client_name.clone());
}
if let Some(extension_message_id) = extension_handshake.ut_metadata_id() {
bt.metadata_extension_ids
.insert(peer_key.clone(), extension_message_id);
}
if let Some(metadata_size) = extension_handshake.metadata_size {
bt.metadata_size = Some(metadata_size);
}
}
let mut known_metadata_size = bt.metadata_size;
for message in &parsed.metadata_messages {
match message.message_type {
PeerWireMetadataMessageType::Request
| PeerWireMetadataMessageType::Reject => {}
PeerWireMetadataMessageType::Data => {
let total_size = message.total_size.ok_or_else(|| {
RpcError::unsupported(
"ut_metadata data payload must include total_size",
)
})?;
if let Some(existing_size) = known_metadata_size {
if existing_size != total_size {
return Err(RpcError::unsupported(&format!(
"conflicting magnet metadata size: expected {existing_size}, got {total_size}",
)));
}
} else {
bt.metadata_size = Some(total_size);
known_metadata_size = Some(total_size);
}
let expected_len = bt_metadata_piece_span(total_size, message.piece);
if expected_len == 0 {
return Err(RpcError::unsupported(&format!(
"ut_metadata piece {} exceeded metadata size {total_size}",
message.piece
)));
}
if message.payload.len() > expected_len {
return Err(RpcError::unsupported(&format!(
"ut_metadata piece {} payload too large: expected at most {expected_len} bytes, got {}",
message.piece,
message.payload.len()
)));
}
bt.metadata_piece_payloads
.insert(message.piece, message.payload.clone());
}
}
}
}
if runtime_metadata_only {
let _ = try_promote_bt_metadata(group)?;
runtime_metadata_only = group.bt().is_some_and(|bt| bt.metadata_only);
}
if let Some(request) = &plan.block_request
&& !runtime_metadata_only
{
let requested_piece = PieceId(request.piece_index);
if group.piece_state(requested_piece) != Some(PieceState::Verified) {
group.set_piece_state(requested_piece, PieceState::Downloading);
}
}
let mut downloaded_delta = 0_u64;
if !runtime_metadata_only {
let piece_length = group.piece_length().max(1);
let total_length = bt_runtime_total_length(group);
for piece in &parsed.pieces {
let piece_id = PieceId(piece.piece_index);
if piece.block.is_empty() {
continue;
}
downloaded_delta =
downloaded_delta.saturating_add(u64_from_usize(piece.block.len()));
let expected_len =
bt_piece_span_bytes(piece_id, piece_length, total_length).max(1);
if piece.block_offset == 0 && u64_from_usize(piece.block.len()) >= expected_len
{
group.set_piece_state(piece_id, PieceState::Verified);
} else if group.piece_state(piece_id) != Some(PieceState::Verified) {
group.set_piece_state(piece_id, PieceState::Downloading);
}
}
let num_pieces = bt_piece_count(total_length, piece_length);
let completed_length = bt_verified_length(group, piece_length, total_length);
if completed_length > group.completed_length() {
group.set_completed_length(completed_length);
}
for piece in &parsed.available_pieces {
if *piece >= u32_from_usize(num_pieces) {
continue;
}
group.apply_bt_piece_availability_update(BtPieceAvailabilityUpdate {
piece_id: PieceId(*piece),
peers_with_piece: 1,
});
}
should_complete = total_length > 0 && completed_length >= total_length;
} else {
should_complete = false;
}
group.set_download_speed(downloaded_delta);
if !matches!(
group.status(),
DownloadStatus::Complete | DownloadStatus::Removed
) {
group.set_status(DownloadStatus::Active);
}
let peer_runtime_piece_length = group.piece_length().max(1);
let peer_runtime_total_length = bt_runtime_total_length(group);
let peer_runtime_piece_count =
bt_piece_count(peer_runtime_total_length, peer_runtime_piece_length);
let peer_stats;
{
let bt = group.bt_mut().ok_or_else(|| {
RpcError::unsupported("peer-wire apply requires bt runtime state")
})?;
let peer = bt.peers.get_mut(plan.peer_index).ok_or_else(|| {
RpcError::unsupported(
"peer-wire apply target peer disappeared from runtime state",
)
})?;
if let Some(peer_id) = parsed.peer_id.clone() {
peer.peer_id = Some(peer_id);
}
if let Some(choked) = parsed.peer_choked {
peer.choked = choked;
}
if let Some(interested) = parsed.peer_interested {
peer.interested = interested;
}
peer.download_speed = downloaded_delta;
peer.upload_speed = u64_from_usize(plan.request.payload.len());
if let Some(bitfield) = &parsed.bitfield_pieces {
peer.seeder =
peer_wire_bitfield_is_complete(bitfield, peer_runtime_piece_count);
} else if parsed.available_pieces.len() >= peer_runtime_piece_count
&& peer_runtime_piece_count > 0
{
peer.seeder = (0..u32_from_usize(peer_runtime_piece_count))
.all(|piece| parsed.available_pieces.contains(&piece));
}
let updated_peer = peer.clone();
peer_stats = group.apply_bt_peer_update(updated_peer);
}
group.set_num_connections(u32_from_usize(peer_stats.peer_count));
}
if should_complete {
self.engine
.complete(gid)
.map_err(|_| missing_download_error(gid))?;
}
Ok(())
}
}
@@ -0,0 +1,92 @@
use super::DownloadStatus;
/// One coordinator-visible BitTorrent action that the dispatcher can execute in a loop.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BtRuntimeCoordinatorAction {
/// Advance share/seeding timers using the supplied wall clock.
AdvanceClock,
/// Refresh peers and tracker metadata from the primary tracker.
TrackerAnnounce,
/// Query DHT for peers against the current info hash.
DhtGetPeers,
/// Expand the DHT node frontier when peers are still unavailable.
DhtFindNode,
/// Announce the local presence back into DHT once a token is cached.
DhtAnnouncePeer,
/// Perform one peer-wire request/response exchange against the best current peer.
PeerWireExchange,
}
/// Outcome classification for one coordinator-visible BitTorrent action.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BtRuntimeCoordinatorStepStatus {
/// The dispatcher executed the action successfully.
Executed,
/// The dispatcher intentionally skipped the action because a prerequisite was absent.
Skipped,
/// The dispatcher attempted the action and it failed.
Failed,
}
/// Detailed result for one BitTorrent coordinator action.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BtRuntimeCoordinatorStepReport {
/// Action that was evaluated.
pub action: BtRuntimeCoordinatorAction,
/// Final status for the action in this loop iteration.
pub status: BtRuntimeCoordinatorStepStatus,
/// Optional human-readable detail for skips and failures.
pub detail: Option<String>,
}
/// Snapshot of dispatcher-visible BitTorrent runtime state for loop orchestration.
#[derive(Clone, Debug, Eq, PartialEq)]
#[expect(
clippy::struct_excessive_bools,
reason = "aria2-compatible BT status snapshots intentionally surface several independent boolean facets"
)]
pub struct BtRuntimeCoordinatorSnapshot {
/// Download GID in aria2 hex form.
pub gid: String,
/// Current aria2/core download state.
pub status: DownloadStatus,
/// Whether the BT runtime currently considers the local side seeding.
pub seeding: bool,
/// Aggregate number of bytes marked complete in the request group.
pub completed_length: u64,
/// Aggregate total length known to the runtime.
pub total_length: u64,
/// Live connection count currently exposed through aria2 status surfaces.
pub connections: u32,
/// Number of configured tracker entries in BT runtime state.
pub tracker_count: usize,
/// Total DHT node entries currently cached, including malformed ones.
pub dht_node_count: usize,
/// Number of DHT node entries that can actually be converted into transport targets.
pub addressable_dht_node_count: usize,
/// Total peer rows currently cached in BT runtime state.
pub peer_count: usize,
/// Number of peers that are currently usable for peer-wire transport.
pub connectable_peer_count: usize,
/// Whether a DHT announce token is already cached from a prior get_peers response.
pub has_dht_token: bool,
/// Whether the BT runtime is still metadata-only.
pub metadata_only: bool,
/// Whether this looks like a magnet-backed partial path that still lacks metadata exchange support.
pub metadata_exchange_pending: bool,
/// Number of locally requestable pieces remaining under the current piece state.
pub requestable_piece_count: usize,
/// Dispatcher-level action suggestions derived from the current snapshot.
pub recommended_actions: Vec<BtRuntimeCoordinatorAction>,
}
/// Full report for one dispatcher-driven BitTorrent loop iteration.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BtRuntimeCoordinatorReport {
/// Snapshot captured before any coordinator actions were attempted.
pub initial_snapshot: BtRuntimeCoordinatorSnapshot,
/// Snapshot captured after the last attempted coordinator action.
pub final_snapshot: BtRuntimeCoordinatorSnapshot,
/// Ordered per-action results for this iteration.
pub steps: Vec<BtRuntimeCoordinatorStepReport>,
}
@@ -0,0 +1,270 @@
use super::{
BtRuntimeCoordinatorAction, BtRuntimeCoordinatorReport, BtRuntimeCoordinatorSnapshot,
DhtTransport, Digest, InProcessRpcDispatcher, PeerWireTransportConnector, RpcError,
TrackerTransport, bt_peer_is_connectable, bt_runtime_total_length, missing_download_error,
parse_dht_node_spec, parse_gid_text, push_bt_runtime_coordinator_result,
skipped_bt_runtime_coordinator_step,
};
impl InProcessRpcDispatcher {
/// Applies a BitTorrent runtime tick update to a tracked download.
///
/// # Errors
///
/// Returns an error when `gid` is invalid or the engine rejects the runtime update.
pub fn apply_bt_runtime_tick(
&mut self,
gid: &str,
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<(), RpcError> {
let gid = parse_gid_text(gid)?;
self.engine
.apply_bt_runtime_tick(
gid,
downloaded_delta,
uploaded_delta,
download_speed,
upload_speed,
share_time_delta_secs,
seeding_time_delta_secs,
seeding,
num_connections,
)
.map_err(|error| RpcError::unsupported(&error.to_string()))?;
Ok(())
}
/// Advances the BitTorrent runtime clock for a tracked download.
///
/// # Errors
///
/// Returns an error when `gid` is invalid or the engine rejects the clock update.
pub fn tick_bt_runtime_clock(
&mut self,
gid: &str,
now_unix_secs: u64,
seeding: bool,
) -> Result<(), RpcError> {
let gid = parse_gid_text(gid)?;
self.engine
.tick_bt_runtime_clock(gid, now_unix_secs, seeding)
.map_err(|error| RpcError::unsupported(&error.to_string()))?;
Ok(())
}
/// Sets whether a BitTorrent download is currently seeding.
///
/// # Errors
///
/// Returns an error when `gid` is invalid or the engine rejects the seeding update.
pub fn set_bt_seeding_state(
&mut self,
gid: &str,
seeding: bool,
at_unix_secs: Option<u64>,
) -> Result<(), RpcError> {
let gid = parse_gid_text(gid)?;
self.engine
.set_bt_seeding_state(gid, seeding, at_unix_secs)
.map_err(|error| RpcError::unsupported(&error.to_string()))?;
Ok(())
}
/// Captures a coordinator-friendly BitTorrent runtime snapshot for one download.
///
/// # Errors
///
/// Returns an error when `gid` is invalid, missing, or does not currently own BT runtime state.
pub fn bt_runtime_coordinator_snapshot(
&self,
gid: &str,
) -> Result<BtRuntimeCoordinatorSnapshot, RpcError> {
let gid = parse_gid_text(gid)?;
let group = self
.engine
.registry()
.get(gid)
.ok_or_else(|| missing_download_error(gid))?;
let bt = group
.bt()
.ok_or_else(|| RpcError::unsupported("bt coordinator requires bt runtime state"))?;
let has_dht_token = group.dht_token().is_some();
let addressable_dht_node_count = bt
.dht_nodes
.iter()
.filter(|raw| parse_dht_node_spec(raw).is_ok())
.count();
let connectable_peer_count = bt
.peers
.iter()
.filter(|peer| bt_peer_is_connectable(peer))
.count();
let (pending, queued, _, _, missing, _) = group.piece_state_counts();
let requestable_piece_count = pending + queued + missing;
let metadata_exchange_pending = bt.metadata_only && bt.magnet_uri.is_some();
let mut recommended_actions = Vec::new();
if !bt.trackers.is_empty() {
recommended_actions.push(BtRuntimeCoordinatorAction::TrackerAnnounce);
}
if addressable_dht_node_count > 0 {
recommended_actions.push(BtRuntimeCoordinatorAction::DhtGetPeers);
if connectable_peer_count == 0 {
recommended_actions.push(BtRuntimeCoordinatorAction::DhtFindNode);
}
if has_dht_token {
recommended_actions.push(BtRuntimeCoordinatorAction::DhtAnnouncePeer);
}
}
if connectable_peer_count > 0 {
recommended_actions.push(BtRuntimeCoordinatorAction::PeerWireExchange);
}
Ok(BtRuntimeCoordinatorSnapshot {
gid: format!("{:016x}", gid.as_u64()),
status: *group.status(),
seeding: group.bt_is_seeding(),
completed_length: group.completed_length(),
total_length: bt_runtime_total_length(group),
connections: group.num_connections(),
tracker_count: bt.trackers.len(),
dht_node_count: bt.dht_nodes.len(),
addressable_dht_node_count,
peer_count: bt.peers.len(),
connectable_peer_count,
has_dht_token,
metadata_only: bt.metadata_only,
metadata_exchange_pending,
requestable_piece_count,
recommended_actions,
})
}
/// Drives one coordinator-friendly BitTorrent loop iteration using any available transports.
///
/// This helper intentionally stays transport-neutral: it consumes already-built tracker, DHT,
/// and peer-wire transports when provided, and it reports the remaining metadata-only magnet gap
/// instead of pretending to implement BEP9/ut_metadata locally.
///
/// # Errors
///
/// Returns an error when `gid` is invalid, missing, or does not currently own BT runtime state.
pub fn drive_bt_runtime_once(
&mut self,
gid: &str,
tracker_transport: Option<&dyn TrackerTransport>,
dht_transport: Option<&dyn DhtTransport>,
peer_wire_transport: Option<&dyn PeerWireTransportConnector>,
now_unix_secs: Option<u64>,
) -> Result<BtRuntimeCoordinatorReport, RpcError> {
let initial_snapshot = self.bt_runtime_coordinator_snapshot(gid)?;
let mut steps = Vec::new();
if let Some(now_unix_secs) = now_unix_secs {
push_bt_runtime_coordinator_result(
&mut steps,
BtRuntimeCoordinatorAction::AdvanceClock,
self.tick_bt_runtime_clock(gid, now_unix_secs, initial_snapshot.seeding),
);
}
let mut snapshot = self.bt_runtime_coordinator_snapshot(gid)?;
if snapshot.tracker_count > 0 {
if let Some(transport) = tracker_transport {
push_bt_runtime_coordinator_result(
&mut steps,
BtRuntimeCoordinatorAction::TrackerAnnounce,
self.execute_tracker_announce(gid, transport),
);
snapshot = self.bt_runtime_coordinator_snapshot(gid)?;
} else {
steps.push(skipped_bt_runtime_coordinator_step(
BtRuntimeCoordinatorAction::TrackerAnnounce,
"tracker transport unavailable",
));
}
}
if snapshot.addressable_dht_node_count > 0 {
if let Some(transport) = dht_transport {
push_bt_runtime_coordinator_result(
&mut steps,
BtRuntimeCoordinatorAction::DhtGetPeers,
self.execute_dht_get_peers(gid, transport),
);
snapshot = self.bt_runtime_coordinator_snapshot(gid)?;
} else {
steps.push(skipped_bt_runtime_coordinator_step(
BtRuntimeCoordinatorAction::DhtGetPeers,
"dht transport unavailable",
));
}
} else if snapshot.dht_node_count > 0 {
steps.push(skipped_bt_runtime_coordinator_step(
BtRuntimeCoordinatorAction::DhtGetPeers,
"no usable dht nodes in runtime state",
));
}
if snapshot.addressable_dht_node_count > 0 && snapshot.connectable_peer_count == 0 {
if let Some(transport) = dht_transport {
push_bt_runtime_coordinator_result(
&mut steps,
BtRuntimeCoordinatorAction::DhtFindNode,
self.execute_dht_find_node(gid, transport),
);
snapshot = self.bt_runtime_coordinator_snapshot(gid)?;
} else {
steps.push(skipped_bt_runtime_coordinator_step(
BtRuntimeCoordinatorAction::DhtFindNode,
"dht transport unavailable",
));
}
}
if snapshot.addressable_dht_node_count > 0 && snapshot.has_dht_token {
if let Some(transport) = dht_transport {
push_bt_runtime_coordinator_result(
&mut steps,
BtRuntimeCoordinatorAction::DhtAnnouncePeer,
self.execute_dht_announce_peer(gid, transport),
);
snapshot = self.bt_runtime_coordinator_snapshot(gid)?;
} else {
steps.push(skipped_bt_runtime_coordinator_step(
BtRuntimeCoordinatorAction::DhtAnnouncePeer,
"dht transport unavailable",
));
}
}
if snapshot.connectable_peer_count > 0 {
if let Some(transport) = peer_wire_transport {
push_bt_runtime_coordinator_result(
&mut steps,
BtRuntimeCoordinatorAction::PeerWireExchange,
self.execute_peer_wire_exchange(gid, transport),
);
} else {
steps.push(skipped_bt_runtime_coordinator_step(
BtRuntimeCoordinatorAction::PeerWireExchange,
"peer-wire transport unavailable",
));
}
}
Ok(BtRuntimeCoordinatorReport {
initial_snapshot,
final_snapshot: self.bt_runtime_coordinator_snapshot(gid)?,
steps,
})
}
}
@@ -0,0 +1,180 @@
use super::{
BtPeerInfo, InProcessRpcDispatcher, RpcError, TrackerScrapeModel, TrackerTransport,
build_tracker_request, hex_string, missing_download_error, parse_gid_text, rpc_bt_info_hash,
};
impl InProcessRpcDispatcher {
/// Applies a tracker announce response to a tracked download.
///
/// # Errors
///
/// Returns an error when `gid` is invalid or the tracker payload cannot be applied.
pub fn apply_tracker_announce_result(
&mut self,
gid: &str,
response: &aria2_rust_pro_protocol::tracker::TrackerResponseModel,
) -> Result<(), RpcError> {
let gid = parse_gid_text(gid)?;
let (tracker_url, peers) = {
let group = self
.engine
.registry()
.get(gid)
.ok_or_else(|| missing_download_error(gid))?;
let tracker_url = group
.bt()
.and_then(|bt| bt.trackers.first().map(|tracker| tracker.url.clone()))
.ok_or_else(|| {
RpcError::unsupported("tracker announce apply requires at least one tracker")
})?;
let peers = response
.peers
.peers
.iter()
.cloned()
.map(|peer| BtPeerInfo {
peer_id: peer.peer_id.map(|id| hex_string(&id).to_ascii_lowercase()),
ip: peer.ip,
port: peer.port,
client_name: peer.client_name,
interested: peer.interested,
choked: peer.choked,
download_speed: 0,
upload_speed: 0,
seeder: false,
})
.collect::<Vec<_>>();
(tracker_url, peers)
};
self.engine
.apply_bt_peer_snapshot(gid, peers)
.map_err(|error| RpcError::unsupported(&error.to_string()))?;
self.engine
.apply_bt_tracker_snapshot(
gid,
&tracker_url,
response.peers.tracker_id.clone(),
None,
None,
)
.map_err(|error| RpcError::unsupported(&error.to_string()))?;
if let Some(scrape) = &response.scrape {
self.apply_tracker_scrape_result(&gid.to_string(), Some(&tracker_url), scrape)?;
}
Ok(())
}
/// Applies a tracker scrape response to a tracked download.
///
/// # Errors
///
/// Returns an error when `gid` is invalid or the scrape payload cannot be applied.
pub fn apply_tracker_scrape_result(
&mut self,
gid: &str,
tracker_url: Option<&str>,
scrape: &TrackerScrapeModel,
) -> Result<(), RpcError> {
let gid = parse_gid_text(gid)?;
let (resolved_tracker_url, resolved_complete, resolved_incomplete) = {
let group = self
.engine
.registry()
.get(gid)
.ok_or_else(|| missing_download_error(gid))?;
let bt = group
.bt()
.ok_or_else(|| RpcError::unsupported("tracker scrape requires bt runtime state"))?;
let tracker_url = tracker_url
.map(ToOwned::to_owned)
.or_else(|| bt.trackers.first().map(|tracker| tracker.url.clone()))
.ok_or_else(|| {
RpcError::unsupported("tracker scrape apply requires at least one tracker")
})?;
let info_hash = if !bt.info_hash.is_empty() {
bt.info_hash.to_ascii_lowercase()
} else {
rpc_bt_info_hash(group.uri())
.unwrap_or_default()
.to_ascii_lowercase()
};
let file_match = scrape
.files
.iter()
.find(|file| file.info_hash.eq_ignore_ascii_case(&info_hash));
let complete = file_match
.and_then(|file| file.complete)
.or(scrape.complete);
let incomplete = file_match
.and_then(|file| file.incomplete)
.or(scrape.incomplete);
(tracker_url, complete, incomplete)
};
self.engine
.apply_bt_tracker_snapshot(
gid,
&resolved_tracker_url,
None,
resolved_complete,
resolved_incomplete,
)
.map_err(|error| RpcError::unsupported(&error.to_string()))?;
Ok(())
}
/// Executes a tracker scrape using the provided transport.
///
/// # Errors
///
/// Returns an error when `gid` is invalid, the tracker request cannot be built, or transport execution fails.
pub fn execute_tracker_scrape<T: TrackerTransport + ?Sized>(
&mut self,
gid: &str,
transport: &T,
) -> Result<(), RpcError> {
let download_id = parse_gid_text(gid)?;
let request = {
let group = self
.engine
.registry()
.get(download_id)
.ok_or_else(|| missing_download_error(download_id))?;
build_tracker_request(group)?
};
let scrape = transport
.scrape(&request.announce_url)
.map_err(|error| RpcError::unsupported(&format!("tracker scrape failed: {error}")))?;
self.apply_tracker_scrape_result(gid, Some(&request.announce_url), &scrape)
}
/// Executes a tracker announce using the provided transport.
///
/// # Errors
///
/// Returns an error when `gid` is invalid, the tracker request cannot be built, or transport execution fails.
pub fn execute_tracker_announce<T: TrackerTransport + ?Sized>(
&mut self,
gid: &str,
transport: &T,
) -> Result<(), RpcError> {
let download_id = parse_gid_text(gid)?;
let request = {
let group = self
.engine
.registry()
.get(download_id)
.ok_or_else(|| missing_download_error(download_id))?;
build_tracker_request(group)?
};
let mut response = transport
.announce(&request)
.map_err(|error| RpcError::unsupported(&format!("tracker announce failed: {error}")))?;
if response.scrape.is_none()
&& let Ok(scrape) = transport.scrape(&request.announce_url)
{
response.scrape = Some(scrape);
}
self.apply_tracker_announce_result(gid, &response)
}
}
@@ -0,0 +1,28 @@
//! Shared compatibility helpers that bridge dispatcher state into aria2-style payloads.
pub(super) use self::{
bt_runtime::*, dht::*, peer_wire::*, rpc_surface::*, selection::*, tracker::*,
};
use super::{
AtomicU64, BTreeMap, BTreeSet, BtFileInfo, BtPeerInfo, BtRuntimeCoordinatorAction,
BtRuntimeCoordinatorStepReport, BtRuntimeCoordinatorStepStatus, BtRuntimeState, BtTrackerInfo,
DhtMessageModel, DhtNodeModel, Digest, DownloadId, MagnetBootstrapModel, MagnetUriModel,
Ordering, PeerWireBlockRequestModel, PeerWireExtensionHandshakeModel, PeerWireHandshakeModel,
PeerWireMessageKind, PeerWireMetadataMessageModel, PeerWirePieceBlockModel,
PeerWireTransportRequest, PeerWireTransportResponse, PieceId, PieceMap, PieceState,
RequestGroup, RpcError, Sha1, SystemTime, TorrentMessageModel, TorrentMetadataModel,
TrackerRequestModel, TransportEndpoint, TransportScheme, UNIX_EPOCH, parse_torrent_metadata,
};
/// BitTorrent runtime state construction and accounting helpers.
mod bt_runtime;
/// DHT request construction and compact payload parsing helpers.
mod dht;
/// Peer-wire request, response, and accounting helpers.
mod peer_wire;
/// RPC-facing compatibility value formatting helpers.
mod rpc_surface;
/// BitTorrent file selection option helpers.
mod selection;
/// Tracker announce request construction helpers.
mod tracker;
@@ -0,0 +1,362 @@
use super::{
BTreeMap, BtFileInfo, BtPeerInfo, BtRuntimeState, BtTrackerInfo, DhtNodeModel, Digest,
MagnetBootstrapModel, MagnetUriModel, PieceId, PieceMap, PieceState, RequestGroup, RpcError,
TorrentMetadataModel, hex_string, merge_bt_dht_nodes, parse_torrent_metadata,
};
/// Extracts an uppercase BitTorrent info hash from a magnet URI.
pub(in crate::dispatcher) fn rpc_bt_info_hash(uri: &str) -> Option<String> {
let lower = uri.to_ascii_lowercase();
let marker = "xt=urn:btih:";
let start = lower.find(marker)?;
let raw = &uri[start + marker.len()..];
let token = raw.split('&').next().unwrap_or(raw);
let normalized: String = token
.chars()
.filter(|ch| ch.is_ascii_alphanumeric())
.map(|ch| ch.to_ascii_uppercase())
.collect();
if normalized.is_empty() {
None
} else {
Some(normalized)
}
}
/// Converts parsed torrent metadata into the core BitTorrent runtime model.
pub(in crate::dispatcher) fn build_bt_runtime_state(
metadata: &TorrentMetadataModel,
) -> BtRuntimeState {
let trackers: Vec<BtTrackerInfo> = metadata
.trackers
.iter()
.map(|tracker| BtTrackerInfo {
url: tracker.url.clone(),
tier: tracker.tier,
id: tracker.id.clone(),
seeders: tracker.seeders,
leechers: tracker.leechers,
})
.collect();
let files = metadata
.info
.files
.iter()
.map(|file| BtFileInfo {
path: file.path.clone(),
length: file.length,
piece_offset: file.piece_offset,
selected: file.selected,
})
.collect();
let peers = metadata
.peers
.iter()
.map(|peer| BtPeerInfo {
peer_id: peer.peer_id.map(|peer_id| hex_string(&peer_id)),
ip: peer.ip.clone(),
port: peer.port,
client_name: peer.client_name.clone(),
interested: peer.interested,
choked: peer.choked,
download_speed: 0,
upload_speed: 0,
seeder: false,
})
.collect();
let info_hash = metadata
.info
.hash
.as_ref()
.map(|hash| hash.info_hash_hex.to_ascii_uppercase())
.unwrap_or_default();
let magnet_uri = (!info_hash.is_empty()).then(|| {
MagnetUriModel {
info_hash: info_hash.clone(),
display_name: Some(metadata.info.name.clone()),
trackers: trackers.iter().map(|tracker| tracker.url.clone()).collect(),
web_seeds: Vec::new(),
exact_topic: None,
}
.to_uri()
});
BtRuntimeState {
info_hash,
name: Some(metadata.info.name.clone()),
magnet_uri,
metadata_only: false,
metadata_size: None,
metadata_extension_ids: BTreeMap::new(),
metadata_piece_payloads: BTreeMap::new(),
creation_date: metadata.creation_date.clone(),
comment: metadata.comment.clone(),
dht_nodes: initial_bt_dht_nodes(&metadata.dht_nodes),
files,
trackers,
peers,
}
}
/// Seeds BitTorrent runtime state from a magnet URI before metadata arrives.
pub(in crate::dispatcher) fn build_bt_runtime_state_from_magnet(
uri: &str,
magnet: &MagnetBootstrapModel,
) -> BtRuntimeState {
let magnet_uri = Some(uri.to_owned());
let trackers = magnet
.trackers
.iter()
.map(|tracker| BtTrackerInfo {
url: tracker.url.clone(),
tier: tracker.tier,
id: tracker.id.clone(),
seeders: tracker.seeders,
leechers: tracker.leechers,
})
.collect();
let peer_hints = magnet
.peer_hints
.iter()
.map(|peer| BtPeerInfo {
peer_id: peer
.peer_id
.map(|peer_id: [u8; 20]| hex_string(&peer_id[..])),
ip: peer.ip.clone(),
port: peer.port,
client_name: peer.client_name.clone(),
interested: peer.interested,
choked: peer.choked,
download_speed: 0,
upload_speed: 0,
seeder: false,
})
.collect();
let hinted_dht_nodes = magnet
.peer_hint_nodes
.iter()
.map(DhtNodeModel::to_spec)
.collect::<Vec<_>>();
BtRuntimeState {
info_hash: magnet.info_hash_hex.to_ascii_uppercase(),
name: magnet.uri.display_name.clone(),
magnet_uri,
metadata_only: true,
metadata_size: None,
metadata_extension_ids: BTreeMap::new(),
metadata_piece_payloads: BTreeMap::new(),
creation_date: None,
comment: None,
dht_nodes: initial_bt_dht_nodes(&hinted_dht_nodes),
files: Vec::new(),
trackers,
peers: peer_hints,
}
}
/// Returns the built-in fallback DHT router list.
pub(in crate::dispatcher) fn default_bt_dht_nodes() -> Vec<String> {
vec![
"router.bittorrent.com:6881".to_owned(),
"dht.transmissionbt.com:6881".to_owned(),
"router.utorrent.com:6881".to_owned(),
]
}
/// Chooses explicit DHT nodes when present and otherwise falls back to router defaults.
pub(in crate::dispatcher) fn initial_bt_dht_nodes(explicit_nodes: &[String]) -> Vec<String> {
if explicit_nodes.is_empty() {
default_bt_dht_nodes()
} else {
explicit_nodes.to_vec()
}
}
/// Returns the BEP 9 metadata piece size used by aria2-compatible peers.
pub(in crate::dispatcher) const BT_METADATA_PIECE_LENGTH: u64 = 16 * 1024;
/// Returns the number of metadata pieces needed for one BEP 9 payload size.
pub(in crate::dispatcher) fn bt_metadata_piece_count(metadata_size: u32) -> u32 {
if metadata_size == 0 {
return 0;
}
metadata_size.div_ceil(BT_METADATA_PIECE_LENGTH as u32)
}
/// Returns the byte length of one metadata piece within the BEP 9 payload.
pub(in crate::dispatcher) fn bt_metadata_piece_span(metadata_size: u32, piece_index: u32) -> usize {
let piece_start = u64::from(piece_index).saturating_mul(BT_METADATA_PIECE_LENGTH);
let metadata_size = u64::from(metadata_size);
if piece_start >= metadata_size {
return 0;
}
let remaining = metadata_size.saturating_sub(piece_start);
usize::try_from(remaining.min(BT_METADATA_PIECE_LENGTH)).unwrap_or(usize::MAX)
}
/// Returns a stable runtime key for one BT peer endpoint.
pub(in crate::dispatcher) fn bt_peer_metadata_key(peer: &BtPeerInfo) -> String {
format!("{}:{}", peer.ip, peer.port)
}
/// Merges BT tracker snapshots by URL while preserving any existing runtime rows.
pub(in crate::dispatcher) fn merge_bt_trackers(
existing: &mut Vec<BtTrackerInfo>,
incoming: Vec<BtTrackerInfo>,
) {
for tracker in incoming {
if existing.iter().any(|current| current.url == tracker.url) {
continue;
}
existing.push(tracker);
}
}
/// Merges BT peer snapshots while preserving existing runtime rows.
pub(in crate::dispatcher) fn merge_bt_peers(
existing: &mut Vec<BtPeerInfo>,
incoming: Vec<BtPeerInfo>,
) {
for peer in incoming {
if let Some(current) = existing.iter_mut().find(|candidate| {
candidate.peer_id.as_deref() == peer.peer_id.as_deref()
|| (candidate.ip == peer.ip && candidate.port == peer.port)
}) {
*current = peer;
} else {
existing.push(peer);
}
}
}
/// Promotes a metadata-only magnet runtime into a full torrent-backed BT session when ready.
pub(in crate::dispatcher) fn try_promote_bt_metadata(
group: &mut RequestGroup,
) -> Result<bool, RpcError> {
let Some(bt) = group.bt().cloned() else {
return Err(RpcError::unsupported(
"metadata promotion requires bt runtime state",
));
};
if !bt.metadata_only {
return Ok(false);
}
let Some(metadata_size) = bt.metadata_size.filter(|size| *size > 0) else {
return Ok(false);
};
let piece_count = bt_metadata_piece_count(metadata_size);
if piece_count == 0 {
return Ok(false);
}
let mut metadata_bytes =
Vec::with_capacity(usize::try_from(metadata_size).unwrap_or(usize::MAX));
for piece in 0..piece_count {
let Some(payload) = bt.metadata_piece_payloads.get(&piece) else {
return Ok(false);
};
let expected_len = bt_metadata_piece_span(metadata_size, piece);
if payload.len() < expected_len {
return Ok(false);
}
metadata_bytes.extend_from_slice(&payload[..expected_len]);
}
metadata_bytes.truncate(usize::try_from(metadata_size).unwrap_or(usize::MAX));
let metadata = parse_torrent_metadata(&metadata_bytes).map_err(|error| {
RpcError::unsupported(&format!("invalid magnet metadata payload: {error}"))
})?;
let parsed_info_hash = metadata
.info
.hash
.as_ref()
.map(|hash| hash.info_hash_hex.to_ascii_uppercase())
.unwrap_or_default();
if parsed_info_hash.is_empty() {
return Err(RpcError::unsupported(
"promoted torrent metadata did not expose an info hash",
));
}
if !bt.info_hash.is_empty() && !parsed_info_hash.eq_ignore_ascii_case(&bt.info_hash) {
return Err(RpcError::unsupported(&format!(
"magnet metadata info hash mismatch: expected {}, got {parsed_info_hash}",
bt.info_hash
)));
}
let mut promoted = build_bt_runtime_state(&metadata);
promoted.magnet_uri = bt.magnet_uri.clone();
promoted.metadata_size = Some(metadata_size);
promoted.metadata_extension_ids = bt.metadata_extension_ids.clone();
promoted.metadata_piece_payloads = bt.metadata_piece_payloads.clone();
promoted.dht_nodes = bt.dht_nodes.clone();
merge_bt_dht_nodes(&mut promoted.dht_nodes, metadata.dht_nodes.iter().cloned());
merge_bt_trackers(&mut promoted.trackers, bt.trackers.clone());
let metadata_peers = std::mem::take(&mut promoted.peers);
promoted.peers = bt.peers.clone();
merge_bt_peers(&mut promoted.peers, metadata_peers);
group.set_bt(promoted);
group.set_total_length(metadata.total_length());
group.set_piece_length(metadata.info.piece_length.max(1));
group.set_completed_length(0);
group.clear_piece_availability();
group.clear_segment_assignments();
*group.piece_map_mut() = PieceMap::new();
for piece in &metadata.pieces {
group.set_piece_state(PieceId(piece.index), PieceState::Pending);
}
Ok(true)
}
/// Computes the effective total length visible to BitTorrent runtime reporting.
pub(in crate::dispatcher) fn bt_runtime_total_length(group: &RequestGroup) -> u64 {
group.total_length().max(
group
.bt()
.map(BtRuntimeState::selected_or_all_total_length)
.unwrap_or_default(),
)
}
/// Computes the number of pieces required to cover a torrent payload.
pub(in crate::dispatcher) fn bt_piece_count(total_length: u64, piece_length: u64) -> usize {
if total_length == 0 {
0
} else {
total_length.div_ceil(piece_length.max(1)) as usize
}
}
/// Computes the byte span represented by a single BitTorrent piece.
pub(in crate::dispatcher) fn bt_piece_span_bytes(
piece: PieceId,
piece_length: u64,
total_length: u64,
) -> u64 {
let piece_length = piece_length.max(1);
if total_length == 0 {
return piece_length;
}
let start = u64::from(piece.0).saturating_mul(piece_length);
total_length.saturating_sub(start).min(piece_length)
}
/// Sums the verified length implied by the request group's piece map.
pub(in crate::dispatcher) fn bt_verified_length(
group: &RequestGroup,
piece_length: u64,
total_length: u64,
) -> u64 {
group
.piece_map()
.iter()
.filter(|(_, state)| **state == PieceState::Verified)
.map(|(piece, _)| bt_piece_span_bytes(*piece, piece_length, total_length))
.sum()
}
/// Returns whether a BT peer row is usable for an outbound peer-wire exchange.
pub(in crate::dispatcher) fn bt_peer_is_connectable(peer: &BtPeerInfo) -> bool {
!peer.ip.trim().is_empty() && peer.port != 0
}
@@ -0,0 +1,240 @@
use super::{
BtPeerInfo, BtRuntimeState, DhtMessageModel, DhtNodeModel, Digest, DownloadId, RequestGroup,
RpcError, rpc_bt_info_hash,
};
/// Builds the outbound DHT `get_peers` message and selected target node.
pub(in crate::dispatcher) fn build_dht_get_peers_request(
group: &RequestGroup,
) -> Result<(DhtNodeModel, DhtMessageModel), RpcError> {
let bt = group
.bt()
.ok_or_else(|| RpcError::unsupported("dht get_peers requires bt runtime state"))?;
let node = pick_bt_dht_node(bt.dht_nodes())?;
let info_hash = resolve_bt_info_hash(group, bt)?;
Ok((
node,
DhtMessageModel::get_peers_query(
b"gp".to_vec(),
rpc_bt_local_node_id(group.gid()),
info_hash,
),
))
}
/// Builds the outbound DHT `ping` message and selected target node.
pub(in crate::dispatcher) fn build_dht_ping_request(
group: &RequestGroup,
) -> Result<(DhtNodeModel, DhtMessageModel), RpcError> {
let bt = group
.bt()
.ok_or_else(|| RpcError::unsupported("dht ping requires bt runtime state"))?;
let node = pick_bt_dht_node(bt.dht_nodes())?;
Ok((
node,
DhtMessageModel::ping_query(b"pi".to_vec(), rpc_bt_local_node_id(group.gid())),
))
}
/// Builds the outbound DHT `find_node` message and selected target node.
pub(in crate::dispatcher) fn build_dht_find_node_request(
group: &RequestGroup,
) -> Result<(DhtNodeModel, DhtMessageModel), RpcError> {
let bt = group
.bt()
.ok_or_else(|| RpcError::unsupported("dht find_node requires bt runtime state"))?;
let node = pick_bt_dht_node(bt.dht_nodes())?;
let target = resolve_bt_info_hash(group, bt)?;
Ok((
node,
DhtMessageModel::find_node_query(b"fn".to_vec(), rpc_bt_local_node_id(group.gid()), target),
))
}
/// Builds the outbound DHT `announce_peer` message and selected target node.
pub(in crate::dispatcher) fn build_dht_announce_peer_request(
group: &RequestGroup,
) -> Result<(DhtNodeModel, DhtMessageModel), RpcError> {
let bt = group
.bt()
.ok_or_else(|| RpcError::unsupported("dht announce_peer requires bt runtime state"))?;
let node = pick_bt_dht_node(bt.dht_nodes())?;
let token = group
.dht_token()
.map(|token| token.to_vec())
.ok_or_else(|| {
RpcError::unsupported("dht announce_peer requires token from prior get_peers")
})?;
let info_hash = resolve_bt_info_hash(group, bt)?;
Ok((
node,
DhtMessageModel::announce_peer_query(
b"ap".to_vec(),
rpc_bt_local_node_id(group.gid()),
info_hash,
6881,
token,
false,
),
))
}
/// Chooses the first parseable DHT node entry from runtime state.
pub(in crate::dispatcher) fn pick_bt_dht_node(nodes: &[String]) -> Result<DhtNodeModel, RpcError> {
if nodes.is_empty() {
return Err(RpcError::unsupported(
"dht get_peers requires at least one dht node",
));
}
let mut last_error = None;
for node in nodes {
match parse_dht_node_spec(node) {
Ok(parsed) => return Ok(parsed),
Err(error) => last_error = Some(error.message),
}
}
Err(RpcError::unsupported(&format!(
"dht get_peers found no valid dht nodes in runtime state{}",
last_error
.map(|message| format!(": {message}"))
.unwrap_or_default()
)))
}
/// Resolves the BitTorrent info hash bytes required by DHT and peer-wire requests.
pub(in crate::dispatcher) fn resolve_bt_info_hash(
group: &RequestGroup,
bt: &BtRuntimeState,
) -> Result<Vec<u8>, RpcError> {
let info_hash = if !bt.info_hash.is_empty() {
bt.info_hash.clone()
} else {
rpc_bt_info_hash(group.uri()).unwrap_or_default()
};
decode_hex_string_exact(&info_hash, 20, "dht get_peers info hash")
.map_err(|error| RpcError::unsupported(&error))
}
/// Parses a `host:port` DHT node spec into a transport model.
pub(in crate::dispatcher) fn parse_dht_node_spec(raw: &str) -> Result<DhtNodeModel, RpcError> {
let (address, port_raw) = raw
.rsplit_once(':')
.ok_or_else(|| RpcError::unsupported("dht node entry must use host:port format"))?;
let port = port_raw
.parse::<u16>()
.map_err(|_| RpcError::unsupported("dht node port must be a valid u16"))?;
Ok(DhtNodeModel {
node_id: String::new(),
address: address.to_owned(),
port,
})
}
/// Derives a deterministic local DHT node ID from a download GID.
pub(in crate::dispatcher) fn rpc_bt_local_node_id(gid: DownloadId) -> Vec<u8> {
let gid_hex = format!("{:040x}", gid.as_u64());
decode_hex_string_exact(&gid_hex, 20, "local dht node id").unwrap_or_else(|_| vec![0_u8; 20])
}
/// Decodes a fixed-width hexadecimal string into raw bytes.
pub(in crate::dispatcher) fn decode_hex_string_exact(
raw: &str,
expected_len: usize,
label: &str,
) -> Result<Vec<u8>, String> {
if raw.len() != expected_len * 2 {
return Err(format!(
"{label} must be {} hex characters",
expected_len * 2
));
}
let mut bytes = Vec::with_capacity(expected_len);
for pair in raw.as_bytes().chunks_exact(2) {
let hi = decode_hex_nibble(pair[0])
.ok_or_else(|| format!("{label} contains non-hex characters"))?;
let lo = decode_hex_nibble(pair[1])
.ok_or_else(|| format!("{label} contains non-hex characters"))?;
bytes.push((hi << 4) | lo);
}
Ok(bytes)
}
/// Decodes a single ASCII hex nibble.
pub(in crate::dispatcher) fn decode_hex_nibble(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,
}
}
/// Parses compact peer payloads returned by DHT `get_peers`.
pub(in crate::dispatcher) fn parse_dht_compact_peers(
values: &[Vec<u8>],
) -> Result<Vec<BtPeerInfo>, String> {
let mut peers = Vec::new();
for value in values {
if value.len() % 6 != 0 {
return Err("compact peer list length must be a multiple of 6".to_owned());
}
for chunk in value.chunks_exact(6) {
peers.push(BtPeerInfo {
peer_id: None,
ip: format!("{}.{}.{}.{}", chunk[0], chunk[1], chunk[2], chunk[3]),
port: u16::from_be_bytes([chunk[4], chunk[5]]),
client_name: None,
interested: false,
choked: false,
download_speed: 0,
upload_speed: 0,
seeder: false,
});
}
}
Ok(peers)
}
/// Parses compact DHT node payload bytes into `host:port` strings.
pub(in crate::dispatcher) fn parse_dht_compact_nodes(
raw: Option<&[u8]>,
) -> Result<Vec<String>, String> {
let Some(raw) = raw else {
return Ok(Vec::new());
};
if raw.len() % 26 != 0 {
return Err("compact dht node list length must be a multiple of 26".to_owned());
}
let mut nodes = Vec::new();
for chunk in raw.chunks_exact(26) {
let ip = format!("{}.{}.{}.{}", chunk[20], chunk[21], chunk[22], chunk[23]);
let port = u16::from_be_bytes([chunk[24], chunk[25]]);
nodes.push(format!("{ip}:{port}"));
}
Ok(nodes)
}
/// Appends newly discovered DHT nodes while preserving existing order.
pub(in crate::dispatcher) fn merge_bt_dht_nodes<I>(existing: &mut Vec<String>, discovered: I)
where
I: IntoIterator<Item = String>,
{
for node in discovered {
if !existing.iter().any(|current| current == &node) {
existing.push(node);
}
}
}
/// Moves a successfully used DHT node to the front of the runtime node list.
pub(in crate::dispatcher) fn promote_bt_dht_node(existing: &mut Vec<String>, node: &DhtNodeModel) {
let entry = format!("{}:{}", node.address, node.port);
if let Some(index) = existing.iter().position(|current| current == &entry) {
if index > 0 {
let value = existing.remove(index);
existing.insert(0, value);
}
} else {
existing.insert(0, entry);
}
}
@@ -0,0 +1,360 @@
use super::{
BTreeMap, BTreeSet, BtPeerInfo, Digest, PeerWireBlockRequestModel,
PeerWireExtensionHandshakeModel, PeerWireHandshakeModel, PeerWireMessageKind,
PeerWireMetadataMessageModel, PeerWirePieceBlockModel, PeerWireTransportRequest,
PeerWireTransportResponse, PieceState, RequestGroup, RpcError, TorrentMessageModel,
TransportEndpoint, TransportScheme, bt_metadata_piece_count, bt_peer_metadata_key,
bt_piece_span_bytes, bt_runtime_total_length, hex_string, resolve_bt_info_hash,
rpc_bt_local_node_id,
};
/// Identifies the peer selected for a peer-wire compatibility exchange.
#[derive(Clone, Debug)]
pub(in crate::dispatcher) struct PeerWirePeerTarget {
/// Original peer index inside the runtime peer list.
pub(in crate::dispatcher) index: usize,
/// Peer runtime snapshot used to build the outbound request.
pub(in crate::dispatcher) peer: BtPeerInfo,
}
/// Holds the outbound peer-wire request and bookkeeping for a compatibility probe.
#[derive(Clone, Debug)]
pub(in crate::dispatcher) struct PeerWireExchangePlan {
/// Peer list index that should receive the parsed response data.
pub(in crate::dispatcher) peer_index: usize,
/// Expected info hash validated against the peer handshake.
pub(in crate::dispatcher) info_hash: [u8; 20],
/// Transport payload sent to the peer.
pub(in crate::dispatcher) request: PeerWireTransportRequest,
/// Optional block request emitted after the handshake.
pub(in crate::dispatcher) block_request: Option<PeerWireBlockRequestModel>,
/// Optional known `ut_metadata` extension id for the selected peer.
pub(in crate::dispatcher) metadata_extension_id: Option<u8>,
}
/// Captures peer-wire handshake and frame state recovered from a peer response.
#[derive(Clone, Debug, Default)]
pub(in crate::dispatcher) struct PeerWireExchangeResponseModel {
/// Remote peer ID emitted by the handshake when present.
pub(in crate::dispatcher) peer_id: Option<String>,
/// Parsed remote extended handshake, when observed.
pub(in crate::dispatcher) extension_handshake: Option<PeerWireExtensionHandshakeModel>,
/// Latest observed choke state.
pub(in crate::dispatcher) peer_choked: Option<bool>,
/// Latest observed interest state.
pub(in crate::dispatcher) peer_interested: Option<bool>,
/// Piece set explicitly advertised by a bitfield frame.
pub(in crate::dispatcher) bitfield_pieces: Option<BTreeSet<u32>>,
/// Aggregate set of pieces implied by bitfield, have, and piece frames.
pub(in crate::dispatcher) available_pieces: BTreeSet<u32>,
/// Piece payload frames recovered from the response.
pub(in crate::dispatcher) pieces: Vec<PeerWirePieceBlockModel>,
/// Metadata payloads recovered from BEP 9 messages.
pub(in crate::dispatcher) metadata_messages: Vec<PeerWireMetadataMessageModel>,
}
/// Builds the outbound peer-wire request for the current BitTorrent runtime state.
pub(in crate::dispatcher) fn build_peer_wire_exchange_plan(
group: &RequestGroup,
) -> Result<PeerWireExchangePlan, RpcError> {
let bt = group
.bt()
.ok_or_else(|| RpcError::unsupported("peer-wire exchange requires bt runtime state"))?;
let target = pick_bt_peer_target(&bt.peers)?;
let info_hash_vec = resolve_bt_info_hash(group, bt)?;
let info_hash: [u8; 20] = info_hash_vec
.as_slice()
.try_into()
.map_err(|_| RpcError::unsupported("peer-wire exchange info hash must be 20 bytes"))?;
let peer_id_vec = rpc_bt_local_node_id(group.gid());
let peer_id: [u8; 20] = peer_id_vec
.clone()
.try_into()
.map_err(|_| RpcError::unsupported("peer-wire exchange peer id must be 20 bytes"))?;
let peer_key = bt_peer_metadata_key(&target.peer);
let metadata_extension_id = bt.metadata_extension_ids.get(&peer_key).copied();
let metadata_request_piece = if bt.metadata_only {
match (metadata_extension_id, bt.metadata_size) {
(Some(_), Some(metadata_size)) if metadata_size > 0 => {
let piece_count = bt_metadata_piece_count(metadata_size);
(0..piece_count).find(|piece| !bt.metadata_piece_payloads.contains_key(piece))
}
_ => None,
}
} else {
None
};
let mut handshake = PeerWireHandshakeModel::new(info_hash, peer_id);
handshake.reserved[5] |= 0x10;
let mut payload = handshake.serialize();
let mut block_request = None;
if bt.metadata_only {
let extension_handshake = PeerWireExtensionHandshakeModel {
extensions: BTreeMap::from([("ut_metadata".to_owned(), 1_u8)]),
client_name: Some("aria2-rust-pro".to_owned()),
metadata_size: None,
request_queue: Some(16),
};
payload.extend_from_slice(
&TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Extension(
extension_handshake.to_peer_wire_message(),
))
.serialize_peer_wire_frame()
.map_err(|error| {
RpcError::unsupported(&format!(
"peer-wire extension handshake serialization failed: {error}"
))
})?,
);
if let (Some(extension_message_id), Some(piece)) =
(metadata_extension_id, metadata_request_piece)
{
payload.extend_from_slice(
&TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Extension(
PeerWireMetadataMessageModel::request(piece)
.to_peer_wire_message(extension_message_id),
))
.serialize_peer_wire_frame()
.map_err(|error| {
RpcError::unsupported(&format!(
"peer-wire metadata request serialization failed: {error}"
))
})?,
);
}
} else {
let piece_length = group.piece_length().max(1);
let total_length = bt_runtime_total_length(group);
let requestable = group.bt_requestable_piece_ids(false, 8);
let availability = group.piece_availability();
let selected_piece = requestable
.iter()
.copied()
.find(|piece| availability.get(piece).copied().unwrap_or(0) > 0)
.or_else(|| requestable.first().copied());
block_request = selected_piece.map(|piece| PeerWireBlockRequestModel {
piece_index: piece.0,
block_offset: 0,
block_length: bt_piece_span_bytes(piece, piece_length, total_length)
.min(16_u64 * 1024)
.max(1) as u32,
});
if block_request.is_some() {
payload.extend_from_slice(
&TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Interested)
.serialize_peer_wire_frame()
.map_err(|error| {
RpcError::unsupported(&format!(
"peer-wire interested frame serialization failed: {error}"
))
})?,
);
}
if let Some(block_request) = &block_request {
payload.extend_from_slice(
&TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Request(
block_request.clone(),
))
.serialize_peer_wire_frame()
.map_err(|error| {
RpcError::unsupported(&format!(
"peer-wire request frame serialization failed: {error}"
))
})?,
);
}
}
Ok(PeerWireExchangePlan {
peer_index: target.index,
info_hash,
request: PeerWireTransportRequest {
endpoint: TransportEndpoint {
scheme: TransportScheme::BitTorrent,
address: format!("{}:{}", target.peer.ip, target.peer.port),
},
info_hash: info_hash_vec,
peer_id: peer_id_vec,
payload,
},
block_request,
metadata_extension_id,
})
}
/// Chooses the best available peer target for a peer-wire exchange.
pub(super) fn pick_bt_peer_target(peers: &[BtPeerInfo]) -> Result<PeerWirePeerTarget, RpcError> {
if peers.is_empty() {
return Err(RpcError::unsupported(
"peer-wire exchange requires at least one bt peer",
));
}
let mut last_error = None;
let mut fallback = None;
for (index, peer) in peers.iter().enumerate() {
if peer.ip.trim().is_empty() {
last_error = Some("peer ip must not be empty".to_owned());
continue;
}
if peer.port == 0 {
last_error = Some("peer port must be non-zero".to_owned());
continue;
}
let target = PeerWirePeerTarget {
index,
peer: peer.clone(),
};
if !peer.choked {
return Ok(target);
}
if fallback.is_none() {
fallback = Some(target);
}
}
if let Some(target) = fallback {
return Ok(target);
}
Err(RpcError::unsupported(&format!(
"peer-wire exchange found no valid bt peers in runtime state{}",
last_error
.map(|message| format!(": {message}"))
.unwrap_or_default()
)))
}
/// Parses a peer-wire transport payload into normalized runtime update data.
pub(in crate::dispatcher) fn parse_peer_wire_exchange_response(
response: &PeerWireTransportResponse,
expected_info_hash: &[u8; 20],
known_metadata_extension_id: Option<u8>,
) -> Result<PeerWireExchangeResponseModel, RpcError> {
let mut parsed = PeerWireExchangeResponseModel::default();
let mut cursor = 0;
let mut negotiated_metadata_extension_id = known_metadata_extension_id;
if response.payload.first().copied() == Some(19) {
let (handshake, consumed) = PeerWireHandshakeModel::parse_prefix(&response.payload)
.map_err(|error| {
RpcError::unsupported(&format!("invalid peer-wire handshake: {error}"))
})?;
if handshake.info_hash != *expected_info_hash {
return Err(RpcError::unsupported(
"peer-wire handshake info hash did not match download runtime state",
));
}
parsed.peer_id = Some(hex_string(&handshake.peer_id).to_ascii_lowercase());
cursor = consumed;
}
while cursor < response.payload.len() {
let (frame, consumed) = TorrentMessageModel::parse_peer_wire_frame(
&response.payload[cursor..],
)
.map_err(|error| RpcError::unsupported(&format!("invalid peer-wire frame: {error}")))?;
cursor = cursor.saturating_add(consumed);
match frame.peer_wire_kind().map_err(|error| {
RpcError::unsupported(&format!("invalid peer-wire message: {error}"))
})? {
PeerWireMessageKind::Choke => parsed.peer_choked = Some(true),
PeerWireMessageKind::Unchoke => parsed.peer_choked = Some(false),
PeerWireMessageKind::Interested => parsed.peer_interested = Some(true),
PeerWireMessageKind::NotInterested => parsed.peer_interested = Some(false),
PeerWireMessageKind::Have(piece) => {
parsed.available_pieces.insert(piece);
}
PeerWireMessageKind::Bitfield(bitfield) => {
let available = bitfield
.to_piece_flags(bitfield.piece_capacity())
.into_iter()
.enumerate()
.filter_map(|(piece, has_piece)| has_piece.then_some(piece as u32))
.collect::<BTreeSet<_>>();
parsed.available_pieces.extend(available.iter().copied());
parsed.bitfield_pieces = Some(available);
}
PeerWireMessageKind::Piece(piece) => {
parsed.available_pieces.insert(piece.piece_index);
parsed.pieces.push(piece);
}
PeerWireMessageKind::Extension(message) => {
if message.extension_message_id == 0 {
let handshake = PeerWireExtensionHandshakeModel::from_peer_wire_message(
&message,
)
.map_err(|error| {
RpcError::unsupported(&format!(
"invalid peer-wire extended handshake: {error}"
))
})?;
negotiated_metadata_extension_id = handshake
.ut_metadata_id()
.or(negotiated_metadata_extension_id);
parsed.extension_handshake = Some(handshake);
} else if negotiated_metadata_extension_id
.is_some_and(|extension_id| extension_id == message.extension_message_id)
{
let metadata_message = PeerWireMetadataMessageModel::from_peer_wire_message(
&message,
negotiated_metadata_extension_id.expect("checked is_some above"),
)
.map_err(|error| {
RpcError::unsupported(&format!(
"invalid peer-wire ut_metadata payload: {error}"
))
})?;
parsed.metadata_messages.push(metadata_message);
}
}
PeerWireMessageKind::KeepAlive
| PeerWireMessageKind::Request(_)
| PeerWireMessageKind::Cancel(_)
| PeerWireMessageKind::Port(_)
| PeerWireMessageKind::Unknown(_) => {}
}
}
Ok(parsed)
}
/// Computes how many verified bytes overlap a requested byte range.
pub(in crate::dispatcher) fn verified_length_for_range(
group: &RequestGroup,
range_start: u64,
range_length: u64,
piece_length: u64,
total_length: u64,
) -> u64 {
if range_length == 0 || piece_length == 0 || total_length == 0 {
return 0;
}
let range_end = range_start.saturating_add(range_length).min(total_length);
if range_end <= range_start {
return 0;
}
group
.piece_map()
.iter()
.filter(|(_, state)| **state == PieceState::Verified)
.map(|(piece, _)| {
let piece_start = u64::from(piece.0).saturating_mul(piece_length);
let piece_end = piece_start
.saturating_add(bt_piece_span_bytes(*piece, piece_length, total_length))
.min(total_length);
let overlap_start = piece_start.max(range_start);
let overlap_end = piece_end.min(range_end);
overlap_end.saturating_sub(overlap_start)
})
.sum()
}
/// Returns whether a peer-wire bitfield covers every expected piece index.
pub(in crate::dispatcher) fn peer_wire_bitfield_is_complete(
pieces: &BTreeSet<u32>,
expected_piece_count: usize,
) -> bool {
expected_piece_count > 0
&& pieces.len() >= expected_piece_count
&& (0..expected_piece_count as u32).all(|piece| pieces.contains(&piece))
}
@@ -0,0 +1,91 @@
use super::{
AtomicU64, BtRuntimeCoordinatorAction, BtRuntimeCoordinatorStepReport,
BtRuntimeCoordinatorStepStatus, Digest, Ordering, RpcError, Sha1, SystemTime, UNIX_EPOCH,
};
/// Converts a successful or failed coordinator action result into a stable report row.
pub(in crate::dispatcher) fn push_bt_runtime_coordinator_result(
steps: &mut Vec<BtRuntimeCoordinatorStepReport>,
action: BtRuntimeCoordinatorAction,
result: Result<(), RpcError>,
) {
match result {
Ok(()) => steps.push(BtRuntimeCoordinatorStepReport {
action,
status: BtRuntimeCoordinatorStepStatus::Executed,
detail: None,
}),
Err(error) => steps.push(BtRuntimeCoordinatorStepReport {
action,
status: BtRuntimeCoordinatorStepStatus::Failed,
detail: Some(error.message),
}),
}
}
/// Builds a skipped coordinator action report row with a concise reason.
pub(in crate::dispatcher) fn skipped_bt_runtime_coordinator_step(
action: BtRuntimeCoordinatorAction,
detail: &str,
) -> BtRuntimeCoordinatorStepReport {
BtRuntimeCoordinatorStepReport {
action,
status: BtRuntimeCoordinatorStepStatus::Skipped,
detail: Some(detail.to_owned()),
}
}
/// Monotonic nonce mixed into generated session IDs.
static NEXT_SESSION_ID_NONCE: AtomicU64 = AtomicU64::new(1);
/// Generates a stable hex session identifier for RPC clients.
pub(in crate::dispatcher) fn generate_session_id() -> String {
let now_nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_nanos();
let nonce = NEXT_SESSION_ID_NONCE.fetch_add(1, Ordering::Relaxed);
let mut sha1 = Sha1::new();
sha1.update(now_nanos.to_le_bytes());
sha1.update(std::process::id().to_le_bytes());
sha1.update(nonce.to_le_bytes());
let digest = sha1.finalize();
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
/// Returns the upstream-style enabled feature list reported by `getVersion`.
pub(in crate::dispatcher) fn rpc_enabled_features() -> &'static [&'static str] {
&[
"Async DNS",
"BitTorrent",
"GZip",
"HTTPS",
"Message Digest",
"Metalink",
"XML-RPC",
"SFTP",
]
}
/// Encodes bytes as an uppercase hexadecimal string.
pub(in crate::dispatcher) fn hex_string(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push_str(&format!("{byte:02X}"));
}
out
}
/// Formats the share ratio text expected by aria2 RPC payloads.
pub(in crate::dispatcher) fn rpc_share_ratio_text(share_ratio_milli: Option<u64>) -> String {
share_ratio_milli
.map(|milli| format!("{:.3}", milli as f64 / 1000.0))
.unwrap_or_else(|| "0.000".to_owned())
}
/// Formats the share time text expected by aria2 RPC payloads.
pub(in crate::dispatcher) fn rpc_share_time_text(
snapshot: &aria2_rust_pro_core::ProgressSnapshot,
) -> String {
snapshot.share_time_secs.unwrap_or(0).to_string()
}
@@ -0,0 +1,74 @@
use super::{BTreeSet, Digest, RequestGroup};
/// Applies a `select-file` option value to BitTorrent file selection state.
pub(in crate::dispatcher) fn apply_bt_select_file_option(
group: &mut RequestGroup,
select_file: &str,
) -> Result<(), String> {
let Some(mut bt_state) = group.bt().cloned() else {
return Ok(());
};
if bt_state.files.is_empty() {
return Ok(());
}
let selected_indexes = parse_bt_select_file_indexes(select_file, bt_state.files.len())?;
for (index, file) in bt_state.files.iter_mut().enumerate() {
file.selected = selected_indexes.contains(&(index + 1));
}
group.set_bt(bt_state);
Ok(())
}
/// Parses aria2-style `select-file` syntax into a set of selected file indexes.
pub(in crate::dispatcher) fn parse_bt_select_file_indexes(
select_file: &str,
file_count: usize,
) -> Result<BTreeSet<usize>, String> {
let mut selected = BTreeSet::new();
let trimmed = select_file.trim();
if trimmed.is_empty() {
return Err("empty value".to_owned());
}
for token in trimmed
.split(',')
.map(str::trim)
.filter(|token| !token.is_empty())
{
if let Some((start_raw, end_raw)) = token.split_once('-') {
let start = start_raw
.trim()
.parse::<usize>()
.map_err(|_| format!("invalid start index `{start_raw}`"))?;
let end = end_raw
.trim()
.parse::<usize>()
.map_err(|_| format!("invalid end index `{end_raw}`"))?;
if start == 0 || end == 0 {
return Err("indexes are 1-based".to_owned());
}
let (lo, hi) = if start <= end {
(start, end)
} else {
(end, start)
};
if hi > file_count {
return Err(format!("index {hi} out of range 1..={file_count}"));
}
for index in lo..=hi {
selected.insert(index);
}
continue;
}
let index = token
.parse::<usize>()
.map_err(|_| format!("invalid index `{token}`"))?;
if index == 0 {
return Err("indexes are 1-based".to_owned());
}
if index > file_count {
return Err(format!("index {index} out of range 1..={file_count}"));
}
selected.insert(index);
}
Ok(selected)
}
@@ -0,0 +1,40 @@
use super::{RequestGroup, RpcError, TrackerRequestModel, rpc_bt_info_hash};
/// Builds a tracker announce request from the current BitTorrent runtime snapshot.
pub(in crate::dispatcher) fn build_tracker_request(
group: &RequestGroup,
) -> Result<TrackerRequestModel, RpcError> {
let bt = group
.bt()
.ok_or_else(|| RpcError::unsupported("tracker announce requires bt runtime state"))?;
let announce_url = bt
.trackers
.first()
.map(|tracker| tracker.url.clone())
.ok_or_else(|| RpcError::unsupported("tracker announce requires at least one tracker"))?;
let info_hash = if !bt.info_hash.is_empty() {
bt.info_hash.clone()
} else {
rpc_bt_info_hash(group.uri()).unwrap_or_default()
};
if info_hash.len() != 40 || !info_hash.chars().all(|ch| ch.is_ascii_hexdigit()) {
return Err(RpcError::unsupported(
"tracker announce requires a 40-character hex info hash",
));
}
let selected_total = group
.bt_selected_total_length()
.unwrap_or_else(|| group.total_length());
Ok(TrackerRequestModel {
announce_url,
info_hash,
peer_id: format!("{:040x}", group.gid().as_u64()),
port: 6881,
uploaded: group.upload_length(),
downloaded: group.completed_length(),
left: selected_total.saturating_sub(group.completed_length()),
event: Some("started".to_owned()),
compact: true,
numwant: Some(50),
})
}
@@ -0,0 +1,264 @@
use crate::{
handlers::RpcHandlerContext,
jsonrpc::{JsonRpcRequest, JsonRpcResponse},
model::{RpcAuthContext, RpcError, RpcMeta, RpcValue},
router::{RpcDispatchRequest, RpcDispatchResult},
xmlrpc::{XmlRpcMember, XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, XmlRpcValue},
};
use super::{
InProcessRpcDispatcher,
faults::{rpc_error_value, xmlrpc_error_value, xmlrpc_fault_from_error, xmlrpc_fault_value},
helpers::xmlrpc_member_value,
};
impl InProcessRpcDispatcher {
#[must_use]
/// Dispatches a JSON-RPC request through the in-process engine.
pub fn dispatch_json(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
match request.method.as_str() {
"aria2.addUri" => self.handle_add_uri(request),
"aria2.addTorrent" => self.handle_add_torrent(request),
"aria2.addMetalink" => self.handle_add_metalink(request),
"aria2.tellStatus" => self.handle_tell_status(request),
"aria2.tellWaiting" => self.handle_tell_waiting(request),
"aria2.tellStopped" => self.handle_tell_stopped(request),
"aria2.pause" => self.handle_state_transition(
request,
"aria2.pause",
aria2_rust_pro_core::DownloadEngine::pause,
),
"aria2.forcePause" => self.handle_state_transition(
request,
"aria2.forcePause",
aria2_rust_pro_core::DownloadEngine::pause,
),
"aria2.unpause" => self.handle_state_transition(
request,
"aria2.unpause",
aria2_rust_pro_core::DownloadEngine::resume,
),
"aria2.remove" => self.handle_state_transition(
request,
"aria2.remove",
aria2_rust_pro_core::DownloadEngine::remove,
),
"aria2.forceRemove" => self.handle_state_transition(
request,
"aria2.forceRemove",
aria2_rust_pro_core::DownloadEngine::remove,
),
"aria2.pauseAll" => self.handle_pause_all(request),
"aria2.forcePauseAll" => self.handle_pause_all(request),
"aria2.unpauseAll" => self.handle_unpause_all(request),
"aria2.tellActive" => self.handle_tell_active(request),
"aria2.getGlobalStat" | "aria2.tellGlobalStat" => self.handle_tell_global_stat(request),
"aria2.getGlobalOption" => self.handle_get_global_option(request),
"aria2.changeGlobalOption" => self.handle_change_global_option(request),
"aria2.getOption" => self.handle_get_option(request),
"aria2.changeOption" => self.handle_change_option(request),
"aria2.getUris" => self.handle_get_uris(request),
"aria2.getFiles" => self.handle_get_files(request),
"aria2.getPeers" => self.handle_get_peers(request),
"aria2.getServers" => self.handle_get_servers(request),
"aria2.changePosition" => self.handle_change_position(request),
"aria2.changeUri" => self.handle_change_uri(request),
"aria2.purgeDownloadResult" => self.handle_purge_download_result(request),
"aria2.removeDownloadResult" => self.handle_remove_download_result(request),
"aria2.getSessionInfo" => self.handle_get_session_info(request),
"aria2.saveSession" => self.handle_save_session(request),
"aria2.shutdown" => self.handle_shutdown(request),
"aria2.forceShutdown" => self.handle_force_shutdown(request),
"system.multicall" | "aria2.multicall" => self.handle_multicall(request),
"aria2.getVersion" => JsonRpcResponse::success(request.id, self.rpc_version_payload()),
_ => {
let ctx = RpcHandlerContext {
auth: RpcAuthContext::default(),
meta: request.meta.clone(),
};
match self
.router
.dispatch(RpcDispatchRequest::Json(request.clone()), ctx)
{
RpcDispatchResult::Json(response) => response,
RpcDispatchResult::Xml(_) | RpcDispatchResult::Empty => {
JsonRpcResponse::success(request.id, RpcValue::Null)
}
}
}
}
}
#[must_use]
/// Dispatches an XML-RPC request through the in-process engine.
pub fn dispatch_xml(&mut self, request: XmlRpcMethodCall) -> XmlRpcMethodResponse {
if request.method_name == "aria2.getVersion" {
return XmlRpcMethodResponse {
value: Some(crate::xmlrpc::rpc_value_to_xmlrpc(
self.rpc_version_payload(),
)),
fault: None,
meta: RpcMeta::default(),
};
}
if request.method_name == "aria2.getSessionInfo" {
return XmlRpcMethodResponse {
value: Some(XmlRpcValue::Struct(vec![XmlRpcMember {
name: "sessionId".to_owned(),
value: XmlRpcValue::String(self.session_id.clone()),
}])),
fault: None,
meta: RpcMeta::default(),
};
}
if request.method_name == "system.multicall" {
return self.handle_xml_multicall(request);
}
let json_request = JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: request.method_name,
params: request
.params
.into_iter()
.map(|param| crate::xmlrpc::xmlrpc_value_to_rpc(param.value))
.collect(),
meta: request.meta,
};
let response = self.dispatch_json(json_request);
XmlRpcMethodResponse {
value: response.result.map(crate::xmlrpc::rpc_value_to_xmlrpc),
fault: response.error.map(xmlrpc_fault_from_error),
meta: RpcMeta::default(),
}
}
/// Dispatches XML-RPC multicall entries and wraps each response in upstream-compatible arrays.
fn handle_xml_multicall(&mut self, request: XmlRpcMethodCall) -> XmlRpcMethodResponse {
let Some(first) = request.params.first() else {
return XmlRpcMethodResponse {
value: None,
fault: Some(xmlrpc_fault_from_error(RpcError::invalid_params(
"system.multicall requires method specs",
))),
meta: RpcMeta::default(),
};
};
let XmlRpcValue::Array(method_specs) = &first.value else {
return XmlRpcMethodResponse {
value: None,
fault: Some(xmlrpc_fault_from_error(RpcError::invalid_params(
"system.multicall expected array of method specs",
))),
meta: RpcMeta::default(),
};
};
let mut results = Vec::with_capacity(method_specs.len());
for method_spec in method_specs {
let XmlRpcValue::Struct(spec) = method_spec else {
results.push(xmlrpc_error_value(RpcError::invalid_params(
"system.multicall expected struct.",
)));
continue;
};
let Some(XmlRpcValue::String(method_name)) = xmlrpc_member_value(spec, "methodName")
else {
results.push(xmlrpc_error_value(RpcError::invalid_params(
"Missing methodName.",
)));
continue;
};
if method_name == "system.multicall" {
results.push(xmlrpc_error_value(RpcError::invalid_params(
"Recursive system.multicall forbidden.",
)));
continue;
}
let params = match xmlrpc_member_value(spec, "params") {
Some(XmlRpcValue::Array(params)) => params
.iter()
.cloned()
.map(|value| XmlRpcParam { value })
.collect(),
_ => Vec::new(),
};
let response = self.dispatch_xml(XmlRpcMethodCall {
method_name: method_name.clone(),
params,
meta: request.meta.clone(),
});
if let Some(fault) = response.fault {
results.push(xmlrpc_fault_value(fault));
} else {
results.push(XmlRpcValue::Array(vec![
response.value.unwrap_or(XmlRpcValue::Nil),
]));
}
}
XmlRpcMethodResponse {
value: Some(XmlRpcValue::Array(results)),
fault: None,
meta: RpcMeta::default(),
}
}
/// Handles JSON-RPC multicall requests while preserving per-call result ordering.
fn handle_multicall(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
let Some(first) = request.params.first() else {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params("system.multicall requires method specs"),
);
};
let RpcValue::Array(method_specs) = first else {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params("system.multicall expected array of method specs"),
);
};
let mut results = Vec::with_capacity(method_specs.len());
for method_spec in method_specs {
let RpcValue::Object(spec) = method_spec else {
results.push(rpc_error_value(RpcError::invalid_params(
"system.multicall expected struct.",
)));
continue;
};
let Some(RpcValue::String(method_name)) = spec.get("methodName") else {
results.push(rpc_error_value(RpcError::invalid_params(
"Missing methodName.",
)));
continue;
};
if method_name == "system.multicall" || method_name == "aria2.multicall" {
results.push(rpc_error_value(RpcError::invalid_params(
"Recursive system.multicall forbidden.",
)));
continue;
}
let params = match spec.get("params") {
Some(RpcValue::Array(params)) => params.clone(),
_ => Vec::new(),
};
let response = self.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: method_name.clone(),
params,
meta: request.meta.clone(),
});
if let Some(error) = response.error {
results.push(rpc_error_value(error));
} else {
results.push(RpcValue::Array(vec![
response.result.unwrap_or(RpcValue::Null),
]));
}
}
JsonRpcResponse::success(request.id, RpcValue::Array(results))
}
}
@@ -0,0 +1,46 @@
use std::collections::BTreeMap;
use crate::{
model::{RpcError, RpcValue},
xmlrpc::{XmlRpcMember, XmlRpcValue},
};
/// Converts an RPC error into the JSON-RPC error-object shape used by multicall.
pub(super) fn rpc_error_value(error: RpcError) -> RpcValue {
RpcValue::Object(BTreeMap::from([
(
"code".to_owned(),
RpcValue::Number(i64::from(error.code as i32)),
),
("message".to_owned(), RpcValue::String(error.message)),
]))
}
/// Converts an RPC error into the XML-RPC fault-value shape used by multicall.
pub(super) fn xmlrpc_error_value(error: RpcError) -> XmlRpcValue {
xmlrpc_fault_value(xmlrpc_fault_from_error(error))
}
/// Serializes an XML-RPC fault struct into a value payload.
pub(super) fn xmlrpc_fault_value(fault: crate::xmlrpc::XmlRpcFault) -> XmlRpcValue {
XmlRpcValue::Struct(vec![
XmlRpcMember {
name: "faultCode".to_owned(),
value: XmlRpcValue::Int(fault.code),
},
XmlRpcMember {
name: "faultString".to_owned(),
value: XmlRpcValue::String(fault.message),
},
])
}
/// Wraps an RPC error in the XML-RPC fault envelope expected by aria2 clients.
pub(super) fn xmlrpc_fault_from_error(error: RpcError) -> crate::xmlrpc::XmlRpcFault {
let message = error.message.clone();
crate::xmlrpc::XmlRpcFault {
code: 1,
message,
error: Some(error),
}
}
@@ -0,0 +1,356 @@
use std::collections::{BTreeMap, BTreeSet};
use aria2_rust_pro_compat::{global_option_specs, per_download_option_specs};
use aria2_rust_pro_core::{DownloadHandle, RequestGroup};
use base64::Engine;
use crate::{
model::RpcValue,
xmlrpc::{XmlRpcMember, XmlRpcValue},
};
/// Builds the unified option surface exposed by `getGlobalOption`.
pub(super) fn option_specs_for_global_view() -> Vec<&'static aria2_rust_pro_compat::OptionSpec> {
let mut specs = global_option_specs();
let mut seen = specs.iter().map(|spec| spec.name).collect::<BTreeSet<_>>();
for spec in per_download_option_specs() {
if seen.insert(spec.name) {
specs.push(spec);
}
}
specs
}
/// Lossily converts a `usize` into an `i64` for RPC payload rendering.
pub(super) fn i64_from_usize(value: usize) -> i64 {
i64::try_from(value).unwrap_or(i64::MAX)
}
/// Lossily converts a `usize` into a `u32` for engine-facing counters.
pub(super) fn u32_from_usize(value: usize) -> u32 {
u32::try_from(value).unwrap_or(u32::MAX)
}
/// Lossily converts a `usize` into a `u64` for RPC payload rendering.
pub(super) fn u64_from_usize(value: usize) -> u64 {
u64::try_from(value).unwrap_or(u64::MAX)
}
/// Converts a non-negative JSON-RPC integer into a platform `usize`.
pub(super) fn usize_from_i64(value: i64) -> Option<usize> {
usize::try_from(value).ok()
}
/// Lossily converts a `u64` into a `usize` for local indexing.
pub(super) fn usize_from_u64(value: u64) -> usize {
usize::try_from(value).unwrap_or(usize::MAX)
}
/// Returns the first per-download option key that aria2 forbids through `changeOption`.
pub(super) fn first_forbidden_change_option_key(map: &BTreeMap<String, RpcValue>) -> Option<&str> {
const FORBIDDEN: &[&str] = &[
"dry-run",
"metalink-base-uri",
"parameterized-uri",
"pause",
"piece-length",
"rpc-save-upload-metadata",
];
FORBIDDEN
.iter()
.find_map(|name| map.contains_key(*name).then_some(*name))
}
/// Returns the first global option key that aria2 forbids through `changeGlobalOption`.
pub(super) fn first_forbidden_change_global_option_key(
map: &BTreeMap<String, RpcValue>,
) -> Option<&str> {
const FORBIDDEN: &[&str] = &["checksum", "index-out", "out", "pause", "select-file"];
FORBIDDEN
.iter()
.find_map(|name| map.contains_key(*name).then_some(*name))
}
/// Parses a required RPC URI parameter into a normalized URI list.
pub(super) fn parse_uri_list_param(value: &RpcValue) -> Result<Vec<String>, String> {
match value {
RpcValue::String(uri) => Ok(vec![uri.clone()]),
RpcValue::Array(items) => {
let mut uris = Vec::with_capacity(items.len());
for item in items {
match item {
RpcValue::String(uri) => uris.push(uri.clone()),
_ => return Err("uri array must contain only strings".to_owned()),
}
}
if uris.is_empty() {
return Err("uri array must not be empty".to_owned());
}
Ok(uris)
}
_ => Err("uris must be an array of strings".to_owned()),
}
}
/// Parses a URI array parameter that may legally be empty.
pub(super) fn parse_uri_array_allow_empty(
value: &RpcValue,
label: &str,
) -> Result<Vec<String>, String> {
let RpcValue::Array(items) = value else {
return Err(format!("{label} must be an array of strings"));
};
let mut uris = Vec::with_capacity(items.len());
for item in items {
if let RpcValue::String(uri) = item {
uris.push(uri.clone());
}
}
Ok(uris)
}
/// Parses an optional webseed URI array for add-torrent style methods.
pub(super) fn parse_optional_uri_array(
value: &RpcValue,
method: &str,
) -> Result<Vec<String>, String> {
match value {
RpcValue::Array(items) => {
let mut uris = Vec::with_capacity(items.len());
for item in items {
match item {
RpcValue::String(uri) => uris.push(uri.clone()),
_ => {
return Err(format!("{method} webseed uris must contain only strings"));
}
}
}
Ok(uris)
}
_ => Err(format!("{method} webseed uris must be an array of strings")),
}
}
/// Parses an optional RPC options object into owned key-value entries.
pub(super) fn parse_optional_option_object(
value: Option<&RpcValue>,
method: &str,
) -> Result<Vec<(String, RpcValue)>, String> {
let Some(value) = value else {
return Ok(Vec::new());
};
let RpcValue::Object(options) = value else {
return Err(format!("{method} options must be a struct/object"));
};
Ok(options
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect())
}
/// Parses an optional queue position parameter.
pub(super) fn parse_optional_position(
value: Option<&RpcValue>,
method: &str,
) -> Result<Option<usize>, String> {
let Some(value) = value else {
return Ok(None);
};
match value {
RpcValue::Number(value) if *value >= 0 => Ok(usize_from_i64(*value)),
_ => Err(format!("{method} position must be a non-negative integer")),
}
}
/// Parses a required 1-based file index parameter.
pub(super) fn parse_required_file_index(value: &RpcValue) -> Option<usize> {
match value {
RpcValue::Number(value) if *value >= 1 => usize_from_i64(*value),
_ => None,
}
}
/// Returns whether a URI looks actionable for aria2-style add methods.
pub(super) fn is_rpc_uri_candidate(uri: &str) -> bool {
rpc_uri_has_ascii_prefix(uri, "magnet:?")
|| uri.contains("://")
|| rpc_uri_has_ascii_suffix(uri, ".torrent")
}
/// Returns whether a URI starts with an ASCII prefix, ignoring case.
pub(super) fn rpc_uri_has_ascii_prefix(uri: &str, prefix: &str) -> bool {
uri.get(..prefix.len())
.is_some_and(|head| head.eq_ignore_ascii_case(prefix))
}
/// Returns whether a URI ends with an ASCII suffix, ignoring case.
pub(super) fn rpc_uri_has_ascii_suffix(uri: &str, suffix: &str) -> bool {
uri.get(uri.len().saturating_sub(suffix.len())..)
.is_some_and(|tail| tail.eq_ignore_ascii_case(suffix))
}
/// Extracts a display file name from a URI when one is obvious.
pub(super) fn rpc_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())
}
}
/// Parses an optional status-field allowlist parameter.
pub(super) fn parse_optional_status_keys(
value: Option<&RpcValue>,
method: &str,
) -> Result<Option<BTreeSet<String>>, String> {
let Some(value) = value else {
return Ok(None);
};
let RpcValue::Array(items) = value else {
return Err(format!("{method} keys must be an array of strings"));
};
if items.is_empty() {
return Ok(None);
}
let mut keys = BTreeSet::new();
for item in items {
match item {
RpcValue::String(key) => {
keys.insert(key.clone());
}
_ => return Err(format!("{method} keys must contain only strings")),
}
}
Ok(Some(keys))
}
/// Filters a status payload down to the requested field set.
pub(super) fn filter_status_payload(
payload: RpcValue,
keys: Option<&BTreeSet<String>>,
) -> RpcValue {
let Some(keys) = keys else {
return payload;
};
match payload {
RpcValue::Object(fields) => RpcValue::Object(
fields
.into_iter()
.filter(|(key, _)| keys.contains(key))
.collect(),
),
other => other,
}
}
/// Applies RPC option values to a request group using aria2's stringly option model.
pub(super) fn apply_group_options(group: &mut RequestGroup, options: Vec<(String, RpcValue)>) {
for (key, value) in options {
match value {
RpcValue::String(value) => group.set_option(key, value),
RpcValue::Number(value) => group.set_option(key, value.to_string()),
RpcValue::Bool(value) => group.set_option(key, if value { "true" } else { "false" }),
RpcValue::Null => group.set_option(key, ""),
RpcValue::Array(_) | RpcValue::Object(_) => {}
}
}
}
/// Applies already-normalized string options to a request group directly.
pub(super) fn apply_group_string_options(group: &mut RequestGroup, options: Vec<(String, String)>) {
for (key, value) in options {
group.set_option(key, value);
}
}
/// Builds implied request-group options from a metalink plan entry.
pub(super) fn metalink_default_options(
entry: &aria2_rust_pro_protocol::metalink::MetalinkDownloadPlanEntry,
) -> Vec<(String, RpcValue)> {
let mut options = Vec::new();
if !entry.file_name.trim().is_empty() {
options.push(("out".to_owned(), RpcValue::String(entry.file_name.clone())));
}
if let Some(checksum) = &entry.checksum {
options.push((
"checksum".to_owned(),
RpcValue::String(format!("{}={}", checksum.algorithm, checksum.expected_hex)),
));
}
options
}
/// Decodes a base64 metalink payload when the caller did not send raw XML.
pub(super) fn decode_metalink_payload(value: &str) -> Option<String> {
let bytes = base64::engine::general_purpose::STANDARD
.decode(value.as_bytes())
.ok()?;
let text = String::from_utf8(bytes).ok()?;
text.contains("<metalink").then_some(text)
}
/// Slices a download-handle list using aria2's positive and negative offset rules.
pub(super) fn slice_handles_by_offset(
handles: Vec<DownloadHandle>,
offset: i64,
max: usize,
) -> Vec<DownloadHandle> {
if max == 0 || handles.is_empty() {
return Vec::new();
}
if offset >= 0 {
return handles
.into_iter()
.skip(usize_from_i64(offset).unwrap_or(usize::MAX))
.take(max)
.collect();
}
let reversed = handles.into_iter().rev().collect::<Vec<_>>();
let start = offset
.checked_neg()
.and_then(|value| value.checked_sub(1))
.and_then(usize_from_i64)
.unwrap_or_default();
reversed.into_iter().skip(start).take(max).collect()
}
/// Looks up a named XML-RPC struct member.
pub(super) fn xmlrpc_member_value<'a>(
members: &'a [XmlRpcMember],
name: &str,
) -> Option<&'a XmlRpcValue> {
members
.iter()
.find(|member| member.name == name)
.map(|member| &member.value)
}
/// Parses the completed byte count from a `Content-Range` header value.
pub(super) fn parse_content_range_completed_length(value: &str) -> Option<u64> {
let mut parts = value.split_whitespace();
let unit = parts.next()?;
if !unit.eq_ignore_ascii_case("bytes") {
return None;
}
let range = parts.next()?;
let (start, end) = range.split_once('-')?;
let start = start.parse::<u64>().ok()?;
let end = end.parse::<u64>().ok()?;
if end < start {
return None;
}
Some(end - start + 1)
}
/// Returns whether an HTTP status should preserve retry eligibility.
pub(super) fn is_retry_relevant_status(status: u16) -> bool {
matches!(status, 408 | 425 | 429 | 500 | 502 | 503 | 504)
}
@@ -0,0 +1,362 @@
use super::compat_support::apply_bt_select_file_option;
use super::{
CoreError, DownloadEngine, DownloadId, DownloadStatus, InProcessRpcDispatcher, JsonRpcRequest,
JsonRpcResponse, QueuePositionMode, RpcError, RpcValue, SaveSessionTarget,
first_forbidden_change_global_option_key, first_forbidden_change_option_key, i64_from_usize,
is_rpc_uri_candidate, parse_optional_position, parse_required_file_index,
parse_uri_array_allow_empty, state_transition_rpc_error,
};
impl InProcessRpcDispatcher {
/// Handles `aria2.changeGlobalOption` after rejecting unsupported dynamic keys.
pub(super) fn handle_change_global_option(
&mut self,
request: JsonRpcRequest,
) -> JsonRpcResponse {
let Some(RpcValue::Object(map)) = request.params.first() else {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params("aria2.changeGlobalOption needs option object"),
);
};
if let Some(option) = first_forbidden_change_global_option_key(map) {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params(&format!(
"aria2.changeGlobalOption does not allow dynamic updates for option: {option}"
)),
);
}
let patch = self.rpc_object_to_patch(map.clone());
self.engine.apply_options(patch);
JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned()))
}
/// Handles `aria2.changeOption` by applying validated per-download option patches.
pub(super) fn handle_change_option(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
let gid = match self.parse_gid_from_first_param(&request, "aria2.changeOption") {
Ok(gid) => gid,
Err(error) => return JsonRpcResponse::error(request.id, error),
};
let Some(RpcValue::Object(map)) = request.params.get(1) else {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params("aria2.changeOption needs option object"),
);
};
if let Some(option) = first_forbidden_change_option_key(map) {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params(&format!(
"aria2.changeOption does not allow dynamic updates for option: {option}"
)),
);
}
let patch = self.rpc_object_to_patch(map.clone());
let select_file_option = map
.get("select-file")
.map(|value| self.option_value_text(&self.rpc_value_to_option_value(value.clone())));
match self.engine.handle_mut(gid) {
Some(group) => {
if let Some(select_file_value) = select_file_option
&& let Err(message) = apply_bt_select_file_option(group, &select_file_value)
{
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params(&format!("invalid select-file option: {message}")),
);
}
group.options_mut().merge(patch);
JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned()))
}
None => JsonRpcResponse::error(
request.id,
RpcError::unsupported(&format!("Cannot change option for GID#{gid}")),
),
}
}
/// Handles `aria2.changePosition` by moving waiting downloads within the queue.
pub(super) fn handle_change_position(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
let gid = match self.parse_gid_from_first_param(&request, "aria2.changePosition") {
Ok(gid) => gid,
Err(error) => return JsonRpcResponse::error(request.id, error),
};
let Some(position) = request.params.get(1).and_then(|value| match value {
RpcValue::Number(value) => Some(*value),
_ => None,
}) else {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params("aria2.changePosition needs position"),
);
};
let Some(mode_text) = request.params.get(2).and_then(|value| match value {
RpcValue::String(value) => Some(value.as_str()),
_ => None,
}) else {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params("aria2.changePosition needs mode"),
);
};
let mode = match mode_text {
"POS_SET" => QueuePositionMode::Set,
"POS_CUR" => QueuePositionMode::Cur,
"POS_END" => QueuePositionMode::End,
_ => {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params("Illegal argument."),
);
}
};
match self.engine.change_position(gid, position, mode) {
Ok(dest) => {
JsonRpcResponse::success(request.id, RpcValue::Number(i64_from_usize(dest)))
}
Err(_) => JsonRpcResponse::error(
request.id,
RpcError::unsupported(&format!("GID#{gid} not found in the waiting queue.")),
),
}
}
/// Handles `aria2.changeUri` by removing and inserting source URIs for a download.
pub(super) fn handle_change_uri(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
let gid = match self.parse_gid_from_first_param(&request, "aria2.changeUri") {
Ok(gid) => gid,
Err(error) => return JsonRpcResponse::error(request.id, error),
};
let Some(file_index) = request.params.get(1).and_then(parse_required_file_index) else {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params("aria2.changeUri needs fileIndex"),
);
};
let Some(del_uris_value) = request.params.get(2) else {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params("aria2.changeUri needs delUris"),
);
};
let del_uris = match parse_uri_array_allow_empty(del_uris_value, "aria2.changeUri delUris")
{
Ok(uris) => uris,
Err(error) => {
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error));
}
};
let Some(add_uris_value) = request.params.get(3) else {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params("aria2.changeUri needs addUris"),
);
};
let add_uris = match parse_uri_array_allow_empty(add_uris_value, "aria2.changeUri addUris")
{
Ok(uris) => uris,
Err(error) => {
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error));
}
};
let position = match parse_optional_position(request.params.get(4), "aria2.changeUri") {
Ok(position) => position,
Err(error) => {
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error));
}
};
if file_index != 1 {
return JsonRpcResponse::error(
request.id,
RpcError::unsupported("fileIndex is out of range"),
);
}
let Some(group) = self.engine.handle_mut(gid) else {
return JsonRpcResponse::error(
request.id,
RpcError::unsupported(&format!("Cannot remove URIs from GID#{gid}")),
);
};
let mut deleted = 0_i64;
for uri in del_uris {
if group.context_mut().remove_first_matching_uri(&uri) {
deleted += 1;
}
}
let mut inserted = 0_i64;
if let Some(mut position) = position {
for uri in add_uris {
if !is_rpc_uri_candidate(&uri) {
continue;
}
group.context_mut().insert_uri(position, uri);
position += 1;
inserted += 1;
}
} else {
for uri in add_uris {
if !is_rpc_uri_candidate(&uri) {
continue;
}
group.context_mut().append_uri(uri);
inserted += 1;
}
}
JsonRpcResponse::success(
request.id,
RpcValue::Array(vec![RpcValue::Number(deleted), RpcValue::Number(inserted)]),
)
}
/// Handles `aria2.purgeDownloadResult` by removing all stopped download results.
pub(super) fn handle_purge_download_result(
&mut self,
request: JsonRpcRequest,
) -> JsonRpcResponse {
self.engine.purge_download_results();
JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned()))
}
/// Handles `aria2.removeDownloadResult` for a single stopped download result.
pub(super) fn handle_remove_download_result(
&mut self,
request: JsonRpcRequest,
) -> JsonRpcResponse {
let Some(gid_text) = request.params.first().and_then(|value| match value {
RpcValue::String(gid) => Some(gid.clone()),
_ => None,
}) else {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params("aria2.removeDownloadResult needs gid"),
);
};
let Some(gid) = DownloadId::parse_hex(&gid_text) else {
return JsonRpcResponse::error(
request.id,
RpcError::unsupported(&format!("Invalid GID {gid_text}")),
);
};
match self.engine.remove_download_result(gid) {
Ok(()) => JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned())),
Err(_) => JsonRpcResponse::error(
request.id,
RpcError {
code: crate::model::RpcErrorCode::ApplicationError,
kind: crate::model::RpcErrorKind::Internal,
message: format!("Could not remove download result of GID#{gid_text}"),
},
),
}
}
/// Handles `aria2.saveSession` by writing the runtime session target when configured.
pub(super) fn handle_save_session(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
let target = self
.engine
.session()
.session_file()
.cloned()
.map(SaveSessionTarget::Path)
.unwrap_or(SaveSessionTarget::Memory);
match self.engine.save_session(target) {
Ok(()) => JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned())),
Err(error) => {
JsonRpcResponse::error(request.id, RpcError::unsupported(&error.to_string()))
}
}
}
/// Handles graceful shutdown requests with the aria2 success sentinel.
pub(super) fn handle_shutdown(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
match self.engine.shutdown() {
Ok(()) => JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned())),
Err(error) => {
JsonRpcResponse::error(request.id, RpcError::unsupported(&error.to_string()))
}
}
}
/// Handles forced shutdown requests with the aria2 success sentinel.
pub(super) fn handle_force_shutdown(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
match self.engine.force_shutdown() {
Ok(()) => JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned())),
Err(error) => {
JsonRpcResponse::error(request.id, RpcError::unsupported(&error.to_string()))
}
}
}
/// Handles pause-all variants by pausing eligible active or waiting downloads.
pub(super) fn handle_pause_all(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
let gids: Vec<_> = self
.engine
.registry()
.handles()
.filter(|handle| {
self.engine
.registry()
.get(handle.gid())
.is_some_and(|group| {
matches!(
group.status(),
DownloadStatus::Active | DownloadStatus::Waiting
)
})
})
.map(|handle| handle.gid())
.collect();
for gid in gids {
let _ = self.engine.pause(gid);
}
JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned()))
}
/// Handles `aria2.unpauseAll` by resuming eligible paused downloads.
pub(super) fn handle_unpause_all(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
let gids: Vec<_> = self
.engine
.registry()
.handles()
.filter(|handle| {
self.engine
.registry()
.get(handle.gid())
.is_some_and(|group| group.status() == &DownloadStatus::Paused)
})
.map(|handle| handle.gid())
.collect();
for gid in gids {
let _ = self.engine.resume(gid);
}
JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned()))
}
/// Handles single-download pause, resume, and remove state transitions.
pub(super) fn handle_state_transition<F>(
&mut self,
request: JsonRpcRequest,
method: &'static str,
mut apply: F,
) -> JsonRpcResponse
where
F: FnMut(&mut DownloadEngine, DownloadId) -> Result<(), CoreError>,
{
let Some(RpcValue::String(gid)) = request.params.first() else {
return JsonRpcResponse::error(request.id, RpcError::invalid_params(method));
};
let Some(gid) = DownloadId::parse_hex(gid) else {
return JsonRpcResponse::error(
request.id,
RpcError::unsupported(&format!("Invalid GID {gid}")),
);
};
match apply(&mut self.engine, gid) {
Ok(()) => JsonRpcResponse::success(request.id, RpcValue::String(gid.to_string())),
Err(error) => {
JsonRpcResponse::error(request.id, state_transition_rpc_error(method, gid, &error))
}
}
}
}
@@ -0,0 +1,785 @@
use aria2_rust_pro_compat::per_download_option_specs;
use super::{
BT_STATUS_FIELDS, BTreeMap, BtFileInfo, BtTrackerInfo, Digest, DownloadStatus,
InProcessRpcDispatcher, OptionKey, OptionPatch, OptionValue, PieceId, PieceState, RequestGroup,
RpcValue, option_specs_for_global_view, rpc_bt_info_hash, rpc_share_ratio_text,
rpc_share_time_text, rpc_uri_file_name, rpc_uri_has_ascii_prefix, rpc_uri_has_ascii_suffix,
verified_length_for_range,
};
impl InProcessRpcDispatcher {
/// Builds the full aria2 `tellStatus` object for a request group.
pub(super) fn rpc_status_payload(&self, group: &RequestGroup) -> RpcValue {
let snapshot = self
.engine
.progress_snapshot(group.gid())
.unwrap_or_else(|_| {
aria2_rust_pro_core::ProgressSnapshot::new(group.gid(), *group.status())
});
let piece_length = group.piece_length().max(1);
let total_length = snapshot.total_length;
let num_pieces = if total_length == 0 {
0
} else {
total_length.div_ceil(piece_length)
};
let completed_pieces = group
.piece_map()
.iter()
.filter(|(_, state)| **state == PieceState::Verified)
.count()
.try_into()
.unwrap_or(u64::MAX);
let mut status = BTreeMap::from([
("gid".to_owned(), RpcValue::String(group.gid().to_string())),
(
"status".to_owned(),
RpcValue::String(self.rpc_status_name(group.status()).to_owned()),
),
(
"totalLength".to_owned(),
RpcValue::String(snapshot.total_length.to_string()),
),
(
"completedLength".to_owned(),
RpcValue::String(snapshot.completed_length.to_string()),
),
(
"uploadLength".to_owned(),
RpcValue::String(snapshot.upload_length.to_string()),
),
(
"uploadSpeed".to_owned(),
RpcValue::String(snapshot.upload_speed.to_string()),
),
(
"shareRatio".to_owned(),
RpcValue::String(rpc_share_ratio_text(snapshot.share_ratio_milli)),
),
(
"shareRatioProgress".to_owned(),
RpcValue::String(rpc_share_ratio_text(snapshot.share_ratio_milli)),
),
(
"shareRatioRemaining".to_owned(),
RpcValue::String("0.000".to_owned()),
),
(
"shareTime".to_owned(),
RpcValue::String(rpc_share_time_text(&snapshot)),
),
(
"downloadSpeed".to_owned(),
RpcValue::String(snapshot.download_speed.to_string()),
),
(
"retryCount".to_owned(),
RpcValue::String(group.retry_count().to_string()),
),
(
"retryAttempts".to_owned(),
RpcValue::Array(
group
.retry_attempts()
.iter()
.map(|attempt| {
RpcValue::Object(BTreeMap::from([
(
"attempt".to_owned(),
RpcValue::String(attempt.attempt.to_string()),
),
(
"offset".to_owned(),
RpcValue::String(attempt.offset.to_string()),
),
(
"length".to_owned(),
RpcValue::String(
attempt.length.unwrap_or_default().to_string(),
),
),
(
"recoverable".to_owned(),
RpcValue::Bool(attempt.recoverable),
),
(
"error".to_owned(),
RpcValue::String(attempt.error.clone().unwrap_or_default()),
),
]))
})
.collect(),
),
),
(
"numSeeders".to_owned(),
RpcValue::String(self.rpc_bt_num_seeders(group).to_string()),
),
(
"seeders".to_owned(),
RpcValue::String(self.rpc_bt_num_seeders(group).to_string()),
),
(
"connections".to_owned(),
RpcValue::String(snapshot.num_connections.to_string()),
),
(
"activeSegments".to_owned(),
RpcValue::String(snapshot.num_connections.to_string()),
),
(
"pieceLength".to_owned(),
RpcValue::String(piece_length.to_string()),
),
(
"numPieces".to_owned(),
RpcValue::String(num_pieces.to_string()),
),
(
"completedPieces".to_owned(),
RpcValue::String(completed_pieces.to_string()),
),
("errorCode".to_owned(), RpcValue::String("0".to_owned())),
("dir".to_owned(), RpcValue::String(String::new())),
(
"resumeState".to_owned(),
self.rpc_resume_state_payload(group),
),
(
"files".to_owned(),
RpcValue::Array(vec![self.rpc_file_payload(group)]),
),
]);
status.extend(self.rpc_bt_status_fields(group));
RpcValue::Object(status)
}
/// Maps internal download states to aria2 status names.
pub(super) fn rpc_status_name(&self, status: &DownloadStatus) -> &'static str {
status.as_rpc_status()
}
/// Builds the effective global option map visible through RPC.
pub(super) fn effective_global_option_map(&self) -> BTreeMap<String, RpcValue> {
option_specs_for_global_view()
.into_iter()
.map(|spec| {
let key = spec
.rpc_names
.first()
.copied()
.unwrap_or(spec.name)
.to_owned();
let value = self
.engine
.session()
.global_options()
.get(&OptionKey::new(spec.name))
.map(|value| self.option_value_text(value))
.unwrap_or_else(|| spec.default_value.to_owned());
(key, RpcValue::String(value))
})
.collect()
}
/// Builds the effective per-download option map with global fallbacks applied.
pub(super) fn effective_download_option_map(
&self,
group: &RequestGroup,
) -> BTreeMap<String, RpcValue> {
per_download_option_specs()
.into_iter()
.map(|spec| {
let key = spec
.rpc_names
.first()
.copied()
.unwrap_or(spec.name)
.to_owned();
let value = group
.options()
.get(&OptionKey::new(spec.name))
.map(|value| self.option_value_text(value))
.or_else(|| {
self.engine
.session()
.global_options()
.get(&OptionKey::new(spec.name))
.map(|value| self.option_value_text(value))
})
.unwrap_or_else(|| spec.default_value.to_owned());
(key, RpcValue::String(value))
})
.collect()
}
/// Converts an internal option value into aria2's stringly RPC representation.
pub(super) fn option_value_text(&self, 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::new(),
}
}
/// Converts an RPC option object into an engine option patch.
pub(super) fn rpc_object_to_patch(&self, map: BTreeMap<String, RpcValue>) -> OptionPatch {
let mut patch = OptionPatch::new();
for (key, value) in map {
patch.insert(key, self.rpc_value_to_option_value(value));
}
patch
}
/// Converts a single RPC value into the engine option-value model.
pub(super) fn rpc_value_to_option_value(&self, value: RpcValue) -> OptionValue {
match value {
RpcValue::Null => OptionValue::Empty,
RpcValue::Bool(value) => OptionValue::Bool(value),
RpcValue::Number(value) => OptionValue::Int(value),
RpcValue::String(value) => OptionValue::Text(value),
RpcValue::Array(values) => OptionValue::List(
values
.into_iter()
.map(|value| self.option_value_text(&self.rpc_value_to_option_value(value)))
.collect(),
),
RpcValue::Object(values) => OptionValue::Map(
values
.into_iter()
.map(|(key, value)| {
(
key,
self.option_value_text(&self.rpc_value_to_option_value(value)),
)
})
.collect(),
),
}
}
/// Resolves the displayed output path for a download.
pub(super) fn rpc_target_path(&self, group: &RequestGroup) -> String {
let dir = group
.options()
.get(&OptionKey::from("dir"))
.or_else(|| {
self.engine
.session()
.global_options()
.get(&OptionKey::from("dir"))
})
.and_then(OptionValue::as_text)
.map(std::path::PathBuf::from);
let file_name = group
.options()
.get(&OptionKey::from("out"))
.and_then(OptionValue::as_text)
.map(str::to_owned)
.or_else(|| rpc_uri_file_name(group.uri()))
.unwrap_or_else(|| group.gid().to_string());
match dir {
Some(dir) => dir.join(file_name).to_string_lossy().into_owned(),
None => file_name,
}
}
/// Builds aria2 URI entries for a request group.
pub(super) fn rpc_uris_payload(&self, group: &RequestGroup) -> Vec<RpcValue> {
if let Some(uri) = group.bt().and_then(|bt| bt.magnet_uri.clone()) {
return vec![RpcValue::Object(BTreeMap::from([
("status".to_owned(), RpcValue::String("used".to_owned())),
("uri".to_owned(), RpcValue::String(uri)),
]))];
}
group
.uris()
.iter()
.enumerate()
.map(|(index, uri)| {
RpcValue::Object(BTreeMap::from([
(
"status".to_owned(),
RpcValue::String(if index == 0 { "used" } else { "waiting" }.to_owned()),
),
("uri".to_owned(), RpcValue::String(uri.clone())),
]))
})
.collect()
}
/// Builds aria2 file payloads for a request group.
pub(super) fn rpc_file_payloads(&self, group: &RequestGroup) -> Vec<RpcValue> {
match group.bt() {
Some(bt) if !bt.files.is_empty() => bt
.files
.iter()
.enumerate()
.map(|(index, file)| self.rpc_bt_file_payload(group, index, file))
.collect(),
_ => vec![self.rpc_file_payload(group)],
}
}
/// Builds the single-file payload used for non-BitTorrent downloads.
pub(super) fn rpc_file_payload(&self, group: &RequestGroup) -> RpcValue {
let snapshot = self
.engine
.progress_snapshot(group.gid())
.unwrap_or_else(|_| {
aria2_rust_pro_core::ProgressSnapshot::new(group.gid(), group.status().clone())
});
let piece_length = group.piece_length().max(1);
let total_length = snapshot.total_length;
let num_pieces = if total_length == 0 {
0
} else {
total_length.div_ceil(piece_length)
};
let bitfield = self.rpc_piece_bitfield(group, num_pieces);
let path = self.rpc_target_path(group);
let completed_length =
verified_length_for_range(group, 0, total_length, piece_length, total_length);
let mut file = BTreeMap::from([
("index".to_owned(), RpcValue::String("1".to_owned())),
("path".to_owned(), RpcValue::String(path)),
(
"length".to_owned(),
RpcValue::String(snapshot.total_length.to_string()),
),
(
"completedLength".to_owned(),
RpcValue::String(completed_length.to_string()),
),
(
"pieceLength".to_owned(),
RpcValue::String(piece_length.to_string()),
),
(
"numPieces".to_owned(),
RpcValue::String(num_pieces.to_string()),
),
("bitfield".to_owned(), RpcValue::String(bitfield)),
("selected".to_owned(), RpcValue::String("true".to_owned())),
(
"uris".to_owned(),
RpcValue::Array(self.rpc_uris_payload(group)),
),
]);
file.insert("isBt".to_owned(), RpcValue::Bool(self.rpc_is_bt(group)));
file.insert("btPath".to_owned(), RpcValue::String(String::new()));
file.insert(
"btCompletedPieces".to_owned(),
RpcValue::String(
group
.piece_map()
.iter()
.filter(|(_, state)| **state == PieceState::Verified)
.count()
.to_string(),
),
);
RpcValue::Object(file)
}
/// Builds one aria2 BitTorrent file payload from torrent metadata and progress.
pub(super) fn rpc_bt_file_payload(
&self,
group: &RequestGroup,
index: usize,
file: &BtFileInfo,
) -> RpcValue {
let piece_length = group.piece_length().max(1);
let file_completed = verified_length_for_range(
group,
file.piece_offset.unwrap_or_default(),
file.length,
piece_length,
group.total_length(),
);
let num_pieces = if file.length == 0 {
0
} else {
file.length.div_ceil(piece_length)
};
RpcValue::Object(BTreeMap::from([
(
"index".to_owned(),
RpcValue::String((index + 1).to_string()),
),
("path".to_owned(), RpcValue::String(file.path.clone())),
(
"length".to_owned(),
RpcValue::String(file.length.to_string()),
),
(
"completedLength".to_owned(),
RpcValue::String(file_completed.to_string()),
),
(
"pieceLength".to_owned(),
RpcValue::String(piece_length.to_string()),
),
(
"numPieces".to_owned(),
RpcValue::String(num_pieces.to_string()),
),
(
"bitfield".to_owned(),
RpcValue::String(self.rpc_piece_bitfield(group, num_pieces)),
),
(
"selected".to_owned(),
RpcValue::String(file.selected.to_string()),
),
(
"uris".to_owned(),
RpcValue::Array(self.rpc_uris_payload(group)),
),
("isBt".to_owned(), RpcValue::Bool(true)),
("btPath".to_owned(), RpcValue::String(file.path.clone())),
(
"btCompletedPieces".to_owned(),
RpcValue::String(
group
.piece_map()
.iter()
.filter(|(_, state)| **state == PieceState::Verified)
.count()
.to_string(),
),
),
]))
}
/// Builds the resume-state metadata exposed in dispatcher status payloads.
pub(super) fn rpc_resume_state_payload(&self, group: &RequestGroup) -> RpcValue {
match group.resume_state() {
Some(state) => RpcValue::Object(BTreeMap::from([
("persisted".to_owned(), RpcValue::Bool(state.persisted)),
(
"resumeOffset".to_owned(),
RpcValue::String(state.resume_offset.to_string()),
),
(
"validatedLength".to_owned(),
RpcValue::String(state.validated_length.unwrap_or_default().to_string()),
),
(
"segmentCursor".to_owned(),
RpcValue::String(
state
.segment_cursor
.map(|piece| piece.0.to_string())
.unwrap_or_default(),
),
),
])),
None => RpcValue::Null,
}
}
/// Encodes verified pieces as the hexadecimal bitfield expected by aria2 clients.
pub(super) fn rpc_piece_bitfield(&self, group: &RequestGroup, num_pieces: u64) -> String {
let mut bitfield = String::with_capacity(num_pieces as usize);
for piece_index in 0..num_pieces {
let state = group.piece_state(PieceId(piece_index as u32));
let marker = match state {
Some(PieceState::Verified) => '2',
Some(PieceState::Downloading) => '1',
_ => '0',
};
bitfield.push(marker);
}
bitfield
}
/// Builds a server payload for a non-BitTorrent download.
pub(super) fn rpc_server_payload(&self, group: &RequestGroup) -> RpcValue {
let host = self.rpc_server_host(group.uri());
let mut top = BTreeMap::from([
("index".to_owned(), RpcValue::String("1".to_owned())),
(
"servers".to_owned(),
RpcValue::Array(vec![RpcValue::Object(BTreeMap::from([
("uri".to_owned(), RpcValue::String(group.uri().to_owned())),
(
"currentUri".to_owned(),
RpcValue::String(group.uri().to_owned()),
),
("downloadSpeed".to_owned(), RpcValue::String("0".to_owned())),
("host".to_owned(), RpcValue::String(host)),
]))]),
),
]);
top.insert("isBt".to_owned(), RpcValue::Bool(self.rpc_is_bt(group)));
RpcValue::Object(top)
}
/// Builds server or tracker payloads for `aria2.getServers`.
pub(super) fn rpc_server_payloads(&self, group: &RequestGroup) -> Vec<RpcValue> {
match group.bt() {
Some(bt) if !bt.trackers.is_empty() => bt
.trackers
.iter()
.enumerate()
.map(|(index, tracker)| self.rpc_bt_server_payload(index, tracker))
.collect(),
_ => vec![self.rpc_server_payload(group)],
}
}
/// Builds a tracker row for BitTorrent server payloads.
pub(super) fn rpc_bt_server_payload(&self, index: usize, tracker: &BtTrackerInfo) -> RpcValue {
let host = self.rpc_server_host(&tracker.url);
RpcValue::Object(BTreeMap::from([
(
"index".to_owned(),
RpcValue::String((index + 1).to_string()),
),
(
"servers".to_owned(),
RpcValue::Array(vec![RpcValue::Object(BTreeMap::from([
("uri".to_owned(), RpcValue::String(tracker.url.clone())),
(
"currentUri".to_owned(),
RpcValue::String(tracker.url.clone()),
),
("downloadSpeed".to_owned(), RpcValue::String("0".to_owned())),
("host".to_owned(), RpcValue::String(host)),
]))]),
),
("isBt".to_owned(), RpcValue::Bool(true)),
]))
}
/// Returns whether the request group has BitTorrent runtime metadata.
pub(super) fn rpc_is_bt(&self, group: &RequestGroup) -> bool {
if group.bt().is_some() {
return true;
}
rpc_uri_has_ascii_prefix(group.uri(), "magnet:?")
|| rpc_uri_has_ascii_suffix(group.uri(), ".torrent")
}
/// Builds the BitTorrent-specific portion of an aria2 status payload.
pub(super) fn rpc_bt_status_fields(&self, group: &RequestGroup) -> BTreeMap<String, RpcValue> {
let mut fields = BTreeMap::new();
let is_bt = self.rpc_is_bt(group);
fields.insert("isBt".to_owned(), RpcValue::Bool(is_bt));
fields.insert("mode".to_owned(), RpcValue::String("single".to_owned()));
let info_hash = group
.bt()
.map(|bt| bt.info_hash.clone())
.or_else(|| rpc_bt_info_hash(group.uri()))
.unwrap_or_default();
fields.insert("infoHash".to_owned(), RpcValue::String(info_hash));
fields.insert(
"seeder".to_owned(),
RpcValue::String(self.rpc_bt_is_seeder(group).to_string()),
);
let piece_length = group.piece_length().max(1);
let num_pieces = if group.total_length() == 0 {
0
} else {
group.total_length().div_ceil(piece_length)
};
fields.insert(
"bitfield".to_owned(),
RpcValue::String(self.rpc_piece_bitfield(group, num_pieces)),
);
fields.insert(
"announceList".to_owned(),
RpcValue::Array(self.rpc_bt_announce_list(group)),
);
fields.insert("followedBy".to_owned(), RpcValue::Array(Vec::new()));
fields.insert("following".to_owned(), RpcValue::String(String::new()));
fields.insert("belongsTo".to_owned(), RpcValue::String(String::new()));
fields.insert(
"verifiedLength".to_owned(),
RpcValue::String(
(group
.piece_map()
.iter()
.filter(|(_, state)| **state == PieceState::Verified)
.count() as u64
* piece_length)
.to_string(),
),
);
fields.insert(
"verifyIntegrityPending".to_owned(),
RpcValue::String("false".to_owned()),
);
fields.insert(
"metadataOnly".to_owned(),
RpcValue::Bool(
group
.bt()
.map(|bt| bt.metadata_only)
.unwrap_or_else(|| rpc_uri_has_ascii_prefix(group.uri(), "magnet:?")),
),
);
fields.insert(
"magnetUri".to_owned(),
RpcValue::String(
group
.bt()
.and_then(|bt| bt.magnet_uri.clone())
.or_else(|| {
rpc_uri_has_ascii_prefix(group.uri(), "magnet:?")
.then(|| group.uri().to_owned())
})
.unwrap_or_default(),
),
);
fields.insert(
"creationDate".to_owned(),
RpcValue::String(
group
.bt()
.and_then(|bt| bt.creation_date.clone())
.unwrap_or_else(|| "0".to_owned()),
),
);
fields.insert(
"comment".to_owned(),
RpcValue::String(
group
.bt()
.and_then(|bt| bt.comment.clone())
.unwrap_or_default(),
),
);
fields.insert(
"btFieldCoverage".to_owned(),
RpcValue::Array(
BT_STATUS_FIELDS
.iter()
.map(|name| RpcValue::String((*name).to_owned()))
.collect(),
),
);
fields
}
/// Builds the nested announce-list representation for BitTorrent status payloads.
pub(super) fn rpc_bt_announce_list(&self, group: &RequestGroup) -> Vec<RpcValue> {
let Some(bt) = group.bt() else {
return Vec::new();
};
let mut tiers = BTreeMap::<u32, Vec<String>>::new();
for tracker in &bt.trackers {
tiers
.entry(tracker.tier.unwrap_or(0))
.or_default()
.push(tracker.url.clone());
}
tiers
.into_values()
.map(|tier| RpcValue::Array(tier.into_iter().map(RpcValue::String).collect()))
.collect()
}
/// Builds peer rows for `aria2.getPeers`.
pub(super) fn rpc_peer_payload(&self, group: &RequestGroup) -> Vec<RpcValue> {
if !self.rpc_is_bt(group) {
return Vec::new();
}
match group.bt() {
Some(bt) => bt
.peers
.iter()
.map(|peer| {
RpcValue::Object(BTreeMap::from([
(
"peerId".to_owned(),
RpcValue::String(peer.peer_id.clone().unwrap_or_default()),
),
("ip".to_owned(), RpcValue::String(peer.ip.clone())),
("port".to_owned(), RpcValue::String(peer.port.to_string())),
("bitfield".to_owned(), RpcValue::String(String::new())),
(
"amChoking".to_owned(),
RpcValue::String(peer.choked.to_string()),
),
(
"peerChoking".to_owned(),
RpcValue::String(peer.choked.to_string()),
),
(
"downloadSpeed".to_owned(),
RpcValue::String(peer.download_speed.to_string()),
),
(
"uploadSpeed".to_owned(),
RpcValue::String(peer.upload_speed.to_string()),
),
(
"seeder".to_owned(),
RpcValue::String(peer.seeder.to_string()),
),
]))
})
.collect(),
None => vec![RpcValue::Object(BTreeMap::from([
("peerId".to_owned(), RpcValue::String(String::new())),
("ip".to_owned(), RpcValue::String(String::new())),
("port".to_owned(), RpcValue::String("0".to_owned())),
("bitfield".to_owned(), RpcValue::String(String::new())),
("amChoking".to_owned(), RpcValue::String("true".to_owned())),
(
"peerChoking".to_owned(),
RpcValue::String("true".to_owned()),
),
("downloadSpeed".to_owned(), RpcValue::String("0".to_owned())),
("uploadSpeed".to_owned(), RpcValue::String("0".to_owned())),
("seeder".to_owned(), RpcValue::String("false".to_owned())),
]))],
}
}
/// Extracts the host portion displayed in server payloads.
pub(super) fn rpc_server_host(&self, uri: &str) -> String {
uri.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or(uri)
.split('/')
.next()
.unwrap_or_default()
.to_owned()
}
/// Counts known BitTorrent seeders for a request group.
pub(super) fn rpc_bt_num_seeders(&self, group: &RequestGroup) -> u32 {
group
.bt()
.map(|bt| {
let from_trackers = bt
.trackers
.iter()
.filter_map(|tracker| tracker.seeders)
.max();
from_trackers
.unwrap_or_else(|| bt.peers.iter().filter(|peer| peer.seeder).count() as u32)
})
.unwrap_or(0)
}
/// Returns whether local BitTorrent state should be reported as seeding.
pub(super) fn rpc_bt_is_seeder(&self, group: &RequestGroup) -> bool {
self.engine
.progress_snapshot(group.gid())
.map(|snapshot| snapshot.bt_true_seeding)
.unwrap_or(false)
}
}
@@ -0,0 +1,270 @@
use super::{
BTreeMap, DownloadId, DownloadStatus, InProcessRpcDispatcher, JsonRpcRequest, JsonRpcResponse,
RpcError, RpcValue, filter_status_payload, parse_optional_status_keys, rpc_enabled_features,
slice_handles_by_offset, usize_from_i64,
};
impl InProcessRpcDispatcher {
/// Handles `aria2.tellStatus` and optional status-key filtering.
pub(super) fn handle_tell_status(&self, request: JsonRpcRequest) -> JsonRpcResponse {
let gid = match self.parse_gid_from_first_param(&request, "aria2.tellStatus") {
Ok(gid) => gid,
Err(error) => return JsonRpcResponse::error(request.id, error),
};
let keys = match parse_optional_status_keys(request.params.get(1), "aria2.tellStatus") {
Ok(keys) => keys,
Err(message) => {
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&message));
}
};
match self.engine.registry().get(gid) {
Some(group) => JsonRpcResponse::success(
request.id,
filter_status_payload(self.rpc_status_payload(group), keys.as_ref()),
),
None => JsonRpcResponse::error(
request.id,
RpcError::unsupported(&format!("No such download for GID#{gid}")),
),
}
}
/// Handles `aria2.tellActive` by returning active download payloads.
pub(super) fn handle_tell_active(&self, request: JsonRpcRequest) -> JsonRpcResponse {
let keys = match parse_optional_status_keys(request.params.first(), "aria2.tellActive") {
Ok(keys) => keys,
Err(message) => {
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&message));
}
};
let values = self
.engine
.tell_active()
.into_iter()
.filter_map(|handle| self.engine.registry().get(handle.gid()))
.map(|group| filter_status_payload(self.rpc_status_payload(group), keys.as_ref()))
.collect();
JsonRpcResponse::success(request.id, RpcValue::Array(values))
}
/// Handles `aria2.tellWaiting` with aria2-compatible offset and limit semantics.
pub(super) fn handle_tell_waiting(&self, request: JsonRpcRequest) -> JsonRpcResponse {
let (offset, max) = self.parse_offset_and_max(&request);
let keys = match parse_optional_status_keys(request.params.get(2), "aria2.tellWaiting") {
Ok(keys) => keys,
Err(message) => {
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&message));
}
};
let values = slice_handles_by_offset(self.engine.tell_waiting(), offset, max)
.into_iter()
.filter_map(|handle| self.engine.registry().get(handle.gid()))
.map(|group| filter_status_payload(self.rpc_status_payload(group), keys.as_ref()))
.collect();
JsonRpcResponse::success(request.id, RpcValue::Array(values))
}
/// Handles `aria2.tellStopped` with stopped-queue ordering and status filtering.
pub(super) fn handle_tell_stopped(&self, request: JsonRpcRequest) -> JsonRpcResponse {
let (offset, max) = self.parse_offset_and_max(&request);
let keys = match parse_optional_status_keys(request.params.get(2), "aria2.tellStopped") {
Ok(keys) => keys,
Err(message) => {
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&message));
}
};
let values = slice_handles_by_offset(self.engine.tell_stopped(), offset, max)
.into_iter()
.filter_map(|handle| self.engine.registry().get(handle.gid()))
.map(|group| filter_status_payload(self.rpc_status_payload(group), keys.as_ref()))
.collect();
JsonRpcResponse::success(request.id, RpcValue::Array(values))
}
/// Handles global statistics requests using the upstream aria2 response shape.
pub(super) fn handle_tell_global_stat(&self, request: JsonRpcRequest) -> JsonRpcResponse {
let stat = self.engine.get_global_stat();
JsonRpcResponse::success(
request.id,
RpcValue::Object(BTreeMap::from([
(
"downloadSpeed".to_owned(),
RpcValue::String(stat.download_speed.to_string()),
),
(
"uploadSpeed".to_owned(),
RpcValue::String(stat.upload_speed.to_string()),
),
(
"numActive".to_owned(),
RpcValue::String(stat.num_active.to_string()),
),
(
"numWaiting".to_owned(),
RpcValue::String(stat.num_waiting.to_string()),
),
(
"numStopped".to_owned(),
RpcValue::String(stat.num_stopped.to_string()),
),
(
"numStoppedTotal".to_owned(),
RpcValue::String(self.engine.num_stopped_total().to_string()),
),
])),
)
}
/// Handles `aria2.getGlobalOption` by exposing effective runtime option values.
pub(super) fn handle_get_global_option(&self, request: JsonRpcRequest) -> JsonRpcResponse {
JsonRpcResponse::success(
request.id,
RpcValue::Object(self.effective_global_option_map()),
)
}
/// Handles `aria2.getOption` for a single tracked download.
pub(super) fn handle_get_option(&self, request: JsonRpcRequest) -> JsonRpcResponse {
let gid = match self.parse_gid_from_first_param(&request, "aria2.getOption") {
Ok(gid) => gid,
Err(error) => return JsonRpcResponse::error(request.id, error),
};
match self.engine.registry().get(gid) {
Some(group) => JsonRpcResponse::success(
request.id,
RpcValue::Object(self.effective_download_option_map(group)),
),
None => JsonRpcResponse::error(
request.id,
RpcError::unsupported(&format!("Cannot get option for GID#{gid}")),
),
}
}
/// Handles `aria2.getSessionInfo` by returning the stable dispatcher session id.
pub(super) fn handle_get_session_info(&self, request: JsonRpcRequest) -> JsonRpcResponse {
JsonRpcResponse::success(
request.id,
RpcValue::Object(BTreeMap::from([(
"sessionId".to_owned(),
RpcValue::String(self.session_id.clone()),
)])),
)
}
/// Builds the aria2-compatible version payload shared by JSON-RPC and XML-RPC.
pub(super) fn rpc_version_payload(&self) -> RpcValue {
RpcValue::Object(BTreeMap::from([
(
"version".to_owned(),
RpcValue::String(aria2_rust_pro_compat::VERSION.to_owned()),
),
(
"enabledFeatures".to_owned(),
RpcValue::Array(
rpc_enabled_features()
.into_iter()
.map(|feature| RpcValue::String((*feature).to_owned()))
.collect(),
),
),
]))
}
/// Handles `aria2.getUris` by projecting stored source URIs for a download.
pub(super) fn handle_get_uris(&self, request: JsonRpcRequest) -> JsonRpcResponse {
let gid = match self.parse_gid_from_first_param(&request, "aria2.getUris") {
Ok(gid) => gid,
Err(error) => return JsonRpcResponse::error(request.id, error),
};
match self.engine.registry().get(gid) {
Some(group) => {
JsonRpcResponse::success(request.id, RpcValue::Array(self.rpc_uris_payload(group)))
}
None => JsonRpcResponse::error(
request.id,
RpcError::unsupported(&format!("No URI data is available for GID#{gid}")),
),
}
}
/// Handles `aria2.getFiles` by returning per-file progress payloads.
pub(super) fn handle_get_files(&self, request: JsonRpcRequest) -> JsonRpcResponse {
let gid = match self.parse_gid_from_first_param(&request, "aria2.getFiles") {
Ok(gid) => gid,
Err(error) => return JsonRpcResponse::error(request.id, error),
};
match self.engine.registry().get(gid) {
Some(group) => {
JsonRpcResponse::success(request.id, RpcValue::Array(self.rpc_file_payloads(group)))
}
None => JsonRpcResponse::error(
request.id,
RpcError::unsupported(&format!("No file data is available for GID#{gid}")),
),
}
}
/// Handles `aria2.getServers` by exposing active server or tracker rows.
pub(super) fn handle_get_servers(&self, request: JsonRpcRequest) -> JsonRpcResponse {
let gid = match self.parse_gid_from_first_param(&request, "aria2.getServers") {
Ok(gid) => gid,
Err(error) => return JsonRpcResponse::error(request.id, error),
};
match self.engine.registry().get(gid) {
Some(group) if group.status() == &DownloadStatus::Active => JsonRpcResponse::success(
request.id,
RpcValue::Array(self.rpc_server_payloads(group)),
),
_ => JsonRpcResponse::error(
request.id,
RpcError::unsupported(&format!("No active download for GID#{gid}")),
),
}
}
/// Handles `aria2.getPeers` by returning BitTorrent peer rows for BT downloads.
pub(super) fn handle_get_peers(&self, request: JsonRpcRequest) -> JsonRpcResponse {
let gid = match self.parse_gid_from_first_param(&request, "aria2.getPeers") {
Ok(gid) => gid,
Err(error) => return JsonRpcResponse::error(request.id, error),
};
match self.engine.registry().get(gid) {
Some(group) => {
JsonRpcResponse::success(request.id, RpcValue::Array(self.rpc_peer_payload(group)))
}
None => JsonRpcResponse::error(
request.id,
RpcError::unsupported(&format!("No peer data is available for GID#{gid}")),
),
}
}
/// Parses the first RPC parameter as a download id and maps errors to RPC failures.
pub(super) fn parse_gid_from_first_param(
&self,
request: &JsonRpcRequest,
method: &'static str,
) -> Result<DownloadId, RpcError> {
let Some(RpcValue::String(gid)) = request.params.first() else {
return Err(RpcError::invalid_params(&format!("{method} needs gid")));
};
DownloadId::parse_hex(gid)
.ok_or_else(|| RpcError::unsupported(&format!("Invalid GID {gid}")))
}
/// Parses optional queue pagination parameters using aria2 defaults.
pub(super) fn parse_offset_and_max(&self, request: &JsonRpcRequest) -> (i64, usize) {
let offset = match request.params.first() {
Some(RpcValue::Number(value)) => *value,
_ => 0,
};
let max = match request.params.get(1) {
Some(RpcValue::Number(value)) if *value >= 0 => {
usize_from_i64(*value).unwrap_or(usize::MAX)
}
_ => usize::MAX,
};
(offset, max)
}
}
@@ -0,0 +1,678 @@
use std::{
collections::BTreeMap,
fs,
sync::Mutex,
time::{SystemTime, UNIX_EPOCH},
};
use aria2_rust_pro_core::{
BtFileInfo, BtPeerInfo, BtPieceAvailabilityUpdate, BtRuntimeState, DownloadId, PieceId,
PieceState, RuntimeConfig,
};
use aria2_rust_pro_protocol::{
DhtMessageModel, DhtNodeModel, DhtTransport, TrackerRequestModel, TrackerResponseModel,
TrackerScrapeModel, TrackerTransport,
torrent::{
DhtGetPeersQueryModel, DhtMessageBody, DhtQueryModel, PeerWireBitfieldModel,
PeerWireExtensionHandshakeModel, PeerWireHandshakeModel, PeerWireMessageKind,
PeerWireMetadataMessageModel, PeerWireMetadataMessageType, PeerWirePieceBlockModel,
TorrentMessageModel, parse_torrent_metadata,
},
transport::{
PeerWireTransportConnector, PeerWireTransportRequest, PeerWireTransportResponse,
TransportEndpoint, TransportError, TransportErrorKind, TransportScheme,
},
};
use aria2_rust_pro_storage::load_session_file;
use base64::Engine;
use super::{
BtRuntimeCoordinatorAction, BtRuntimeCoordinatorStepStatus, InProcessRpcDispatcher,
bt_metadata_piece_span, decode_hex_string_exact,
};
use crate::{
jsonrpc::{JsonRpcRequest, jsonrpc_request_from_json, jsonrpc_response_to_json},
methods::RpcMethod,
model::{RpcError, RpcMeta, RpcValue},
xmlrpc::{XmlRpcMember, XmlRpcMethodCall, XmlRpcParam, XmlRpcValue},
};
#[doc(hidden)]
fn temp_session_path(name: &str) -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be monotonic enough for test naming")
.as_nanos();
let root = std::env::temp_dir().join(format!(
"aria2-rust-pro-rpc-test-{}-{nanos}",
std::process::id()
));
fs::create_dir_all(&root).expect("temp dir should be creatable");
root.join(name)
}
#[doc(hidden)]
fn request(method: RpcMethod, params: Vec<RpcValue>) -> JsonRpcRequest {
JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: method.as_str().to_owned(),
params,
meta: RpcMeta::default(),
}
}
#[doc(hidden)]
fn request_with_method_name(method: &str, params: Vec<RpcValue>) -> JsonRpcRequest {
JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: method.to_owned(),
params,
meta: RpcMeta::default(),
}
}
#[doc(hidden)]
fn add_uri(dispatcher: &mut InProcessRpcDispatcher, uri: &str) -> String {
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2AddUri,
vec![RpcValue::String(uri.to_owned())],
));
match response.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri result: {other:?}"),
}
}
#[doc(hidden)]
#[test]
fn add_uri_direct_registers_uri_and_options_without_jsonrpc_roundtrip() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = dispatcher
.add_uri_direct(
vec![
"https://example.org/direct-a.iso".to_owned(),
"https://example.org/direct-b.iso".to_owned(),
],
vec![("split".to_owned(), RpcValue::String("8".to_owned()))],
)
.expect("direct addUri should register");
let group = dispatcher
.engine
.handle_mut(download_id(&gid))
.expect("direct addUri group should exist");
assert_eq!(group.uri(), "https://example.org/direct-a.iso");
assert_eq!(
group.uris(),
&[
"https://example.org/direct-a.iso".to_owned(),
"https://example.org/direct-b.iso".to_owned(),
]
);
assert_eq!(group.option_limit("split"), Some(8));
}
#[doc(hidden)]
fn download_id(gid: &str) -> DownloadId {
DownloadId::parse_hex(gid).expect("gid should parse into DownloadId")
}
#[doc(hidden)]
fn compact_peer(ip: [u8; 4], port: u16) -> Vec<u8> {
let mut bytes = Vec::with_capacity(6);
bytes.extend_from_slice(&ip);
bytes.extend_from_slice(&port.to_be_bytes());
bytes
}
#[doc(hidden)]
fn compact_node(node_id_byte: u8, ip: [u8; 4], port: u16) -> Vec<u8> {
let mut bytes = vec![node_id_byte; 20];
bytes.extend_from_slice(&ip);
bytes.extend_from_slice(&port.to_be_bytes());
bytes
}
#[doc(hidden)]
#[derive(Debug)]
struct FakeDhtTransport {
#[doc(hidden)]
response: DhtMessageModel,
#[doc(hidden)]
seen: Mutex<Vec<(DhtNodeModel, DhtMessageModel)>>,
}
impl FakeDhtTransport {
#[doc(hidden)]
fn new(response: DhtMessageModel) -> Self {
Self {
response,
seen: Mutex::new(Vec::new()),
}
}
#[doc(hidden)]
fn seen(&self) -> Vec<(DhtNodeModel, DhtMessageModel)> {
self.seen
.lock()
.expect("seen requests mutex should not be poisoned")
.clone()
}
}
impl DhtTransport for FakeDhtTransport {
#[doc(hidden)]
fn send_message(
&self,
node: &DhtNodeModel,
message: &DhtMessageModel,
) -> Result<DhtMessageModel, TransportError> {
self.seen
.lock()
.expect("seen requests mutex should not be poisoned")
.push((node.clone(), message.clone()));
Ok(self.response.clone())
}
}
#[doc(hidden)]
#[derive(Debug)]
struct FakeTrackerTransport {
#[doc(hidden)]
announce_response: TrackerResponseModel,
#[doc(hidden)]
scrape_response: Option<TrackerScrapeModel>,
#[doc(hidden)]
seen_announces: Mutex<Vec<TrackerRequestModel>>,
#[doc(hidden)]
seen_scrapes: Mutex<Vec<String>>,
}
impl FakeTrackerTransport {
#[doc(hidden)]
fn new(
announce_response: TrackerResponseModel,
scrape_response: Option<TrackerScrapeModel>,
) -> Self {
Self {
announce_response,
scrape_response,
seen_announces: Mutex::new(Vec::new()),
seen_scrapes: Mutex::new(Vec::new()),
}
}
#[doc(hidden)]
fn seen_announces(&self) -> Vec<TrackerRequestModel> {
self.seen_announces
.lock()
.expect("tracker announce mutex should not be poisoned")
.clone()
}
#[doc(hidden)]
fn seen_scrapes(&self) -> Vec<String> {
self.seen_scrapes
.lock()
.expect("tracker scrape mutex should not be poisoned")
.clone()
}
}
impl TrackerTransport for FakeTrackerTransport {
#[doc(hidden)]
fn announce(
&self,
request: &TrackerRequestModel,
) -> Result<TrackerResponseModel, TransportError> {
self.seen_announces
.lock()
.expect("tracker announce mutex should not be poisoned")
.push(request.clone());
Ok(self.announce_response.clone())
}
#[doc(hidden)]
fn scrape(&self, url: &str) -> Result<TrackerScrapeModel, TransportError> {
self.seen_scrapes
.lock()
.expect("tracker scrape mutex should not be poisoned")
.push(url.to_owned());
self.scrape_response.clone().ok_or_else(|| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: "scrape unavailable".to_owned(),
source: None,
context: None,
})
}
}
#[doc(hidden)]
#[derive(Debug)]
struct FakePeerWireConnector {
#[doc(hidden)]
response_payload: Vec<u8>,
#[doc(hidden)]
seen: Mutex<Vec<PeerWireTransportRequest>>,
}
impl FakePeerWireConnector {
#[doc(hidden)]
fn new(response_payload: Vec<u8>) -> Self {
Self {
response_payload,
seen: Mutex::new(Vec::new()),
}
}
#[doc(hidden)]
fn seen(&self) -> Vec<PeerWireTransportRequest> {
self.seen
.lock()
.expect("peer-wire seen requests mutex should not be poisoned")
.clone()
}
}
#[doc(hidden)]
#[derive(Debug)]
struct SequencedPeerWireConnector {
#[doc(hidden)]
response_payloads: Mutex<Vec<Vec<u8>>>,
#[doc(hidden)]
seen: Mutex<Vec<PeerWireTransportRequest>>,
}
impl SequencedPeerWireConnector {
#[doc(hidden)]
fn new(response_payloads: Vec<Vec<u8>>) -> Self {
Self {
response_payloads: Mutex::new(response_payloads),
seen: Mutex::new(Vec::new()),
}
}
#[doc(hidden)]
fn seen(&self) -> Vec<PeerWireTransportRequest> {
self.seen
.lock()
.expect("sequenced peer-wire seen requests mutex should not be poisoned")
.clone()
}
}
impl PeerWireTransportConnector for SequencedPeerWireConnector {
#[doc(hidden)]
fn connect_peer_wire(
&self,
request: &PeerWireTransportRequest,
) -> Result<PeerWireTransportResponse, TransportError> {
self.seen
.lock()
.expect("sequenced peer-wire seen requests mutex should not be poisoned")
.push(request.clone());
let payload = self
.response_payloads
.lock()
.expect("sequenced peer-wire payload mutex should not be poisoned")
.remove(0);
Ok(PeerWireTransportResponse {
endpoint: TransportEndpoint {
scheme: TransportScheme::BitTorrent,
address: request.endpoint.address.clone(),
},
payload,
})
}
}
impl PeerWireTransportConnector for FakePeerWireConnector {
#[doc(hidden)]
fn connect_peer_wire(
&self,
request: &PeerWireTransportRequest,
) -> Result<PeerWireTransportResponse, TransportError> {
self.seen
.lock()
.expect("peer-wire seen requests mutex should not be poisoned")
.push(request.clone());
Ok(PeerWireTransportResponse {
endpoint: TransportEndpoint {
scheme: TransportScheme::BitTorrent,
address: request.endpoint.address.clone(),
},
payload: self.response_payload.clone(),
})
}
}
#[doc(hidden)]
#[derive(Debug)]
struct RoutedDhtTransport {
#[doc(hidden)]
get_peers_response: DhtMessageModel,
#[doc(hidden)]
announce_peer_response: DhtMessageModel,
#[doc(hidden)]
find_node_response: Option<DhtMessageModel>,
#[doc(hidden)]
ping_response: Option<DhtMessageModel>,
#[doc(hidden)]
seen: Mutex<Vec<(DhtNodeModel, DhtMessageModel)>>,
}
impl RoutedDhtTransport {
#[doc(hidden)]
fn new(get_peers_response: DhtMessageModel, announce_peer_response: DhtMessageModel) -> Self {
Self {
get_peers_response,
announce_peer_response,
find_node_response: None,
ping_response: None,
seen: Mutex::new(Vec::new()),
}
}
#[doc(hidden)]
fn seen(&self) -> Vec<(DhtNodeModel, DhtMessageModel)> {
self.seen
.lock()
.expect("routed dht seen mutex should not be poisoned")
.clone()
}
}
impl DhtTransport for RoutedDhtTransport {
#[doc(hidden)]
fn send_message(
&self,
node: &DhtNodeModel,
message: &DhtMessageModel,
) -> Result<DhtMessageModel, TransportError> {
self.seen
.lock()
.expect("routed dht seen mutex should not be poisoned")
.push((node.clone(), message.clone()));
match &message.body {
DhtMessageBody::Query(DhtQueryModel::GetPeers(_)) => {
Ok(self.get_peers_response.clone())
}
DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(_)) => {
Ok(self.announce_peer_response.clone())
}
DhtMessageBody::Query(DhtQueryModel::FindNode(_)) => self
.find_node_response
.clone()
.ok_or_else(|| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: "find_node unavailable".to_owned(),
source: None,
context: None,
}),
DhtMessageBody::Query(DhtQueryModel::Ping(_)) => {
self.ping_response.clone().ok_or_else(|| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: "ping unavailable".to_owned(),
source: None,
context: None,
})
}
_ => Err(TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: "unexpected dht method for routed transport".to_owned(),
source: None,
context: None,
}),
}
}
}
#[doc(hidden)]
fn peer_wire_handshake_and_frames(
info_hash: [u8; 20],
peer_id: [u8; 20],
frames: &[PeerWireMessageKind],
) -> Vec<u8> {
let mut bytes = PeerWireHandshakeModel::new(info_hash, peer_id).serialize();
for frame in frames {
bytes.extend_from_slice(
&TorrentMessageModel::from_peer_wire_kind(frame.clone())
.serialize_peer_wire_frame()
.expect("peer-wire frame should serialize"),
);
}
bytes
}
#[doc(hidden)]
fn peer_from_ip(ip: &str, port: u16) -> BtPeerInfo {
BtPeerInfo {
peer_id: None,
ip: ip.to_owned(),
port,
client_name: None,
interested: false,
choked: true,
download_speed: 0,
upload_speed: 0,
seeder: false,
}
}
#[doc(hidden)]
fn single_file_torrent_bytes(name: &str, comment_len: usize) -> Vec<u8> {
let comment = "x".repeat(comment_len);
format!(
"d8:announce35:http://tracker.example.org/announce7:comment{}:{}4:infod6:lengthi2048e4:name{}:{}12:piece lengthi1024e6:pieces20:aaaaaaaaaaaaaaaaaaaaee",
comment.len(),
comment,
name.len(),
name
)
.into_bytes()
}
#[doc(hidden)]
#[test]
fn bt_runtime_coordinator_snapshot_surfaces_partial_magnet_runtime_and_recommended_actions() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:1234567890abcdef1234567890abcdef12345678&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
);
let download_id = download_id(&gid);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download group should exist");
group.set_piece_length(1_024);
group.set_total_length(4_096);
group.set_piece_state(PieceId(0), PieceState::Pending);
group.set_piece_state(PieceId(1), PieceState::Missing);
group.set_dht_token(Some(b"cached-token".to_vec()));
let bt = group.bt_mut().expect("bt runtime state should exist");
bt.dht_nodes = vec!["bad-node".to_owned(), "127.0.0.9:6881".to_owned()];
bt.peers = vec![peer_from_ip("127.0.0.7", 51413)];
}
let snapshot = dispatcher
.bt_runtime_coordinator_snapshot(&gid)
.expect("snapshot should inspect bt runtime");
assert!(snapshot.metadata_only);
assert!(snapshot.metadata_exchange_pending);
assert_eq!(snapshot.tracker_count, 1);
assert_eq!(snapshot.dht_node_count, 2);
assert_eq!(snapshot.addressable_dht_node_count, 1);
assert_eq!(snapshot.peer_count, 1);
assert_eq!(snapshot.connectable_peer_count, 1);
assert_eq!(snapshot.requestable_piece_count, 2);
assert_eq!(
snapshot.recommended_actions,
vec![
BtRuntimeCoordinatorAction::TrackerAnnounce,
BtRuntimeCoordinatorAction::DhtGetPeers,
BtRuntimeCoordinatorAction::DhtAnnouncePeer,
BtRuntimeCoordinatorAction::PeerWireExchange,
]
);
}
#[doc(hidden)]
#[test]
fn drive_bt_runtime_once_executes_newly_unlocked_bt_steps_within_one_iteration() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:fedcba9876543210fedcba9876543210fedcba98&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
);
let download_id = download_id(&gid);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download group should exist");
group.set_piece_length(1_024);
group.set_total_length(2_048);
group.set_piece_state(PieceId(0), PieceState::Pending);
group.set_piece_state(PieceId(1), PieceState::Missing);
let bt = group.bt_mut().expect("bt runtime state should exist");
bt.dht_nodes = vec!["127.0.0.11:6881".to_owned()];
}
let tracker = FakeTrackerTransport::new(
TrackerResponseModel {
peers: aria2_rust_pro_protocol::TrackerPeerListModel {
interval_sec: 1_800,
peers: vec![aria2_rust_pro_protocol::torrent::TorrentPeerModel {
ip: "127.0.0.21".to_owned(),
port: 51_413,
peer_id: Some(*b"12345678901234567890"),
client_name: Some("tracker-peer".to_owned()),
interested: false,
choked: false,
}],
min_interval_sec: None,
tracker_id: Some("tracker-id".to_owned()),
},
scrape: None,
},
Some(TrackerScrapeModel {
complete: Some(5),
downloaded: Some(8),
incomplete: Some(3),
files: Vec::new(),
}),
);
let dht = RoutedDhtTransport::new(
DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x99; 20],
Some(b"announce-token".to_vec()),
Some(compact_node(0x77, [127, 0, 0, 31], 6882)),
Vec::new(),
),
DhtMessageModel::ping_response(b"ap".to_vec(), vec![0x55; 20]),
);
let connector = FakePeerWireConnector::new(peer_wire_handshake_and_frames(
[
0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54,
0x32, 0x10, 0xfe, 0xdc, 0xba, 0x98,
],
*b"-PC0001-LOOP-PEER-01",
&[PeerWireMessageKind::Unchoke],
));
let report = dispatcher
.drive_bt_runtime_once(
&gid,
Some(&tracker),
Some(&dht),
Some(&connector),
Some(1_050),
)
.expect("coordinator loop should run");
assert!(report.initial_snapshot.metadata_exchange_pending);
assert!(report.final_snapshot.metadata_exchange_pending);
assert_eq!(
report
.steps
.iter()
.map(|step| (step.action, step.status))
.collect::<Vec<_>>(),
vec![
(
BtRuntimeCoordinatorAction::AdvanceClock,
BtRuntimeCoordinatorStepStatus::Executed,
),
(
BtRuntimeCoordinatorAction::TrackerAnnounce,
BtRuntimeCoordinatorStepStatus::Executed,
),
(
BtRuntimeCoordinatorAction::DhtGetPeers,
BtRuntimeCoordinatorStepStatus::Executed,
),
(
BtRuntimeCoordinatorAction::DhtAnnouncePeer,
BtRuntimeCoordinatorStepStatus::Executed,
),
(
BtRuntimeCoordinatorAction::PeerWireExchange,
BtRuntimeCoordinatorStepStatus::Executed,
),
]
);
assert_eq!(tracker.seen_announces().len(), 1);
assert_eq!(
tracker.seen_scrapes(),
vec!["http://tracker.example.org/announce".to_owned()]
);
assert_eq!(
dht.seen().len(),
2,
"get_peers plus announce_peer should run"
);
assert_eq!(
connector.seen().len(),
1,
"peer-wire should run after peers arrive"
);
let final_snapshot = dispatcher
.bt_runtime_coordinator_snapshot(&gid)
.expect("final snapshot should remain readable");
assert!(final_snapshot.has_dht_token);
assert!(final_snapshot.connectable_peer_count >= 1);
assert!(final_snapshot.addressable_dht_node_count >= 2);
}
#[doc(hidden)]
fn xml_request(method_name: &str) -> XmlRpcMethodCall {
XmlRpcMethodCall {
method_name: method_name.to_owned(),
params: Vec::new(),
meta: RpcMeta::default(),
}
}
#[doc(hidden)]
fn xml_request_with_params(method_name: &str, params: Vec<XmlRpcValue>) -> XmlRpcMethodCall {
XmlRpcMethodCall {
method_name: method_name.to_owned(),
params: params
.into_iter()
.map(|value| XmlRpcParam { value })
.collect(),
meta: RpcMeta::default(),
}
}
mod bt_and_extension;
mod protocol_surface;
mod queue_and_options;
@@ -0,0 +1,7 @@
pub(super) use super::*;
mod bt_status_and_magnet;
mod dht_runtime;
mod extensions_and_multicall;
mod peer_wire_runtime;
mod tracker_and_bridges;
@@ -0,0 +1,336 @@
use super::*;
#[test]
fn bt_status_reports_local_seeding_truthfully_under_peer_pressure() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=SeedFields",
);
let download_id = DownloadId::parse_hex(&gid).expect("gid should parse");
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("group should exist");
group.set_status(aria2_rust_pro_core::DownloadStatus::Active);
group.set_total_length(10_000);
group.set_completed_length(4_000);
group.set_upload_length(2_500);
let mut bt = group
.bt()
.cloned()
.expect("magnet should have bt runtime state");
bt.peers.push(BtPeerInfo {
peer_id: Some("feedbeef".to_owned()),
ip: "10.0.0.2".to_owned(),
port: 51413,
client_name: Some("seed-peer".to_owned()),
interested: true,
choked: false,
download_speed: 0,
upload_speed: 128,
seeder: true,
});
group.set_bt(bt);
group
.options_mut()
.insert("seed-time", aria2_rust_pro_core::OptionValue::UInt(600));
}
dispatcher
.engine
.set_bt_seeding_state(download_id, true, Some(1_000))
.expect("local seeding should start");
dispatcher
.engine
.set_bt_seeding_state(download_id, false, Some(1_030))
.expect("local seeding should stop");
let status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("status"),
Some(&RpcValue::String("active".to_owned()))
);
assert_ne!(
payload.get("status"),
Some(&RpcValue::String("complete".to_owned()))
);
assert_eq!(
payload.get("seeder"),
Some(&RpcValue::String("false".to_owned()))
);
assert_eq!(
payload.get("numSeeders"),
Some(&RpcValue::String("1".to_owned()))
);
assert_eq!(
payload.get("uploadLength"),
Some(&RpcValue::String("2500".to_owned()))
);
assert!(matches!(
payload.get("shareRatio"),
Some(RpcValue::String(value)) if !value.is_empty()
));
assert_eq!(
payload.get("shareTime"),
Some(&RpcValue::String("30".to_owned()))
);
}
other => panic!("unexpected tellStatus seeding payload: {other:?}"),
}
}
#[test]
fn get_servers_and_peers_include_bt_seed_state_fields() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
);
let group = dispatcher
.engine
.handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse"))
.expect("group should exist");
group.set_status(aria2_rust_pro_core::DownloadStatus::Active);
let mut bt = group
.bt()
.cloned()
.expect("magnet should have bt runtime state");
bt.trackers[0].seeders = Some(9);
bt.peers.push(BtPeerInfo {
peer_id: Some("001122".to_owned()),
ip: "127.0.0.1".to_owned(),
port: 6881,
client_name: Some("peer-a".to_owned()),
interested: true,
choked: false,
download_speed: 16,
upload_speed: 32,
seeder: true,
});
group.set_bt(bt);
let servers = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetServers,
vec![RpcValue::String(gid.clone())],
));
match servers.result {
Some(RpcValue::Array(entries)) => {
assert!(!entries.is_empty());
assert!(matches!(
entries.first(),
Some(RpcValue::Object(server)) if server.get("isBt") == Some(&RpcValue::Bool(true))
));
}
other => panic!("unexpected getServers seed-state result: {other:?}"),
}
let peers = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid.clone())],
));
match peers.result {
Some(RpcValue::Array(items)) => match items.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("uploadSpeed"),
Some(&RpcValue::String("32".to_owned()))
);
assert_eq!(
peer.get("seeder"),
Some(&RpcValue::String("true".to_owned()))
);
}
other => panic!("unexpected getPeers seed-state row: {other:?}"),
},
other => panic!("unexpected getPeers seed-state result: {other:?}"),
}
}
#[test]
fn get_peers_returns_bt_peer_shape_for_bt_like_download() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
);
let peers = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid.clone())],
));
match peers.result {
Some(RpcValue::Array(items)) => {
assert!(
items.is_empty(),
"magnet registration alone should not fabricate peer rows"
);
}
other => panic!("unexpected getPeers result: {other:?}"),
}
}
#[test]
fn add_uri_magnet_registers_runtime_backed_bt_fields() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Ubuntu%2024.04&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
);
let status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
assert_eq!(
payload.get("metadataOnly"),
Some(&RpcValue::Bool(true)),
"magnet registrations should be metadata-only at addUri time"
);
assert_eq!(
payload.get("infoHash"),
Some(&RpcValue::String(
"0123456789ABCDEF0123456789ABCDEF01234567".to_owned()
))
);
assert!(matches!(
payload.get("magnetUri"),
Some(RpcValue::String(uri)) if uri.starts_with("magnet:?")
));
assert!(matches!(
payload.get("announceList"),
Some(RpcValue::Array(tiers))
if matches!(
tiers.first(),
Some(RpcValue::Array(urls))
if urls.contains(&RpcValue::String(
"http://tracker.example.org/announce".to_owned()
))
)
));
}
other => panic!("unexpected tellStatus after magnet addUri: {other:?}"),
}
}
#[test]
fn add_uri_magnet_uses_bootstrap_peer_and_dht_hints() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:AERUKZ4JVPG66AJDIVTYTK6N54ASGRLH&dn=peer-hints&tr=http%3A%2F%2Ftracker.example.org%2Fannounce&x.pe=198.51.100.9:51413&x.pe=%5B2001:db8::9%5D:51413",
);
let peers = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid.clone())],
));
match peers.result {
Some(RpcValue::Array(items)) => {
assert_eq!(items.len(), 2);
let rendered = items
.iter()
.map(|value| match value {
RpcValue::Object(payload) => {
(payload.get("ip").cloned(), payload.get("port").cloned())
}
other => panic!("unexpected peer row: {other:?}"),
})
.collect::<Vec<_>>();
assert!(rendered.contains(&(
Some(RpcValue::String("198.51.100.9".to_owned())),
Some(RpcValue::String("51413".to_owned())),
)));
assert!(rendered.contains(&(
Some(RpcValue::String("2001:db8::9".to_owned())),
Some(RpcValue::String("51413".to_owned())),
)));
}
other => panic!("unexpected getPeers result for hinted magnet: {other:?}"),
}
let group = dispatcher
.engine
.registry()
.get(download_id(&gid))
.expect("magnet gid should remain registered");
let bt = group.bt().expect("magnet gid should own bt state");
assert!(
bt.dht_nodes.contains(&"198.51.100.9:51413".to_owned()),
"ipv4 x.pe hint should seed dht/bootstrap nodes"
);
assert!(
bt.dht_nodes.contains(&"[2001:db8::9]:51413".to_owned()),
"ipv6 x.pe hint should seed dht/bootstrap nodes"
);
assert_eq!(bt.info_hash.len(), 40);
assert!(
bt.info_hash
.chars()
.all(|ch| ch.is_ascii_hexdigit() && !ch.is_ascii_lowercase()),
"base32 btih should normalize into canonical uppercase hex"
);
}
#[test]
fn apply_tracker_announce_result_updates_get_peers() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
);
let announce = TrackerResponseModel {
peers: aria2_rust_pro_protocol::TrackerPeerListModel {
interval_sec: 1800,
peers: vec![aria2_rust_pro_protocol::TorrentPeerModel {
peer_id: Some(*b"12345678901234567890"),
ip: "127.0.0.1".to_owned(),
port: 6881,
client_name: Some("rust-peer".to_owned()),
interested: true,
choked: false,
}],
min_interval_sec: Some(900),
tracker_id: Some("tracker-session-id".to_owned()),
},
scrape: Some(TrackerScrapeModel {
complete: Some(12),
downloaded: Some(34),
incomplete: Some(56),
files: Vec::new(),
}),
};
dispatcher
.apply_tracker_announce_result(&gid, &announce)
.expect("tracker announce should ingest into runtime state");
let peers = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid.clone())],
));
match peers.result {
Some(RpcValue::Array(items)) => match items.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("ip"),
Some(&RpcValue::String("127.0.0.1".to_owned()))
);
assert_eq!(peer.get("port"), Some(&RpcValue::String("6881".to_owned())));
assert_eq!(
peer.get("peerId"),
Some(&RpcValue::String(
"3132333435363738393031323334353637383930".to_owned()
))
);
}
other => panic!("unexpected getPeers entry after tracker ingest: {other:?}"),
},
other => panic!("unexpected getPeers result after tracker ingest: {other:?}"),
}
}
@@ -0,0 +1,514 @@
use super::*;
#[test]
fn execute_dht_get_peers_rejects_missing_or_invalid_runtime_nodes() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
);
let response =
DhtMessageModel::get_peers_response(b"gp".to_vec(), vec![0x11; 20], None, None, Vec::new());
let transport = FakeDhtTransport::new(response);
let download_id = download_id(&gid);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download should exist");
let bt = group.bt_mut().expect("bt runtime state should exist");
bt.dht_nodes.clear();
}
let missing_nodes = dispatcher
.execute_dht_get_peers(&gid, &transport)
.expect_err("missing nodes should be rejected");
assert!(
missing_nodes.message.contains("at least one dht node"),
"unexpected missing-nodes error: {}",
missing_nodes.message
);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download should exist");
let bt = group.bt_mut().expect("bt runtime state should exist");
bt.dht_nodes = vec!["not-a-node".to_owned(), "still.bad:99999".to_owned()];
}
let invalid_nodes = dispatcher
.execute_dht_get_peers(&gid, &transport)
.expect_err("invalid node list should be rejected");
assert!(
invalid_nodes.message.contains("no valid dht nodes"),
"unexpected invalid-node error: {}",
invalid_nodes.message
);
assert!(transport.seen().is_empty(), "transport should not be used");
}
#[test]
fn execute_dht_ping_rejects_missing_or_invalid_runtime_nodes() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:abababababababababababababababababababab",
);
let response = DhtMessageModel::ping_response(b"pi".to_vec(), vec![0x11; 20]);
let transport = FakeDhtTransport::new(response);
let download_id = download_id(&gid);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download should exist");
let bt = group.bt_mut().expect("bt runtime state should exist");
bt.dht_nodes.clear();
}
let missing_nodes = dispatcher
.execute_dht_ping(&gid, &transport)
.expect_err("missing nodes should be rejected");
assert!(
missing_nodes.message.contains("at least one dht node"),
"unexpected missing-nodes error: {}",
missing_nodes.message
);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download should exist");
let bt = group.bt_mut().expect("bt runtime state should exist");
bt.dht_nodes = vec!["bad-node".to_owned(), "still.bad:99999".to_owned()];
}
let invalid_nodes = dispatcher
.execute_dht_ping(&gid, &transport)
.expect_err("invalid nodes should be rejected");
assert!(
invalid_nodes.message.contains("no valid dht nodes"),
"unexpected invalid-node error: {}",
invalid_nodes.message
);
assert!(transport.seen().is_empty(), "transport should not be used");
}
#[test]
fn apply_dht_ping_result_promotes_responsive_node_and_rejects_bad_node_id() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:bcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbc",
);
let download_id = download_id(&gid);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download should exist");
let bt = group.bt_mut().expect("bt runtime state should exist");
bt.dht_nodes = vec![
"198.51.100.7:6881".to_owned(),
"203.0.113.8:6882".to_owned(),
];
}
dispatcher
.apply_dht_ping_result(
&gid,
&DhtNodeModel {
node_id: String::new(),
address: "203.0.113.8".to_owned(),
port: 6882,
},
&DhtMessageModel::ping_response(b"pi".to_vec(), vec![0x44; 20]),
)
.expect("valid ping should promote responsive node");
let bt = dispatcher
.engine
.registry()
.get(download_id)
.and_then(|group| group.bt())
.expect("bt runtime state should remain present");
assert_eq!(
bt.dht_nodes.first().map(String::as_str),
Some("203.0.113.8:6882")
);
let error = dispatcher
.apply_dht_ping_result(
&gid,
&DhtNodeModel {
node_id: String::new(),
address: "192.0.2.9".to_owned(),
port: 6881,
},
&DhtMessageModel::ping_response(b"pi".to_vec(), vec![0x55; 19]),
)
.expect_err("short node id should be rejected");
assert!(error.message.contains("node id must be 20 bytes"));
}
#[test]
fn apply_dht_get_peers_result_updates_rpc_visible_bt_peers() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:ffffffffffffffffffffffffffffffffffffffff",
);
let response = DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x21; 20],
Some(b"tok".to_vec()),
Some(compact_node(0x44, [127, 0, 0, 2], 6882)),
vec![compact_peer([127, 0, 0, 1], 6881)],
);
dispatcher
.apply_dht_get_peers_result(
&gid,
&DhtNodeModel {
node_id: String::new(),
address: "127.0.0.9".to_owned(),
port: 7001,
},
&response,
)
.expect("dht apply should ingest peers");
let peers = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid.clone())],
));
match peers.result {
Some(RpcValue::Array(items)) => match items.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("ip"),
Some(&RpcValue::String("127.0.0.1".to_owned()))
);
assert_eq!(peer.get("port"), Some(&RpcValue::String("6881".to_owned())));
}
other => panic!("unexpected getPeers entry after dht apply: {other:?}"),
},
other => panic!("unexpected getPeers result after dht apply: {other:?}"),
}
let status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("connections"),
Some(&RpcValue::String("1".to_owned()))
);
}
other => panic!("unexpected tellStatus after dht apply: {other:?}"),
}
let bt = dispatcher
.engine
.registry()
.get(download_id(&gid))
.and_then(|group| group.bt())
.expect("bt runtime state should remain available");
assert!(bt.dht_nodes.contains(&"127.0.0.9:7001".to_owned()));
assert!(bt.dht_nodes.contains(&"127.0.0.2:6882".to_owned()));
}
#[test]
fn execute_dht_get_peers_updates_bt_views_and_retains_discovered_nodes() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=ubuntu",
);
let download_id = download_id(&gid);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download should exist");
let bt = group.bt_mut().expect("bt runtime state should exist");
bt.dht_nodes = vec!["bad-node-entry".to_owned(), "127.0.0.8:6885".to_owned()];
}
let response = DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x31; 20],
Some(b"node-token".to_vec()),
Some(compact_node(0x55, [127, 0, 0, 7], 6890)),
vec![compact_peer([127, 0, 0, 6], 6884)],
);
let transport = FakeDhtTransport::new(response);
dispatcher
.execute_dht_get_peers(&gid, &transport)
.expect("dht get_peers should succeed");
let seen = transport.seen();
assert_eq!(seen.len(), 1, "transport should see exactly one request");
assert_eq!(seen[0].0.address, "127.0.0.8");
assert_eq!(seen[0].0.port, 6885);
match &seen[0].1.body {
DhtMessageBody::Query(DhtQueryModel::GetPeers(DhtGetPeersQueryModel {
info_hash, ..
})) => assert_eq!(
info_hash,
&vec![
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab,
0xcd, 0xef, 0x01, 0x23, 0x45, 0x67
]
),
other => panic!("unexpected dht request body: {other:?}"),
}
let peers = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid.clone())],
));
match peers.result {
Some(RpcValue::Array(items)) => match items.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("ip"),
Some(&RpcValue::String("127.0.0.6".to_owned()))
);
assert_eq!(peer.get("port"), Some(&RpcValue::String("6884".to_owned())));
}
other => panic!("unexpected getPeers entry after dht execute: {other:?}"),
},
other => panic!("unexpected getPeers result after dht execute: {other:?}"),
}
let status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
assert_eq!(
payload.get("connections"),
Some(&RpcValue::String("1".to_owned()))
);
}
other => panic!("unexpected tellStatus after dht execute: {other:?}"),
}
let bt = dispatcher
.engine
.registry()
.get(download_id)
.and_then(|group| group.bt())
.expect("bt runtime state should remain available");
assert!(bt.dht_nodes.contains(&"127.0.0.8:6885".to_owned()));
assert!(bt.dht_nodes.contains(&"127.0.0.7:6890".to_owned()));
}
#[test]
fn execute_dht_ping_sends_ping_query_and_promotes_responsive_node() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
);
let download_id = download_id(&gid);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download should exist");
let bt = group.bt_mut().expect("bt runtime state should exist");
bt.dht_nodes = vec!["bad-node".to_owned(), "127.0.0.10:6886".to_owned()];
}
let response = DhtMessageModel::ping_response(b"pi".to_vec(), vec![0x77; 20]);
let transport = FakeDhtTransport::new(response);
dispatcher
.execute_dht_ping(&gid, &transport)
.expect("dht ping should succeed");
let seen = transport.seen();
assert_eq!(seen.len(), 1, "transport should see exactly one ping");
assert_eq!(seen[0].0.address, "127.0.0.10");
assert_eq!(seen[0].0.port, 6886);
match &seen[0].1.body {
DhtMessageBody::Query(DhtQueryModel::Ping(query)) => {
assert_eq!(query.node_id.len(), 20);
}
other => panic!("unexpected dht ping request body: {other:?}"),
}
let bt = dispatcher
.engine
.registry()
.get(download_id)
.and_then(|group| group.bt())
.expect("bt runtime state should remain available");
assert_eq!(
bt.dht_nodes.first().map(String::as_str),
Some("127.0.0.10:6886")
);
}
#[test]
fn execute_dht_find_node_rejects_missing_or_invalid_runtime_nodes() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:dededededededededededededededededededede",
);
let response = DhtMessageModel::find_node_response(b"fn".to_vec(), vec![0x11; 20], Vec::new());
let transport = FakeDhtTransport::new(response);
let download_id = download_id(&gid);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download should exist");
let bt = group.bt_mut().expect("bt runtime state should exist");
bt.dht_nodes.clear();
}
let missing_nodes = dispatcher
.execute_dht_find_node(&gid, &transport)
.expect_err("missing nodes should be rejected");
assert!(
missing_nodes.message.contains("at least one dht node"),
"unexpected missing-nodes error: {}",
missing_nodes.message
);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download should exist");
let bt = group.bt_mut().expect("bt runtime state should exist");
bt.dht_nodes = vec!["bad-node".to_owned(), "still.bad:99999".to_owned()];
}
let invalid_nodes = dispatcher
.execute_dht_find_node(&gid, &transport)
.expect_err("invalid nodes should be rejected");
assert!(
invalid_nodes.message.contains("no valid dht nodes"),
"unexpected invalid-node error: {}",
invalid_nodes.message
);
assert!(transport.seen().is_empty(), "transport should not be used");
}
#[test]
fn execute_dht_get_peers_reports_upstream_style_invalid_gid_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "bad-dht-gid".to_owned();
let response = DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x31; 20],
Some(b"node-token".to_vec()),
None,
Vec::new(),
);
let transport = FakeDhtTransport::new(response);
let error = dispatcher
.execute_dht_get_peers(&gid, &transport)
.expect_err("invalid gid should be rejected before transport");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("Invalid GID {gid}"));
assert!(transport.seen().is_empty(), "transport should not be used");
}
#[test]
fn execute_dht_get_peers_reports_missing_download_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "00000000000000ab".to_owned();
let response = DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x31; 20],
Some(b"node-token".to_vec()),
None,
Vec::new(),
);
let transport = FakeDhtTransport::new(response);
let error = dispatcher
.execute_dht_get_peers(&gid, &transport)
.expect_err("missing gid should be rejected before transport");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("No such download for GID#{gid}"));
assert!(transport.seen().is_empty(), "transport should not be used");
}
#[test]
fn apply_dht_find_node_result_discovers_nodes_and_promotes_responsive_node() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:efefefefefefefefefefefefefefefefefefefef",
);
let download_id = download_id(&gid);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download should exist");
let bt = group.bt_mut().expect("bt runtime state should exist");
bt.dht_nodes = vec![
"198.51.100.7:6881".to_owned(),
"203.0.113.8:6882".to_owned(),
];
}
dispatcher
.apply_dht_find_node_result(
&gid,
&DhtNodeModel {
node_id: String::new(),
address: "203.0.113.8".to_owned(),
port: 6882,
},
&DhtMessageModel::find_node_response(
b"fn".to_vec(),
vec![0x44; 20],
vec![aria2_rust_pro_protocol::torrent::DhtCompactNodeModel {
node_id: [0x88; 20],
address: [127, 0, 0, 7],
port: 6890,
}],
),
)
.expect("valid find_node response should promote responsive node");
let bt = dispatcher
.engine
.registry()
.get(download_id)
.and_then(|group| group.bt())
.expect("bt runtime state should remain present");
assert_eq!(
bt.dht_nodes.first().map(String::as_str),
Some("203.0.113.8:6882")
);
assert!(bt.dht_nodes.contains(&"127.0.0.7:6890".to_owned()));
let error = dispatcher
.apply_dht_find_node_result(
&gid,
&DhtNodeModel {
node_id: String::new(),
address: "192.0.2.9".to_owned(),
port: 6881,
},
&DhtMessageModel::find_node_response(b"fn".to_vec(), vec![0x55; 19], Vec::new()),
)
.expect_err("short node id should be rejected");
assert!(error.message.contains("node id must be 20 bytes"));
}
@@ -0,0 +1,338 @@
use super::*;
#[test]
fn add_metalink_registers_preferred_resource_uri_as_download() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2AddMetalink,
vec![RpcValue::String(
r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0">
<file name="example.iso">
<url priority="9">http://example.org/example.iso</url>
<url priority="1">http://mirror.example.org/example.iso</url>
</file>
</metalink>"#
.to_owned(),
)],
));
let gid = match response.result {
Some(RpcValue::Array(items)) => match items.first() {
Some(RpcValue::String(gid)) => gid.clone(),
other => panic!("unexpected addMetalink gid entry: {other:?}"),
},
other => panic!("unexpected addMetalink result: {other:?}"),
};
assert_eq!(dispatcher.tracked_download_count(), 1);
let uris = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetUris,
vec![RpcValue::String(gid.clone())],
));
match uris.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(entry)) => {
assert_eq!(
entry.get("uri"),
Some(&RpcValue::String(
"http://mirror.example.org/example.iso".to_owned()
))
);
}
other => panic!("unexpected getUris entry after addMetalink: {other:?}"),
},
other => panic!("unexpected getUris result after addMetalink: {other:?}"),
}
}
#[test]
fn add_metalink_accepts_base64_payload_and_applies_options() {
let mut dispatcher = InProcessRpcDispatcher::new();
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0">
<file name="example.iso">
<url priority="9">http://example.org/example.iso</url>
<url priority="1">http://mirror.example.org/example.iso</url>
</file>
</metalink>"#;
let payload = base64::engine::general_purpose::STANDARD.encode(xml.as_bytes());
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2AddMetalink,
vec![
RpcValue::String(payload),
RpcValue::Object(BTreeMap::from([(
"dir".to_owned(),
RpcValue::String("/metalink-downloads".to_owned()),
)])),
RpcValue::Number(0),
],
));
let gid = match response.result {
Some(RpcValue::Array(items)) => match items.first() {
Some(RpcValue::String(gid)) => gid.clone(),
other => panic!("unexpected addMetalink gid entry: {other:?}"),
},
other => panic!("unexpected addMetalink result: {other:?}"),
};
let options = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetOption,
vec![RpcValue::String(gid.clone())],
));
match options.result {
Some(RpcValue::Object(options)) => {
assert_eq!(
options.get("dir"),
Some(&RpcValue::String("/metalink-downloads".to_owned()))
);
}
other => panic!("unexpected getOption result after addMetalink: {other:?}"),
}
}
#[test]
fn add_metalink_registers_each_actionable_file_with_implied_defaults() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2AddMetalink,
vec![RpcValue::String(
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://mirror.example.org/alpha.bin</url>
</file>
<file name="ignored.bin">
<url priority="1"></url>
</file>
<file name="beta.bin">
<url priority="2">https://backup.example.org/beta.bin</url>
<url priority="1">https://example.org/beta.bin</url>
</file>
</metalink>"#
.to_owned(),
)],
));
let gids = match response.result {
Some(RpcValue::Array(items)) => items
.into_iter()
.map(|item| match item {
RpcValue::String(gid) => gid,
other => panic!("unexpected addMetalink gid entry: {other:?}"),
})
.collect::<Vec<_>>(),
other => panic!("unexpected addMetalink result: {other:?}"),
};
assert_eq!(gids.len(), 2);
assert_eq!(dispatcher.tracked_download_count(), 2);
let first_options = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetOption,
vec![RpcValue::String(gids[0].clone())],
));
match first_options.result {
Some(RpcValue::Object(options)) => {
assert_eq!(
options.get("out"),
Some(&RpcValue::String("alpha.bin".to_owned()))
);
assert_eq!(
options.get("checksum"),
Some(&RpcValue::String(
"md5=900150983cd24fb0d6963f7d28e17f72".to_owned()
))
);
}
other => panic!("unexpected first getOption result after addMetalink: {other:?}"),
}
let second_uris = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetUris,
vec![RpcValue::String(gids[1].clone())],
));
match second_uris.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(entry)) => {
assert_eq!(
entry.get("uri"),
Some(&RpcValue::String("https://example.org/beta.bin".to_owned()))
);
}
other => panic!("unexpected second getUris entry after addMetalink: {other:?}"),
},
other => panic!("unexpected second getUris result after addMetalink: {other:?}"),
}
}
#[test]
fn add_metalink_selects_preferred_resource_from_first_actionable_file() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2AddMetalink,
vec![RpcValue::String(
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">
<url priority="1">https://mirror-b.example.org/picked.bin</url>
<url priority="1" location="us">https://mirror-a.example.org/picked.bin</url>
</file>
<file name="later.bin">
<url priority="1">https://later.example.org/later.bin</url>
</file>
</metalink>"#
.to_owned(),
)],
));
let gid = match response.result {
Some(RpcValue::Array(items)) => match items.first() {
Some(RpcValue::String(gid)) => gid.clone(),
other => panic!("unexpected addMetalink gid entry: {other:?}"),
},
other => panic!("unexpected addMetalink result: {other:?}"),
};
assert_eq!(dispatcher.tracked_download_count(), 2);
let uris = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetUris,
vec![RpcValue::String(gid.clone())],
));
match uris.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(entry)) => {
assert_eq!(
entry.get("uri"),
Some(&RpcValue::String(
"https://mirror-a.example.org/picked.bin".to_owned()
))
);
}
other => panic!("unexpected getUris entry after addMetalink: {other:?}"),
},
other => panic!("unexpected getUris result after addMetalink: {other:?}"),
}
}
#[test]
fn add_metalink_rejects_invalid_document() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2AddMetalink,
vec![RpcValue::String("<metalink></metalink>".to_owned())],
));
assert!(response.result.is_none());
assert!(response.error.is_some());
}
#[test]
fn multicall_wraps_success_results_and_preserves_order() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_json(request(
RpcMethod::SystemMulticall,
vec![RpcValue::Array(vec![
RpcValue::Object(BTreeMap::from([
(
"methodName".to_owned(),
RpcValue::String("aria2.getVersion".to_owned()),
),
("params".to_owned(), RpcValue::Array(Vec::new())),
])),
RpcValue::Object(BTreeMap::from([
(
"methodName".to_owned(),
RpcValue::String("system.listMethods".to_owned()),
),
("params".to_owned(), RpcValue::Array(Vec::new())),
])),
])],
));
match response.result {
Some(RpcValue::Array(items)) => {
assert_eq!(items.len(), 2);
match &items[0] {
RpcValue::Array(first) => match first.first() {
Some(RpcValue::Object(payload)) => {
assert!(payload.contains_key("version"));
}
other => panic!("unexpected first multicall payload: {other:?}"),
},
other => panic!("unexpected first multicall item: {other:?}"),
}
match &items[1] {
RpcValue::Array(second) => match second.first() {
Some(RpcValue::Array(methods)) => {
assert!(!methods.is_empty());
}
other => panic!("unexpected second multicall payload: {other:?}"),
},
other => panic!("unexpected second multicall item: {other:?}"),
}
}
other => panic!("unexpected multicall result: {other:?}"),
}
}
#[test]
fn multicall_returns_error_object_for_invalid_member_and_missing_method_name() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_json(request(
RpcMethod::SystemMulticall,
vec![RpcValue::Array(vec![
RpcValue::String("bad".to_owned()),
RpcValue::Object(BTreeMap::new()),
])],
));
match response.result {
Some(RpcValue::Array(items)) => {
assert_eq!(items.len(), 2);
for item in items {
match item {
RpcValue::Object(payload) => {
assert!(payload.contains_key("code"));
assert!(payload.contains_key("message"));
}
other => panic!("unexpected multicall error item: {other:?}"),
}
}
}
other => panic!("unexpected multicall result: {other:?}"),
}
}
#[test]
fn multicall_rejects_recursive_invocation() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_json(request(
RpcMethod::SystemMulticall,
vec![RpcValue::Array(vec![RpcValue::Object(BTreeMap::from([
(
"methodName".to_owned(),
RpcValue::String("system.multicall".to_owned()),
),
("params".to_owned(), RpcValue::Array(Vec::new())),
]))])],
));
match response.result {
Some(RpcValue::Array(items)) => match items.first() {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("message"),
Some(&RpcValue::String(
"Recursive system.multicall forbidden.".to_owned()
))
);
}
other => panic!("unexpected recursive multicall payload: {other:?}"),
},
other => panic!("unexpected recursive multicall result: {other:?}"),
}
}
@@ -0,0 +1,777 @@
use super::*;
#[test]
fn execute_peer_wire_exchange_rejects_missing_or_invalid_runtime_peers() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:9999999999999999999999999999999999999999",
);
let connector = FakePeerWireConnector::new(Vec::new());
let download_id = download_id(&gid);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download should exist");
let bt = group.bt_mut().expect("bt runtime state should exist");
bt.peers.clear();
}
let missing_peers = dispatcher
.execute_peer_wire_exchange(&gid, &connector)
.expect_err("missing peers should be rejected");
assert!(
missing_peers
.message
.contains("requires at least one bt peer"),
"unexpected missing-peer error: {}",
missing_peers.message
);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download should exist");
let bt = group.bt_mut().expect("bt runtime state should exist");
bt.peers = vec![peer_from_ip("", 0), peer_from_ip("127.0.0.1", 0)];
}
let invalid_peers = dispatcher
.execute_peer_wire_exchange(&gid, &connector)
.expect_err("invalid peers should be rejected");
assert!(
invalid_peers.message.contains("found no valid bt peers"),
"unexpected invalid-peer error: {}",
invalid_peers.message
);
assert!(connector.seen().is_empty(), "transport should not be used");
}
#[test]
fn execute_peer_wire_exchange_reports_upstream_style_invalid_gid_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "bad-peer-wire-gid".to_owned();
let connector = FakePeerWireConnector::new(Vec::new());
let error = dispatcher
.execute_peer_wire_exchange(&gid, &connector)
.expect_err("invalid gid should be rejected before connector use");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("Invalid GID {gid}"));
assert!(connector.seen().is_empty(), "connector should not be used");
}
#[test]
fn execute_peer_wire_exchange_builds_handshake_and_request_from_bt_runtime_state() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
);
let download_id = download_id(&gid);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download should exist");
group.set_piece_length(1024);
group.set_total_length(2048);
group.set_piece_state(PieceId(0), PieceState::Pending);
group.set_piece_state(PieceId(1), PieceState::Missing);
let bt = group.bt_mut().expect("bt runtime state should exist");
bt.metadata_only = false;
bt.peers = vec![peer_from_ip("127.0.0.2", 51413)];
}
let info_hash = [
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd,
0xef, 0x01, 0x23, 0x45, 0x67,
];
let remote_peer_id = *b"-UT0001-123456789012";
let connector = FakePeerWireConnector::new(peer_wire_handshake_and_frames(
info_hash,
remote_peer_id,
&[],
));
dispatcher
.execute_peer_wire_exchange(&gid, &connector)
.expect("peer-wire exchange should succeed");
let seen = connector.seen();
assert_eq!(seen.len(), 1, "transport should receive one request");
assert_eq!(seen[0].endpoint.address, "127.0.0.2:51413");
assert_eq!(seen[0].info_hash, info_hash.to_vec());
assert_eq!(seen[0].peer_id.len(), 20);
let (handshake, consumed) = PeerWireHandshakeModel::parse_prefix(&seen[0].payload)
.expect("request payload should begin with a valid handshake");
assert_eq!(handshake.info_hash, info_hash);
assert_eq!(handshake.peer_id.as_slice(), seen[0].peer_id.as_slice());
let (interested, interested_len) =
TorrentMessageModel::parse_peer_wire_frame(&seen[0].payload[consumed..])
.expect("interested frame should parse");
assert_eq!(
interested.peer_wire_kind(),
Ok(PeerWireMessageKind::Interested)
);
let request = TorrentMessageModel::parse_peer_wire_frame_exact(
&seen[0].payload[consumed + interested_len..],
)
.expect("request frame should parse");
match request.peer_wire_kind() {
Ok(PeerWireMessageKind::Request(block)) => {
assert_eq!(block.piece_index, 0);
assert_eq!(block.block_offset, 0);
assert_eq!(block.block_length, 1024);
}
other => panic!("unexpected peer-wire request frame: {other:?}"),
}
}
#[test]
fn execute_peer_wire_exchange_metadata_only_sends_extension_handshake_and_learns_metadata() {
let torrent_bytes = single_file_torrent_bytes("metadata.iso", 0);
let metadata = parse_torrent_metadata(&torrent_bytes).expect("reference torrent should parse");
let info_hash = metadata
.info
.hash
.as_ref()
.expect("reference torrent should expose info hash");
let info_hash_bytes: [u8; 20] =
decode_hex_string_exact(&info_hash.info_hash_hex, 20, "test info hash")
.expect("reference torrent info hash bytes should decode")
.try_into()
.expect("reference torrent info hash should be 20 bytes");
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
&format!(
"magnet:?xt=urn:btih:{}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
info_hash.info_hash_hex
),
);
let download_id = download_id(&gid);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download should exist");
let bt = group.bt_mut().expect("bt runtime state should exist");
bt.peers = vec![peer_from_ip("127.0.0.2", 51413)];
}
let extension_handshake = PeerWireExtensionHandshakeModel {
extensions: BTreeMap::from([("ut_metadata".to_owned(), 3_u8)]),
client_name: Some("libtorrent/2.0.11".to_owned()),
metadata_size: Some(u32::try_from(torrent_bytes.len()).expect("test torrent fits u32")),
request_queue: Some(64),
};
let connector = FakePeerWireConnector::new(peer_wire_handshake_and_frames(
info_hash_bytes,
*b"-LT0001-META-PEER-01",
&[
PeerWireMessageKind::Unchoke,
PeerWireMessageKind::Extension(extension_handshake.to_peer_wire_message()),
],
));
dispatcher
.execute_peer_wire_exchange(&gid, &connector)
.expect("metadata-only exchange should succeed");
let seen = connector.seen();
let (handshake, consumed) = PeerWireHandshakeModel::parse_prefix(&seen[0].payload)
.expect("request payload should begin with a valid handshake");
assert!(handshake.extension_protocol_enabled());
let extension = TorrentMessageModel::parse_peer_wire_frame_exact(&seen[0].payload[consumed..])
.expect("extension handshake frame should parse");
match extension.peer_wire_kind() {
Ok(PeerWireMessageKind::Extension(message)) => {
let decoded = PeerWireExtensionHandshakeModel::from_peer_wire_message(&message)
.expect("outbound extended handshake should decode");
assert_eq!(decoded.ut_metadata_id(), Some(1));
}
other => panic!("unexpected metadata-only outbound frame: {other:?}"),
}
let group = dispatcher
.engine
.registry()
.get(download_id)
.expect("download should remain registered");
let bt = group.bt().expect("bt runtime state should remain present");
assert!(bt.metadata_only);
assert_eq!(
bt.metadata_size,
Some(u32::try_from(torrent_bytes.len()).expect("test torrent fits u32"))
);
assert_eq!(
bt.metadata_extension_ids.get("127.0.0.2:51413"),
Some(&3_u8)
);
assert_eq!(
bt.peers
.first()
.and_then(|peer| peer.client_name.as_deref()),
Some("libtorrent/2.0.11")
);
}
#[test]
fn execute_peer_wire_exchange_promotes_metadata_only_magnet_to_torrent_surface() {
let torrent_bytes = single_file_torrent_bytes("promoted.iso", 17_000);
let metadata = parse_torrent_metadata(&torrent_bytes).expect("reference torrent should parse");
let info_hash = metadata
.info
.hash
.as_ref()
.expect("reference torrent should expose info hash");
let info_hash_bytes: [u8; 20] =
decode_hex_string_exact(&info_hash.info_hash_hex, 20, "test info hash")
.expect("reference torrent info hash bytes should decode")
.try_into()
.expect("reference torrent info hash should be 20 bytes");
let mut dispatcher = InProcessRpcDispatcher::new();
let magnet_gid = add_uri(
&mut dispatcher,
&format!(
"magnet:?xt=urn:btih:{}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
info_hash.info_hash_hex
),
);
let magnet_download_id = download_id(&magnet_gid);
{
let group = dispatcher
.engine
.handle_mut(magnet_download_id)
.expect("magnet download should exist");
let bt = group.bt_mut().expect("magnet runtime state should exist");
bt.peers = vec![peer_from_ip("127.0.0.22", 51413)];
}
let torrent_response = dispatcher.dispatch_json(request(
RpcMethod::Aria2AddTorrent,
vec![RpcValue::String(
base64::engine::general_purpose::STANDARD.encode(&torrent_bytes),
)],
));
let torrent_gid = match (torrent_response.result, torrent_response.error) {
(Some(RpcValue::String(gid)), None) => gid,
other => panic!("unexpected addTorrent result: {other:?}"),
};
let metadata_size =
u32::try_from(torrent_bytes.len()).expect("test torrent metadata should fit u32");
let piece_zero_len = bt_metadata_piece_span(metadata_size, 0);
let piece_one_len = bt_metadata_piece_span(metadata_size, 1);
let extension_handshake = PeerWireExtensionHandshakeModel {
extensions: BTreeMap::from([("ut_metadata".to_owned(), 3_u8)]),
client_name: Some("libtorrent/2.0.11".to_owned()),
metadata_size: Some(metadata_size),
request_queue: Some(64),
};
let connector = SequencedPeerWireConnector::new(vec![
peer_wire_handshake_and_frames(
info_hash_bytes,
*b"-LT0001-META-PEER-02",
&[
PeerWireMessageKind::Unchoke,
PeerWireMessageKind::Extension(extension_handshake.to_peer_wire_message()),
PeerWireMessageKind::Extension(
PeerWireMetadataMessageModel::data(
0,
metadata_size,
torrent_bytes[..piece_zero_len].to_vec(),
)
.to_peer_wire_message(3),
),
],
),
peer_wire_handshake_and_frames(
info_hash_bytes,
*b"-LT0001-META-PEER-02",
&[PeerWireMessageKind::Extension(
PeerWireMetadataMessageModel::data(
1,
metadata_size,
torrent_bytes[piece_zero_len..piece_zero_len + piece_one_len].to_vec(),
)
.to_peer_wire_message(3),
)],
),
]);
dispatcher
.execute_peer_wire_exchange(&magnet_gid, &connector)
.expect("first metadata exchange should succeed");
{
let group = dispatcher
.engine
.registry()
.get(magnet_download_id)
.expect("magnet download should remain registered");
let bt = group.bt().expect("magnet bt runtime should remain present");
assert!(bt.metadata_only);
assert_eq!(bt.metadata_piece_payloads.len(), 1);
}
dispatcher
.execute_peer_wire_exchange(&magnet_gid, &connector)
.expect("second metadata exchange should promote torrent metadata");
let seen = connector.seen();
let (first_handshake, first_consumed) = PeerWireHandshakeModel::parse_prefix(&seen[0].payload)
.expect("first request should begin with a valid handshake");
assert!(first_handshake.extension_protocol_enabled());
let first_extension =
TorrentMessageModel::parse_peer_wire_frame_exact(&seen[0].payload[first_consumed..])
.expect("first outbound extension handshake should parse");
assert!(matches!(
first_extension.peer_wire_kind(),
Ok(PeerWireMessageKind::Extension(_))
));
let (_, second_consumed) = PeerWireHandshakeModel::parse_prefix(&seen[1].payload)
.expect("second request should begin with a valid handshake");
let second_extension_handshake =
TorrentMessageModel::parse_peer_wire_frame(&seen[1].payload[second_consumed..])
.expect("second outbound extension handshake should parse");
assert!(matches!(
second_extension_handshake.0.peer_wire_kind(),
Ok(PeerWireMessageKind::Extension(_))
));
let second_extension = TorrentMessageModel::parse_peer_wire_frame_exact(
&seen[1].payload[second_consumed + second_extension_handshake.1..],
)
.expect("second outbound metadata request should parse");
match second_extension.peer_wire_kind() {
Ok(PeerWireMessageKind::Extension(message)) => {
let metadata_request =
PeerWireMetadataMessageModel::from_peer_wire_message(&message, 3)
.expect("second outbound extension should be a metadata request");
assert_eq!(
metadata_request.message_type,
PeerWireMetadataMessageType::Request
);
assert_eq!(metadata_request.piece, 1);
}
other => panic!("unexpected second outbound frame: {other:?}"),
}
let magnet_status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(magnet_gid.clone())],
));
let torrent_status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(torrent_gid.clone())],
));
let magnet_files = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetFiles,
vec![RpcValue::String(magnet_gid.clone())],
));
let torrent_files = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetFiles,
vec![RpcValue::String(torrent_gid.clone())],
));
match &magnet_status.result {
Some(RpcValue::Object(fields)) => {
assert_eq!(fields.get("metadataOnly"), Some(&RpcValue::Bool(false)));
assert_eq!(
fields.get("infoHash"),
torrent_status
.result
.as_ref()
.and_then(|result| match result {
RpcValue::Object(reference) => reference.get("infoHash"),
_ => None,
})
);
assert_eq!(
fields.get("totalLength"),
torrent_status
.result
.as_ref()
.and_then(|result| match result {
RpcValue::Object(reference) => reference.get("totalLength"),
_ => None,
})
);
}
other => panic!("unexpected promoted magnet tellStatus result: {other:?}"),
}
match (magnet_files.result, torrent_files.result) {
(Some(RpcValue::Array(mut magnet_items)), Some(RpcValue::Array(mut torrent_items))) => {
assert_eq!(magnet_items.len(), 1);
assert_eq!(torrent_items.len(), 1);
let Some(RpcValue::Object(magnet_file)) = magnet_items.pop() else {
panic!("unexpected promoted magnet getFiles payload");
};
let Some(RpcValue::Object(torrent_file)) = torrent_items.pop() else {
panic!("unexpected reference torrent getFiles payload");
};
for key in [
"bitfield",
"btCompletedPieces",
"btPath",
"completedLength",
"index",
"isBt",
"length",
"numPieces",
"path",
"pieceLength",
"selected",
] {
assert_eq!(
magnet_file.get(key),
torrent_file.get(key),
"mismatch for {key}"
);
}
}
other => panic!("unexpected getFiles comparison payloads: {other:?}"),
}
}
#[test]
fn execute_peer_wire_exchange_prefers_unchoked_peer_and_available_piece() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc",
);
let download_id = download_id(&gid);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download should exist");
group.set_piece_length(1024);
group.set_total_length(2048);
group.set_piece_state(PieceId(0), PieceState::Pending);
group.set_piece_state(PieceId(1), PieceState::Missing);
group.apply_bt_piece_availability_update(BtPieceAvailabilityUpdate {
piece_id: PieceId(1),
peers_with_piece: 1,
});
let bt = group.bt_mut().expect("bt runtime state should exist");
bt.metadata_only = false;
bt.peers = vec![
BtPeerInfo {
choked: true,
..peer_from_ip("198.51.100.8", 51413)
},
BtPeerInfo {
choked: false,
..peer_from_ip("198.51.100.9", 51414)
},
];
}
let info_hash = [0xcc; 20];
let remote_peer_id = *b"-LT1000-PEER-STATE01";
let connector = FakePeerWireConnector::new(peer_wire_handshake_and_frames(
info_hash,
remote_peer_id,
&[PeerWireMessageKind::Unchoke],
));
dispatcher
.execute_peer_wire_exchange(&gid, &connector)
.expect("peer-wire exchange should succeed");
let seen = connector.seen();
assert_eq!(seen.len(), 1, "transport should receive one request");
assert_eq!(seen[0].endpoint.address, "198.51.100.9:51414");
let (handshake, consumed) = PeerWireHandshakeModel::parse_prefix(&seen[0].payload)
.expect("request payload should begin with a valid handshake");
assert_eq!(handshake.info_hash, info_hash);
let (interested, interested_len) =
TorrentMessageModel::parse_peer_wire_frame(&seen[0].payload[consumed..])
.expect("interested frame should parse");
assert_eq!(
interested.peer_wire_kind(),
Ok(PeerWireMessageKind::Interested)
);
let request = TorrentMessageModel::parse_peer_wire_frame_exact(
&seen[0].payload[consumed + interested_len..],
)
.expect("request frame should parse");
match request.peer_wire_kind() {
Ok(PeerWireMessageKind::Request(block)) => {
assert_eq!(block.piece_index, 1);
assert_eq!(block.block_offset, 0);
assert_eq!(block.block_length, 1024);
}
other => panic!("unexpected peer-wire request frame: {other:?}"),
}
}
#[test]
fn execute_peer_wire_exchange_uses_bitfield_and_have_to_update_peer_state_without_claiming_local_seeding()
{
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
);
let download_id = download_id(&gid);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download should exist");
group.set_piece_length(1024);
group.set_total_length(2048);
group.set_piece_state(PieceId(0), PieceState::Pending);
group.set_piece_state(PieceId(1), PieceState::Missing);
let bt = group.bt_mut().expect("bt runtime state should exist");
bt.metadata_only = false;
bt.peers = vec![peer_from_ip("198.51.100.2", 51413)];
}
let info_hash = [0xaa; 20];
let remote_peer_id = *b"-TR3000-HELLO-WORLD!";
let connector = FakePeerWireConnector::new(peer_wire_handshake_and_frames(
info_hash,
remote_peer_id,
&[
PeerWireMessageKind::Unchoke,
PeerWireMessageKind::Bitfield(PeerWireBitfieldModel::from_piece_flags(&[true, true])),
PeerWireMessageKind::Have(1),
],
));
dispatcher
.execute_peer_wire_exchange(&gid, &connector)
.expect("peer-wire bitfield exchange should succeed");
let group = dispatcher
.engine
.registry()
.get(download_id)
.expect("group should remain present");
assert_eq!(group.piece_state(PieceId(0)), Some(PieceState::Downloading));
assert_eq!(group.piece_availability().get(&PieceId(0)), Some(&1));
assert_eq!(group.piece_availability().get(&PieceId(1)), Some(&1));
let peers = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid.clone())],
));
match peers.result {
Some(RpcValue::Array(items)) => match items.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("peerId"),
Some(&RpcValue::String(
"2d5452333030302d48454c4c4f2d574f524c4421".to_owned()
))
);
assert_eq!(
peer.get("peerChoking"),
Some(&RpcValue::String("false".to_owned()))
);
assert_eq!(
peer.get("seeder"),
Some(&RpcValue::String("true".to_owned()))
);
}
other => {
panic!("unexpected getPeers row after peer-wire bitfield exchange: {other:?}")
}
},
other => {
panic!("unexpected getPeers result after peer-wire bitfield exchange: {other:?}")
}
}
let status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("bitfield"),
Some(&RpcValue::String("10".to_owned()))
);
assert_eq!(
payload.get("connections"),
Some(&RpcValue::String("1".to_owned()))
);
assert_eq!(
payload.get("seeder"),
Some(&RpcValue::String("false".to_owned()))
);
assert_eq!(
payload.get("numSeeders"),
Some(&RpcValue::String("1".to_owned()))
);
assert_eq!(
payload.get("shareTime"),
Some(&RpcValue::String("0".to_owned()))
);
}
other => {
panic!("unexpected tellStatus payload after peer-wire bitfield exchange: {other:?}")
}
}
}
#[test]
fn execute_peer_wire_exchange_ignores_out_of_range_have_and_bitfield_pieces() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:abababababababababababababababababababab",
);
let download_id = download_id(&gid);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download should exist");
group.set_piece_length(1024);
group.set_total_length(2048);
let bt = group.bt_mut().expect("bt runtime state should exist");
bt.metadata_only = false;
bt.peers = vec![peer_from_ip("198.51.100.3", 51413)];
}
let info_hash = [0xab; 20];
let remote_peer_id = *b"-TR3000-RANGE-CHECK1";
let connector = FakePeerWireConnector::new(peer_wire_handshake_and_frames(
info_hash,
remote_peer_id,
&[
PeerWireMessageKind::Unchoke,
PeerWireMessageKind::Bitfield(PeerWireBitfieldModel::from_piece_flags(&[
true, true, true, true, true, true, true, true,
])),
PeerWireMessageKind::Have(7),
],
));
dispatcher
.execute_peer_wire_exchange(&gid, &connector)
.expect("peer-wire exchange with out-of-range availability should succeed");
let group = dispatcher
.engine
.registry()
.get(download_id)
.expect("group should remain present");
assert_eq!(group.piece_availability().get(&PieceId(0)), Some(&1));
assert_eq!(group.piece_availability().get(&PieceId(1)), Some(&1));
assert_eq!(group.piece_availability().get(&PieceId(2)), None);
assert_eq!(group.piece_availability().get(&PieceId(7)), None);
}
#[test]
fn execute_peer_wire_exchange_applies_piece_payload_to_completion_and_peer_metrics() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
);
let download_id = download_id(&gid);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download should exist");
group.set_piece_length(1024);
group.set_total_length(1024);
group.set_piece_state(PieceId(0), PieceState::Pending);
let bt = group.bt_mut().expect("bt runtime state should exist");
bt.metadata_only = false;
bt.peers = vec![peer_from_ip("203.0.113.8", 60000)];
}
let info_hash = [0xbb; 20];
let remote_peer_id = *b"-AZ2060-PIECE-FINISH";
let connector = FakePeerWireConnector::new(peer_wire_handshake_and_frames(
info_hash,
remote_peer_id,
&[
PeerWireMessageKind::Unchoke,
PeerWireMessageKind::Piece(PeerWirePieceBlockModel {
piece_index: 0,
block_offset: 0,
block: vec![0x5a; 1024],
}),
],
));
dispatcher
.execute_peer_wire_exchange(&gid, &connector)
.expect("peer-wire piece exchange should succeed");
let group = dispatcher
.engine
.registry()
.get(download_id)
.expect("group should remain present");
assert_eq!(group.piece_state(PieceId(0)), Some(PieceState::Verified));
assert_eq!(group.piece_availability().get(&PieceId(0)), Some(&1));
let status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("status"),
Some(&RpcValue::String("complete".to_owned()))
);
assert_eq!(
payload.get("completedLength"),
Some(&RpcValue::String("1024".to_owned()))
);
assert_eq!(
payload.get("completedPieces"),
Some(&RpcValue::String("1".to_owned()))
);
assert_eq!(
payload.get("bitfield"),
Some(&RpcValue::String("2".to_owned()))
);
}
other => {
panic!("unexpected tellStatus payload after peer-wire piece exchange: {other:?}")
}
}
let peers = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid.clone())],
));
match peers.result {
Some(RpcValue::Array(items)) => match items.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("downloadSpeed"),
Some(&RpcValue::String("1024".to_owned()))
);
assert_eq!(
peer.get("peerChoking"),
Some(&RpcValue::String("false".to_owned()))
);
}
other => {
panic!("unexpected getPeers row after peer-wire piece exchange: {other:?}")
}
},
other => panic!("unexpected getPeers result after peer-wire piece exchange: {other:?}"),
}
}
@@ -0,0 +1,926 @@
use super::*;
#[test]
fn apply_tracker_announce_result_keeps_tracker_metadata_visible_in_get_servers() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
);
let announce = TrackerResponseModel {
peers: aria2_rust_pro_protocol::TrackerPeerListModel {
interval_sec: 1800,
peers: Vec::new(),
min_interval_sec: None,
tracker_id: Some("announce-tracker-id".to_owned()),
},
scrape: Some(TrackerScrapeModel {
complete: Some(7),
downloaded: Some(11),
incomplete: Some(5),
files: Vec::new(),
}),
};
dispatcher
.apply_tracker_announce_result(&gid, &announce)
.expect("tracker metadata should ingest");
dispatcher
.engine
.handle_mut(download_id(&gid))
.expect("download group should exist")
.set_status(aria2_rust_pro_core::DownloadStatus::Active);
let servers = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetServers,
vec![RpcValue::String(gid.clone())],
));
match servers.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(server)) => {
assert_eq!(server.get("isBt"), Some(&RpcValue::Bool(true)));
assert!(matches!(
server.get("servers"),
Some(RpcValue::Array(items))
if matches!(
items.first(),
Some(RpcValue::Object(row))
if row.get("uri")
== Some(&RpcValue::String(
"http://tracker.example.org/announce".to_owned()
))
)
));
}
other => {
panic!("unexpected getServers entry after tracker metadata ingest: {other:?}")
}
},
other => {
panic!("unexpected getServers result after tracker metadata ingest: {other:?}")
}
}
}
#[test]
fn apply_tracker_scrape_result_updates_bt_seed_counts_without_peer_rows() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
);
let scrape = TrackerScrapeModel {
complete: Some(15),
downloaded: Some(22),
incomplete: Some(8),
files: vec![aria2_rust_pro_protocol::TrackerScrapeFileModel {
info_hash: "cccccccccccccccccccccccccccccccccccccccc".to_owned(),
complete: Some(15),
downloaded: Some(22),
incomplete: Some(8),
}],
};
dispatcher
.apply_tracker_scrape_result(&gid, None, &scrape)
.expect("tracker scrape should ingest");
dispatcher
.engine
.handle_mut(download_id(&gid))
.expect("download group should exist")
.set_status(aria2_rust_pro_core::DownloadStatus::Active);
let status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("numSeeders"),
Some(&RpcValue::String("15".to_owned()))
);
}
other => panic!("unexpected tellStatus after tracker scrape ingest: {other:?}"),
}
let servers = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetServers,
vec![RpcValue::String(gid.clone())],
));
match servers.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(server)) => {
assert!(matches!(
server.get("servers"),
Some(RpcValue::Array(items))
if matches!(
items.first(),
Some(RpcValue::Object(row))
if row.get("currentUri")
== Some(&RpcValue::String(
"http://tracker.example.org/announce".to_owned()
))
)
));
}
other => panic!("unexpected getServers row after scrape ingest: {other:?}"),
},
other => panic!("unexpected getServers result after scrape ingest: {other:?}"),
}
}
#[test]
fn execute_tracker_announce_fetches_live_tracker_data_and_updates_bt_views() {
use std::{
io::{Read, Write},
net::TcpListener,
thread,
};
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_text = String::from_utf8_lossy(&request[..read]);
let (payload, path) = if request_text.starts_with("GET /announce?") {
(
b"d8:intervali600e10:tracker id12:rpc-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");
}
});
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
&format!(
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&tr=http%3A%2F%2F{addr}%2Fannounce"
),
);
let transport =
aria2_rust_pro_protocol::ReqwestTrackerTransport::new().expect("transport should build");
dispatcher
.execute_tracker_announce(&gid, &transport)
.expect("live tracker announce should succeed");
let peers = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid.clone())],
));
match peers.result {
Some(RpcValue::Array(items)) => match items.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("ip"),
Some(&RpcValue::String("127.0.0.1".to_owned()))
);
assert_eq!(peer.get("port"), Some(&RpcValue::String("6881".to_owned())));
}
other => panic!("unexpected peer row after live tracker announce: {other:?}"),
},
other => panic!("unexpected getPeers result after live tracker announce: {other:?}"),
}
let status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("numSeeders"),
Some(&RpcValue::String("4".to_owned()))
);
}
other => panic!("unexpected tellStatus payload after live tracker announce: {other:?}"),
}
handle.join().expect("tracker server thread should join");
}
#[test]
fn execute_tracker_announce_reports_upstream_style_invalid_gid_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "bad-tracker-gid".to_owned();
let transport =
aria2_rust_pro_protocol::ReqwestTrackerTransport::new().expect("transport should build");
let error = dispatcher
.execute_tracker_announce(&gid, &transport)
.expect_err("invalid gid should be rejected before tracker transport");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("Invalid GID {gid}"));
}
#[test]
fn execute_tracker_scrape_fetches_live_scrape_data_and_updates_bt_views() {
use std::{
io::{Read, Write},
net::TcpListener,
thread,
};
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 || {
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_text = String::from_utf8_lossy(&request[..read]);
assert!(request_text.starts_with("GET /scrape"));
let payload = b"d5:filesd20:\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xddd8:completei11e10:downloadedi17e10:incompletei4eeee".to_vec();
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");
});
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
&format!(
"magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd&tr=http%3A%2F%2F{addr}%2Fannounce"
),
);
let transport =
aria2_rust_pro_protocol::ReqwestTrackerTransport::new().expect("transport should build");
dispatcher
.execute_tracker_scrape(&gid, &transport)
.expect("live tracker scrape should succeed");
let status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("numSeeders"),
Some(&RpcValue::String("11".to_owned()))
);
}
other => panic!("unexpected tellStatus after live tracker scrape: {other:?}"),
}
handle.join().expect("tracker server thread should join");
}
#[test]
fn apply_dht_get_peers_result_updates_bt_peers_and_discovers_more_nodes() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
);
let download_id = DownloadId::parse_hex(&gid).expect("gid should parse");
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("group should exist");
let mut bt = group.bt().cloned().expect("magnet should have bt state");
bt.dht_nodes = vec!["192.0.2.10:6881".to_owned()];
group.set_bt(bt);
let mut compact_node = vec![0x44_u8; 20];
compact_node.extend_from_slice(&[198, 51, 100, 77]);
compact_node.extend_from_slice(&51413_u16.to_be_bytes());
let response = DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x33_u8; 20],
Some(b"tok".to_vec()),
Some(compact_node),
vec![vec![203, 0, 113, 10, 0x1a, 0xe1]],
);
let node = DhtNodeModel {
node_id: String::new(),
address: "192.0.2.10".to_owned(),
port: 6881,
};
dispatcher
.apply_dht_get_peers_result(&gid, &node, &response)
.expect("dht get_peers apply should succeed");
let peers = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid.clone())],
));
match peers.result {
Some(RpcValue::Array(items)) => match items.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("ip"),
Some(&RpcValue::String("203.0.113.10".to_owned()))
);
assert_eq!(peer.get("port"), Some(&RpcValue::String("6881".to_owned())));
}
other => panic!("unexpected peer row after dht get_peers apply: {other:?}"),
},
other => panic!("unexpected getPeers result after dht get_peers apply: {other:?}"),
}
let saved = dispatcher
.engine
.registry()
.get(download_id)
.expect("group should still exist");
let bt = saved.bt().expect("bt state should remain present");
assert!(bt.dht_nodes.iter().any(|node| node == "192.0.2.10:6881"));
assert!(
bt.dht_nodes
.iter()
.any(|node| node == "198.51.100.77:51413")
);
}
#[test]
fn execute_dht_announce_peer_uses_cached_token_and_promotes_node() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
);
let download_id = DownloadId::parse_hex(&gid).expect("gid should parse");
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("group should exist");
let mut bt = group.bt().cloned().expect("magnet should have bt state");
bt.dht_nodes = vec!["bad-node-entry".to_owned(), "192.0.2.10:6881".to_owned()];
group.set_bt(bt);
let get_peers_response = DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x11; 20],
Some(b"tok".to_vec()),
None,
Vec::new(),
);
dispatcher
.apply_dht_get_peers_result(
&gid,
&DhtNodeModel {
node_id: String::new(),
address: "192.0.2.10".to_owned(),
port: 6881,
},
&get_peers_response,
)
.expect("get_peers should cache a token");
let transport = FakeDhtTransport::new(DhtMessageModel::ping_response(
b"ap".to_vec(),
vec![0x22; 20],
));
dispatcher
.execute_dht_announce_peer(&gid, &transport)
.expect("announce_peer should succeed");
let seen = transport.seen();
assert_eq!(seen.len(), 1, "transport should see exactly one request");
assert_eq!(seen[0].0.address, "192.0.2.10");
assert_eq!(seen[0].0.port, 6881);
match &seen[0].1.body {
DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(query)) => {
assert_eq!(query.token, b"tok".to_vec());
assert_eq!(query.port, 6881);
assert!(!query.implied_port);
}
other => panic!("unexpected announce_peer request body: {other:?}"),
}
let bt = dispatcher
.engine
.registry()
.get(download_id)
.and_then(|group| group.bt())
.expect("bt runtime state should remain present");
assert_eq!(
bt.dht_nodes.first().map(String::as_str),
Some("192.0.2.10:6881")
);
assert_eq!(
dispatcher
.engine
.registry()
.get(download_id)
.and_then(|group| group.dht_token().map(|token| token.to_vec())),
Some(b"tok".to_vec())
);
}
#[test]
fn execute_dht_announce_peer_requires_cached_token_and_valid_response() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
);
let download_id = DownloadId::parse_hex(&gid).expect("gid should parse");
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("group should exist");
let mut bt = group.bt().cloned().expect("magnet should have bt state");
bt.dht_nodes = vec!["192.0.2.11:6881".to_owned()];
group.set_bt(bt);
}
let missing_token = dispatcher
.execute_dht_announce_peer(
&gid,
&FakeDhtTransport::new(DhtMessageModel::ping_response(
b"ap".to_vec(),
vec![0x22; 20],
)),
)
.expect_err("announce_peer should require a cached token");
assert!(
missing_token
.message
.contains("requires token from prior get_peers"),
"unexpected missing-token error: {}",
missing_token.message
);
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("group should still exist");
group.set_dht_token(Some(b"tok".to_vec()));
let invalid_response = dispatcher
.apply_dht_announce_peer_result(
&gid,
&DhtNodeModel {
node_id: String::new(),
address: "192.0.2.11".to_owned(),
port: 6881,
},
&DhtMessageModel::ping_response(b"ap".to_vec(), vec![0x22; 19]),
)
.expect_err("short node id should be rejected");
assert!(
invalid_response
.message
.contains("node id must be 20 bytes")
);
}
#[test]
fn execute_dht_get_peers_fetches_live_data_and_updates_bt_views() {
use std::cell::RefCell;
struct FakeDhtTransport {
response: DhtMessageModel,
seen_nodes: RefCell<Vec<String>>,
seen_methods: RefCell<Vec<Option<&'static str>>>,
}
impl DhtTransport for FakeDhtTransport {
fn send_message(
&self,
node: &DhtNodeModel,
message: &DhtMessageModel,
) -> Result<DhtMessageModel, TransportError> {
self.seen_nodes
.borrow_mut()
.push(format!("{}:{}", node.address, node.port));
self.seen_methods.borrow_mut().push(message.method());
Ok(self.response.clone())
}
}
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:89abcdef0123456789abcdef0123456789abcdef",
);
let download_id = DownloadId::parse_hex(&gid).expect("gid should parse");
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("group should exist");
let mut bt = group.bt().cloned().expect("magnet should have bt state");
bt.dht_nodes = vec!["192.0.2.30:7000".to_owned()];
group.set_bt(bt);
let transport = FakeDhtTransport {
response: DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x55_u8; 20],
None,
None,
vec![vec![198, 51, 100, 22, 0x13, 0x89]],
),
seen_nodes: RefCell::new(Vec::new()),
seen_methods: RefCell::new(Vec::new()),
};
dispatcher
.execute_dht_get_peers(&gid, &transport)
.expect("dht get_peers execute should succeed");
assert_eq!(
transport.seen_nodes.borrow().as_slice(),
&["192.0.2.30:7000".to_owned()]
);
assert_eq!(
transport.seen_methods.borrow().as_slice(),
&[Some("get_peers")]
);
let peers = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid.clone())],
));
match peers.result {
Some(RpcValue::Array(items)) => match items.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("ip"),
Some(&RpcValue::String("198.51.100.22".to_owned()))
);
assert_eq!(peer.get("port"), Some(&RpcValue::String("5001".to_owned())));
}
other => panic!("unexpected peer row after dht get_peers execute: {other:?}"),
},
other => panic!("unexpected getPeers result after dht execute: {other:?}"),
}
}
#[test]
fn apply_bt_runtime_tick_bridges_hex_gid_into_engine_state() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc",
);
let download_id = download_id(&gid);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download group should exist");
group.set_bt(BtRuntimeState {
files: vec![BtFileInfo {
path: "file.bin".to_owned(),
length: 2_048,
piece_offset: Some(0),
selected: true,
}],
..BtRuntimeState::default()
});
}
dispatcher
.apply_bt_runtime_tick(&gid, 2_048, 1_024, 90, 180, 5, 5, true, Some(16))
.expect("runtime tick wrapper should bridge");
let group = dispatcher
.engine
.registry()
.get(download_id)
.expect("download group should exist");
assert!(group.bt_is_seeding());
assert_eq!(group.completed_length(), 2_048);
assert_eq!(group.upload_length(), 1_024);
assert_eq!(group.download_speed(), 90);
assert_eq!(group.bt_share_ratio_milli(), Some(500));
assert_eq!(group.bt_share_time_secs(), Some(5));
assert_eq!(group.bt_seeding_time_secs(), Some(5));
assert_eq!(group.upload_speed(), 180);
assert_eq!(group.num_connections(), 16);
let status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("uploadSpeed"),
Some(&RpcValue::String("180".to_owned()))
);
assert_eq!(
payload.get("shareTime"),
Some(&RpcValue::String("5".to_owned()))
);
assert_eq!(
payload.get("shareRatio"),
Some(&RpcValue::String("0.500".to_owned()))
);
}
other => panic!("unexpected tellStatus payload after bt runtime wrappers: {other:?}"),
}
let tick_error = dispatcher
.apply_bt_runtime_tick("0123456789abcdeg", 0, 0, 0, 0, 0, 0, false, None)
.expect_err("invalid hex gid should be rejected");
assert_eq!(tick_error.kind, crate::model::RpcErrorKind::Unsupported);
}
#[test]
fn tick_bt_runtime_clock_bridges_hex_gid_into_engine_state() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd",
);
let download_id = download_id(&gid);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download group should exist");
group.set_bt(BtRuntimeState {
files: vec![BtFileInfo {
path: "clock.bin".to_owned(),
length: 2_048,
piece_offset: Some(0),
selected: true,
}],
..BtRuntimeState::default()
});
}
dispatcher
.engine
.set_bt_seeding_state(download_id, true, Some(1_000))
.expect("bt state setup should succeed");
dispatcher
.tick_bt_runtime_clock(&gid, 1_040, true)
.expect("clock tick wrapper should bridge");
let group = dispatcher
.engine
.registry()
.get(download_id)
.expect("download group should exist");
assert!(group.bt_is_seeding());
assert_eq!(group.bt_share_time_secs(), Some(40));
assert_eq!(group.bt_seeding_time_secs(), Some(40));
let clock_error = dispatcher
.tick_bt_runtime_clock("0123456789abcdeg", 1, false)
.expect_err("invalid hex gid should be rejected");
assert_eq!(clock_error.kind, crate::model::RpcErrorKind::Unsupported);
}
#[test]
fn set_bt_seeding_state_bridges_hex_gid_into_engine_state() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
);
let download_id = download_id(&gid);
{
let group = dispatcher
.engine
.handle_mut(download_id)
.expect("download group should exist");
group.set_bt(BtRuntimeState {
files: vec![BtFileInfo {
path: "seeding.bin".to_owned(),
length: 2_048,
piece_offset: Some(0),
selected: true,
}],
..BtRuntimeState::default()
});
}
dispatcher
.set_bt_seeding_state(&gid, true, Some(1_000))
.expect("starting seeding should bridge");
dispatcher
.set_bt_seeding_state(&gid, false, Some(1_030))
.expect("stopping seeding should bridge");
let group = dispatcher
.engine
.registry()
.get(download_id)
.expect("download group should exist");
assert!(!group.bt_is_seeding());
assert_eq!(group.bt_share_time_secs(), Some(30));
assert_eq!(group.bt_seeding_time_secs(), Some(30));
let seeding_error = dispatcher
.set_bt_seeding_state("0123456789abcdeg", true, None)
.expect_err("invalid hex gid should be rejected");
assert_eq!(seeding_error.kind, crate::model::RpcErrorKind::Unsupported);
}
#[test]
fn tell_status_bt_fields_for_non_bt_download_do_not_claim_magnet_metadata() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/not-bt.bin");
let status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(false)));
assert_eq!(payload.get("metadataOnly"), Some(&RpcValue::Bool(false)));
assert_eq!(
payload.get("magnetUri"),
Some(&RpcValue::String(String::new()))
);
assert_eq!(
payload.get("infoHash"),
Some(&RpcValue::String(String::new()))
);
}
other => panic!("unexpected tellStatus non-bt payload: {other:?}"),
}
}
#[test]
fn change_global_option_and_change_option_feed_speed_limit_runtime_surfaces() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:00112233445566778899aabbccddeeff00112233",
);
let global = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeGlobalOption,
vec![RpcValue::Object(BTreeMap::from([
(
"max-overall-download-limit".to_owned(),
RpcValue::String("1200".to_owned()),
),
(
"max-overall-upload-limit".to_owned(),
RpcValue::String("600".to_owned()),
),
("disk-cache".to_owned(), RpcValue::String("32M".to_owned())),
]))],
));
assert!(global.error.is_none(), "changeGlobalOption should succeed");
let per_download = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeOption,
vec![
RpcValue::String(gid.clone()),
RpcValue::Object(BTreeMap::from([
(
"max-download-limit".to_owned(),
RpcValue::String("700".to_owned()),
),
(
"max-upload-limit".to_owned(),
RpcValue::String("200".to_owned()),
),
])),
],
));
assert!(per_download.error.is_none(), "changeOption should succeed");
dispatcher
.apply_bt_runtime_tick(&gid, 2048, 1024, 2_000, 900, 1, 1, false, Some(4))
.expect("bt runtime tick should succeed");
let status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("downloadSpeed"),
Some(&RpcValue::String("700".to_owned()))
);
assert_eq!(
payload.get("uploadSpeed"),
Some(&RpcValue::String("200".to_owned()))
);
}
other => panic!("unexpected tellStatus payload after limit change: {other:?}"),
}
let global_stat = dispatcher.dispatch_json(request(RpcMethod::Aria2TellGlobalStat, vec![]));
match global_stat.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("downloadSpeed"),
Some(&RpcValue::String("700".to_owned()))
);
assert_eq!(
payload.get("uploadSpeed"),
Some(&RpcValue::String("200".to_owned()))
);
assert_eq!(
payload.get("numStoppedTotal"),
Some(&RpcValue::String("0".to_owned()))
);
assert_eq!(payload.len(), 6);
}
other => panic!("unexpected tellGlobalStat payload after limit change: {other:?}"),
}
}
#[test]
fn tell_global_stat_uses_upstream_field_set_and_stopped_counters() {
let mut dispatcher = InProcessRpcDispatcher::new();
let _waiting = add_uri(&mut dispatcher, "https://example.org/waiting.iso");
let active = add_uri(&mut dispatcher, "https://example.org/active.iso");
dispatcher
.engine
.handle_mut(DownloadId::parse_hex(&active).expect("gid should parse"))
.expect("group should exist")
.set_status(aria2_rust_pro_core::DownloadStatus::Active);
let removed = add_uri(&mut dispatcher, "https://example.org/removed.iso");
let _ = dispatcher.dispatch_json(request(
RpcMethod::Aria2Remove,
vec![RpcValue::String(removed.clone())],
));
let global = dispatcher.dispatch_json(request(RpcMethod::Aria2TellGlobalStat, vec![]));
match global.result {
Some(RpcValue::Object(payload)) => {
let keys = payload.keys().cloned().collect::<Vec<_>>();
assert_eq!(
keys,
vec![
"downloadSpeed".to_owned(),
"numActive".to_owned(),
"numStopped".to_owned(),
"numStoppedTotal".to_owned(),
"numWaiting".to_owned(),
"uploadSpeed".to_owned(),
]
);
assert_eq!(
payload.get("numActive"),
Some(&RpcValue::String("1".to_owned()))
);
assert_eq!(
payload.get("numWaiting"),
Some(&RpcValue::String("1".to_owned()))
);
assert_eq!(
payload.get("numStopped"),
Some(&RpcValue::String("1".to_owned()))
);
assert_eq!(
payload.get("numStoppedTotal"),
Some(&RpcValue::String("1".to_owned()))
);
assert!(!payload.contains_key("numError"));
assert!(!payload.contains_key("numComplete"));
assert!(!payload.contains_key("totalLength"));
assert!(!payload.contains_key("completedLength"));
}
other => panic!("unexpected tellGlobalStat upstream-shape result: {other:?}"),
}
let _ = dispatcher.dispatch_json(request(
RpcMethod::Aria2RemoveDownloadResult,
vec![RpcValue::String(removed)],
));
let after_purge = dispatcher.dispatch_json(request(RpcMethod::Aria2TellGlobalStat, vec![]));
match after_purge.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("numStopped"),
Some(&RpcValue::String("0".to_owned()))
);
assert_eq!(
payload.get("numStoppedTotal"),
Some(&RpcValue::String("1".to_owned()))
);
}
other => panic!("unexpected tellGlobalStat after purge result: {other:?}"),
}
}
@@ -0,0 +1,379 @@
use super::*;
#[test]
fn dispatch_xml_get_version_returns_struct_with_enabled_features() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_xml(xml_request("aria2.getVersion"));
assert!(response.fault.is_none(), "expected XML-RPC success");
match response.value {
Some(XmlRpcValue::Struct(members)) => {
let version = members.iter().find(|member| member.name == "version");
assert_eq!(
version.map(|member| &member.value),
Some(&XmlRpcValue::String(
aria2_rust_pro_compat::VERSION.to_owned()
))
);
let features = members
.iter()
.find(|member| member.name == "enabledFeatures")
.expect("enabledFeatures member should exist");
match &features.value {
XmlRpcValue::Array(values) => {
assert_eq!(
values,
&vec![
XmlRpcValue::String("Async DNS".to_owned()),
XmlRpcValue::String("BitTorrent".to_owned()),
XmlRpcValue::String("GZip".to_owned()),
XmlRpcValue::String("HTTPS".to_owned()),
XmlRpcValue::String("Message Digest".to_owned()),
XmlRpcValue::String("Metalink".to_owned()),
XmlRpcValue::String("XML-RPC".to_owned()),
XmlRpcValue::String("SFTP".to_owned()),
]
);
}
other => panic!("unexpected enabledFeatures value: {other:?}"),
}
}
other => panic!("unexpected aria2.getVersion XML-RPC payload: {other:?}"),
}
}
#[test]
fn get_version_returns_package_version_and_upstream_style_features() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_json(request(RpcMethod::Aria2GetVersion, vec![]));
match response.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("version"),
Some(&RpcValue::String(aria2_rust_pro_compat::VERSION.to_owned()))
);
let features = match payload.get("enabledFeatures") {
Some(RpcValue::Array(features)) => features,
other => panic!("unexpected enabledFeatures payload: {other:?}"),
};
assert_eq!(
features,
&vec![
RpcValue::String("Async DNS".to_owned()),
RpcValue::String("BitTorrent".to_owned()),
RpcValue::String("GZip".to_owned()),
RpcValue::String("HTTPS".to_owned()),
RpcValue::String("Message Digest".to_owned()),
RpcValue::String("Metalink".to_owned()),
RpcValue::String("XML-RPC".to_owned()),
RpcValue::String("SFTP".to_owned()),
]
);
}
other => panic!("unexpected aria2.getVersion JSON-RPC payload: {other:?}"),
}
}
#[test]
fn get_session_info_returns_only_hex_session_id() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_json(request(RpcMethod::Aria2GetSessionInfo, vec![]));
match response.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.len(), 1, "upstream payload only exposes sessionId");
let session_id = match payload.get("sessionId") {
Some(RpcValue::String(value)) => value,
other => panic!("unexpected sessionId payload: {other:?}"),
};
assert_eq!(
session_id.len(),
40,
"sessionId should be 20 bytes rendered as hex"
);
assert!(
session_id.chars().all(|ch| ch.is_ascii_hexdigit()),
"sessionId should contain only hexadecimal characters: {session_id}"
);
}
other => panic!("unexpected aria2.getSessionInfo JSON-RPC payload: {other:?}"),
}
}
#[test]
fn dispatch_xml_get_session_info_returns_only_hex_session_id() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_xml(xml_request("aria2.getSessionInfo"));
assert!(response.fault.is_none(), "expected XML-RPC success");
match response.value {
Some(XmlRpcValue::Struct(members)) => {
assert_eq!(members.len(), 1, "upstream payload only exposes sessionId");
let session = members
.iter()
.find(|member| member.name == "sessionId")
.expect("sessionId member should exist");
let session_id = match &session.value {
XmlRpcValue::String(value) => value,
other => panic!("unexpected sessionId XML-RPC value: {other:?}"),
};
assert_eq!(
session_id.len(),
40,
"sessionId should be 20 bytes rendered as hex"
);
assert!(
session_id.chars().all(|ch| ch.is_ascii_hexdigit()),
"sessionId should contain only hexadecimal characters: {session_id}"
);
}
other => panic!("unexpected aria2.getSessionInfo XML-RPC payload: {other:?}"),
}
}
#[test]
fn dispatch_xml_get_global_stat_reuses_real_rpc_payload() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/file.iso");
dispatcher
.engine
.handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse"))
.expect("group should exist")
.set_status(aria2_rust_pro_core::DownloadStatus::Active);
let response = dispatcher.dispatch_xml(xml_request("aria2.getGlobalStat"));
assert!(response.fault.is_none(), "expected XML-RPC success");
match response.value {
Some(XmlRpcValue::Struct(members)) => {
assert!(members.iter().any(|member| {
member.name == "numActive" && member.value == XmlRpcValue::String("1".to_owned())
}));
assert!(members.iter().any(|member| {
member.name == "numStoppedTotal"
&& member.value == XmlRpcValue::String("0".to_owned())
}));
assert!(!members.iter().any(|member| member.name == "totalLength"));
assert!(
!members
.iter()
.any(|member| member.name == "completedLength")
);
}
other => panic!("unexpected aria2.getGlobalStat XML-RPC payload: {other:?}"),
}
}
#[test]
fn dispatch_xml_tell_status_reuses_real_rpc_payload_and_params() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/xml-status.bin");
let group = dispatcher
.engine
.handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse"))
.expect("group should exist");
group.set_piece_length(1024);
group.set_total_length(2048);
group.set_piece_state(PieceId(0), PieceState::Verified);
group.set_num_connections(2);
group.set_status(aria2_rust_pro_core::DownloadStatus::Active);
let response = dispatcher.dispatch_xml(xml_request_with_params(
"aria2.tellStatus",
vec![XmlRpcValue::String(gid)],
));
assert!(response.fault.is_none(), "expected XML-RPC success");
match response.value {
Some(XmlRpcValue::Struct(members)) => {
assert!(members.iter().any(|member| {
member.name == "status" && member.value == XmlRpcValue::String("active".to_owned())
}));
assert!(members.iter().any(|member| {
member.name == "completedLength"
&& member.value == XmlRpcValue::String("1024".to_owned())
}));
assert!(members.iter().any(|member| {
member.name == "connections" && member.value == XmlRpcValue::String("2".to_owned())
}));
}
other => panic!("unexpected aria2.tellStatus XML-RPC payload: {other:?}"),
}
}
#[test]
fn dispatch_xml_unknown_method_uses_upstream_fault_code_one() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_xml(xml_request("aria2.notFound"));
assert!(
response.value.is_none(),
"unknown methods should return fault"
);
let fault = response.fault.expect("fault payload should exist");
assert_eq!(fault.code, 1);
assert_eq!(
fault.message,
RpcError::unknown_method("aria2.notFound").message
);
assert_eq!(
fault.error,
Some(RpcError::unknown_method("aria2.notFound"))
);
}
#[test]
fn dispatch_xml_get_version_response_can_be_rendered_to_method_response_xml() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_xml(xml_request("aria2.getVersion"));
assert!(response.fault.is_none(), "expected XML-RPC success");
let xml = crate::xmlrpc::xmlrpc_method_response_to_xml(&response);
assert!(
xml.starts_with("<?xml version=\"1.0\"?><methodResponse><params><param><value><struct>")
);
assert!(xml.contains("<name>version</name><value><string>"));
assert!(xml.contains("<name>enabledFeatures</name><value><array><data>"));
assert!(xml.contains("<value><string>XML-RPC</string></value>"));
assert!(!xml.contains("<value><string>JSON-RPC</string></value>"));
assert!(xml.ends_with("</param></params></methodResponse>"));
}
#[test]
fn parsed_jsonrpc_success_response_renders_transport_visible_result_without_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let request = jsonrpc_request_from_json(
r#"{"jsonrpc":"2.0","id":"wire-success","method":"aria2.getVersion","params":[]}"#,
)
.expect("raw JSON-RPC request should parse");
let body = jsonrpc_response_to_json(&dispatcher.dispatch_json(request))
.expect("JSON-RPC success response should render");
let value: serde_json::Value =
serde_json::from_str(&body).expect("rendered response should be valid JSON");
assert_eq!(value.get("jsonrpc"), Some(&serde_json::json!("2.0")));
assert_eq!(value.get("id"), Some(&serde_json::json!("wire-success")));
assert!(
value.get("error").is_none(),
"successful JSON-RPC response must not expose an error member: {body}"
);
assert_eq!(
value.pointer("/result/version"),
Some(&serde_json::json!(aria2_rust_pro_compat::VERSION))
);
assert_eq!(
value.pointer("/result/enabledFeatures/0"),
Some(&serde_json::json!("Async DNS"))
);
}
#[test]
fn dispatch_xml_multicall_wraps_success_results_and_preserves_order() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_xml(XmlRpcMethodCall {
method_name: "system.multicall".to_owned(),
params: vec![XmlRpcParam {
value: XmlRpcValue::Array(vec![
XmlRpcValue::Struct(vec![
XmlRpcMember {
name: "methodName".to_owned(),
value: XmlRpcValue::String("aria2.getVersion".to_owned()),
},
XmlRpcMember {
name: "params".to_owned(),
value: XmlRpcValue::Array(Vec::new()),
},
]),
XmlRpcValue::Struct(vec![
XmlRpcMember {
name: "methodName".to_owned(),
value: XmlRpcValue::String("system.listMethods".to_owned()),
},
XmlRpcMember {
name: "params".to_owned(),
value: XmlRpcValue::Array(Vec::new()),
},
]),
]),
}],
meta: RpcMeta::default(),
});
assert!(
response.fault.is_none(),
"expected XML-RPC multicall success"
);
match response.value {
Some(XmlRpcValue::Array(items)) => {
assert_eq!(items.len(), 2);
match &items[0] {
XmlRpcValue::Array(first) => match first.first() {
Some(XmlRpcValue::Struct(payload)) => {
assert!(payload.iter().any(|member| member.name == "version"));
}
other => panic!("unexpected first XML multicall payload: {other:?}"),
},
other => panic!("unexpected first XML multicall item: {other:?}"),
}
match &items[1] {
XmlRpcValue::Array(second) => match second.first() {
Some(XmlRpcValue::Array(methods)) => {
assert!(!methods.is_empty());
}
other => panic!("unexpected second XML multicall payload: {other:?}"),
},
other => panic!("unexpected second XML multicall item: {other:?}"),
}
}
other => panic!("unexpected XML multicall result: {other:?}"),
}
}
#[test]
fn dispatch_xml_multicall_invalid_members_use_fault_code_and_fault_string() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_xml(XmlRpcMethodCall {
method_name: "system.multicall".to_owned(),
params: vec![XmlRpcParam {
value: XmlRpcValue::Array(vec![
XmlRpcValue::String("bad".to_owned()),
XmlRpcValue::Struct(Vec::new()),
XmlRpcValue::Struct(vec![XmlRpcMember {
name: "methodName".to_owned(),
value: XmlRpcValue::String("system.multicall".to_owned()),
}]),
]),
}],
meta: RpcMeta::default(),
});
assert!(
response.fault.is_none(),
"expected in-band XML multicall errors"
);
match response.value {
Some(XmlRpcValue::Array(items)) => {
assert_eq!(items.len(), 3);
for item in &items[..2] {
match item {
XmlRpcValue::Struct(payload) => {
assert!(payload.iter().any(|member| member.name == "faultCode"));
assert!(payload.iter().any(|member| member.name == "faultString"));
}
other => panic!("unexpected XML multicall error item: {other:?}"),
}
}
match &items[2] {
XmlRpcValue::Struct(payload) => {
assert!(payload.iter().any(|member| {
member.name == "faultString"
&& member.value
== XmlRpcValue::String(
"Recursive system.multicall forbidden.".to_owned(),
)
}));
}
other => panic!("unexpected recursive XML multicall item: {other:?}"),
}
}
other => panic!("unexpected XML multicall result: {other:?}"),
}
}
@@ -0,0 +1,7 @@
pub(super) use super::*;
mod additions_and_state;
mod options_and_files;
mod queue_and_uri;
mod queue_views_and_transfer;
mod status_and_global;
@@ -0,0 +1,488 @@
use super::*;
#[test]
fn add_uri_accepts_uri_array_and_applies_options() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2AddUri,
vec![
RpcValue::Array(vec![RpcValue::String(
"https://example.org/file.iso".to_owned(),
)]),
RpcValue::Object(BTreeMap::from([
("dir".to_owned(), RpcValue::String("/downloads".to_owned())),
("out".to_owned(), RpcValue::String("file.iso".to_owned())),
])),
RpcValue::Number(0),
],
));
let gid = match response.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri result: {other:?}"),
};
let options = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetOption,
vec![RpcValue::String(gid.clone())],
));
match options.result {
Some(RpcValue::Object(options)) => {
assert_eq!(
options.get("dir"),
Some(&RpcValue::String("/downloads".to_owned()))
);
assert_eq!(
options.get("out"),
Some(&RpcValue::String("file.iso".to_owned()))
);
}
other => panic!("unexpected getOption result after addUri: {other:?}"),
}
}
#[test]
fn add_uri_rejects_non_numeric_position() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2AddUri,
vec![
RpcValue::Array(vec![RpcValue::String(
"https://example.org/file.iso".to_owned(),
)]),
RpcValue::Object(BTreeMap::new()),
RpcValue::String("front".to_owned()),
],
));
assert!(matches!(response.error, Some(error) if error.message.contains("position")));
}
#[test]
fn add_torrent_registers_bt_like_download() {
let mut dispatcher = InProcessRpcDispatcher::new();
let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl";
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2AddTorrent,
vec![RpcValue::String(torrent_payload.to_owned())],
));
let gid = match response.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result: {other:?}"),
};
let status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
assert!(payload.contains_key("magnetUri"));
assert!(payload.contains_key("btFieldCoverage"));
assert_eq!(
payload.get("metadataOnly"),
Some(&RpcValue::Bool(false)),
"torrent-file downloads should not be treated as metadataOnly"
);
assert!(matches!(
payload.get("announceList"),
Some(RpcValue::Array(tiers)) if !tiers.is_empty()
));
assert!(matches!(
payload.get("infoHash"),
Some(RpcValue::String(info_hash))
if info_hash.len() == 40 && info_hash.chars().all(|ch| ch.is_ascii_hexdigit())
));
}
other => panic!("unexpected tellStatus after addTorrent: {other:?}"),
}
let files = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetFiles,
vec![RpcValue::String(gid.clone())],
));
match files.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(file)) => {
assert_eq!(
file.get("path"),
Some(&RpcValue::String("ubuntu.iso".to_owned()))
);
assert_eq!(
file.get("length"),
Some(&RpcValue::String("32768".to_owned()))
);
assert_eq!(
file.get("selected"),
Some(&RpcValue::String("true".to_owned()))
);
}
other => panic!("unexpected addTorrent file payload: {other:?}"),
},
other => panic!("unexpected getFiles after addTorrent: {other:?}"),
}
dispatcher
.engine
.handle_mut(download_id(&gid))
.expect("download group should exist")
.set_status(aria2_rust_pro_core::DownloadStatus::Active);
let servers = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetServers,
vec![RpcValue::String(gid.clone())],
));
match servers.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(server)) => {
assert_eq!(server.get("isBt"), Some(&RpcValue::Bool(true)));
}
other => panic!("unexpected addTorrent server payload: {other:?}"),
},
other => panic!("unexpected getServers after addTorrent: {other:?}"),
}
}
#[test]
fn add_torrent_accepts_webseed_array_and_applies_options() {
let mut dispatcher = InProcessRpcDispatcher::new();
let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl";
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2AddTorrent,
vec![
RpcValue::String(torrent_payload.to_owned()),
RpcValue::Array(vec![RpcValue::String(
"https://seed.example.org/ubuntu.iso".to_owned(),
)]),
RpcValue::Object(BTreeMap::from([(
"dir".to_owned(),
RpcValue::String("/torrent-downloads".to_owned()),
)])),
RpcValue::Number(0),
],
));
let gid = match response.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result: {other:?}"),
};
let options = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetOption,
vec![RpcValue::String(gid.clone())],
));
match options.result {
Some(RpcValue::Object(options)) => {
assert_eq!(
options.get("dir"),
Some(&RpcValue::String("/torrent-downloads".to_owned()))
);
}
other => panic!("unexpected getOption result after addTorrent: {other:?}"),
}
}
#[test]
fn change_option_select_file_updates_bt_file_selected_flags() {
let mut dispatcher = InProcessRpcDispatcher::new();
let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl";
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2AddTorrent,
vec![RpcValue::String(torrent_payload.to_owned())],
));
let gid = match response.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result: {other:?}"),
};
let group = dispatcher
.engine
.handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse"))
.expect("group should exist");
let mut bt = group.bt().cloned().expect("torrent should have bt state");
bt.files = vec![
BtFileInfo {
path: "episode-01.mkv".to_owned(),
length: 10,
piece_offset: Some(0),
selected: true,
},
BtFileInfo {
path: "episode-02.mkv".to_owned(),
length: 10,
piece_offset: Some(10),
selected: true,
},
BtFileInfo {
path: "episode-03.mkv".to_owned(),
length: 10,
piece_offset: Some(20),
selected: true,
},
];
group.set_bt(bt);
let changed = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeOption,
vec![
RpcValue::String(gid.clone()),
RpcValue::Object(BTreeMap::from([(
"select-file".to_owned(),
RpcValue::String("2-3".to_owned()),
)])),
],
));
assert_eq!(changed.result, Some(RpcValue::String("OK".to_owned())));
let files = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetFiles,
vec![RpcValue::String(gid.clone())],
));
match files.result {
Some(RpcValue::Array(entries)) => {
assert_eq!(entries.len(), 3);
let selected: Vec<String> = entries
.iter()
.map(|entry| match entry {
RpcValue::Object(file) => match file.get("selected") {
Some(RpcValue::String(value)) => value.clone(),
other => panic!("unexpected selected payload: {other:?}"),
},
other => panic!("unexpected file row: {other:?}"),
})
.collect();
assert_eq!(selected, vec!["false", "true", "true"]);
}
other => panic!("unexpected getFiles result after select-file change: {other:?}"),
}
}
#[test]
fn bt_pause_and_unpause_keep_bt_status_payload_shape() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(
&mut dispatcher,
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=PauseResumeBt",
);
let pause = dispatcher.dispatch_json(request(
RpcMethod::Aria2Pause,
vec![RpcValue::String(gid.clone())],
));
assert!(pause.error.is_none(), "pause should succeed for bt group");
assert!(pause.result.is_some(), "pause should return a payload");
let unpause = dispatcher.dispatch_json(request(
RpcMethod::Aria2Unpause,
vec![RpcValue::String(gid.clone())],
));
assert!(
unpause.error.is_none(),
"unpause should succeed for bt group"
);
assert!(unpause.result.is_some(), "unpause should return a payload");
let status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("status"),
Some(&RpcValue::String("waiting".to_owned()))
);
for key in crate::model::BT_STATUS_FIELDS {
assert!(
payload.contains_key(*key),
"BT status payload missing key `{key}` after pause/unpause"
);
}
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
assert!(matches!(payload.get("files"), Some(RpcValue::Array(_))));
}
other => panic!("unexpected tellStatus payload after bt pause/unpause: {other:?}"),
}
}
#[test]
fn pause_remove_and_unpause_return_gid_strings() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/state.bin");
let pause = dispatcher.dispatch_json(request(
RpcMethod::Aria2Pause,
vec![RpcValue::String(gid.clone())],
));
assert_eq!(pause.result, Some(RpcValue::String(gid.clone())));
let unpause = dispatcher.dispatch_json(request(
RpcMethod::Aria2Unpause,
vec![RpcValue::String(gid.clone())],
));
assert_eq!(unpause.result, Some(RpcValue::String(gid.clone())));
let remove = dispatcher.dispatch_json(request(
RpcMethod::Aria2Remove,
vec![RpcValue::String(gid.clone())],
));
assert_eq!(remove.result, Some(RpcValue::String(gid.clone())));
}
#[test]
fn pause_reports_upstream_style_missing_gid_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "0000000000000005".to_owned();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2Pause,
vec![RpcValue::String(gid.clone())],
));
let error = response
.error
.expect("missing gid should be rejected with upstream-style pause error");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("GID#{gid} cannot be paused now"));
}
#[test]
fn pause_reports_upstream_style_invalid_gid_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "bad-pause-gid".to_owned();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2Pause,
vec![RpcValue::String(gid.clone())],
));
let error = response
.error
.expect("invalid gid should be rejected with upstream-style pause error");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("Invalid GID {gid}"));
}
#[test]
fn pause_reports_upstream_style_invalid_state_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/already-paused.bin");
let first = dispatcher.dispatch_json(request(
RpcMethod::Aria2Pause,
vec![RpcValue::String(gid.clone())],
));
assert_eq!(first.result, Some(RpcValue::String(gid.clone())));
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2Pause,
vec![RpcValue::String(gid.clone())],
));
let error = response
.error
.expect("second pause should be rejected with upstream-style pause error");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("GID#{gid} cannot be paused now"));
}
#[test]
fn unpause_reports_upstream_style_invalid_gid_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "bad-unpause-gid".to_owned();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2Unpause,
vec![RpcValue::String(gid.clone())],
));
let error = response
.error
.expect("invalid gid should be rejected with upstream-style unpause error");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("Invalid GID {gid}"));
}
#[test]
fn unpause_reports_upstream_style_missing_gid_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "0000000000000006".to_owned();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2Unpause,
vec![RpcValue::String(gid.clone())],
));
let error = response
.error
.expect("missing gid should be rejected with upstream-style unpause error");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("GID#{gid} cannot be unpaused now"));
}
#[test]
fn remove_reports_upstream_style_invalid_gid_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "bad-remove-gid".to_owned();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2Remove,
vec![RpcValue::String(gid.clone())],
));
let error = response
.error
.expect("invalid gid should be rejected by remove");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("Invalid GID {gid}"));
}
#[test]
fn unpause_reports_upstream_style_invalid_state_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/not-paused.bin");
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2Unpause,
vec![RpcValue::String(gid.clone())],
));
let error = response
.error
.expect("unpause without paused state should be rejected");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("GID#{gid} cannot be unpaused now"));
}
#[test]
fn remove_reports_upstream_style_missing_active_download_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "0000000000000004".to_owned();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2Remove,
vec![RpcValue::String(gid.clone())],
));
let error = response
.error
.expect("remove should reject unknown gid with upstream-style error");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(
error.message,
format!("Active Download not found for GID#{gid}")
);
}
#[test]
fn force_pause_and_force_remove_return_gid_strings() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/force.bin");
let force_pause = dispatcher.dispatch_json(request(
RpcMethod::Aria2ForcePause,
vec![RpcValue::String(gid.clone())],
));
assert_eq!(force_pause.result, Some(RpcValue::String(gid.clone())));
let force_remove = dispatcher.dispatch_json(request(
RpcMethod::Aria2ForceRemove,
vec![RpcValue::String(gid.clone())],
));
assert_eq!(force_remove.result, Some(RpcValue::String(gid.clone())));
}
@@ -0,0 +1,525 @@
use super::*;
#[test]
fn get_global_option_exposes_documented_default_keys() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_json(request(RpcMethod::Aria2GetGlobalOption, vec![]));
match response.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("rpc-listen-port"),
Some(&RpcValue::String("6800".to_owned()))
);
assert_eq!(
payload.get("max-overall-download-limit"),
Some(&RpcValue::String("0".to_owned()))
);
assert_eq!(
payload.get("retry-on-403"),
Some(&RpcValue::String("false".to_owned()))
);
assert_eq!(
payload.get("ftp-pasv"),
Some(&RpcValue::String("true".to_owned()))
);
assert_eq!(
payload.get("ftp-type"),
Some(&RpcValue::String("binary".to_owned()))
);
assert_eq!(
payload.get("ftp-reuse-connection"),
Some(&RpcValue::String("true".to_owned()))
);
assert_eq!(
payload.get("all-proxy-user"),
Some(&RpcValue::String(String::new()))
);
}
other => panic!("unexpected getGlobalOption defaults result: {other:?}"),
}
}
#[test]
fn per_download_options_round_trip_through_rpc() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/file.iso");
let _ = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeOption,
vec![
RpcValue::String(gid.clone()),
RpcValue::Object(BTreeMap::from([
("split".to_owned(), RpcValue::Number(8)),
("out".to_owned(), RpcValue::String("file.iso".to_owned())),
(
"ftp-proxy-user".to_owned(),
RpcValue::String("ftp-user".to_owned()),
),
("ftp-pasv".to_owned(), RpcValue::Bool(false)),
])),
],
));
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetOption,
vec![RpcValue::String(gid.clone())],
));
match response.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("split"),
Some(&RpcValue::String("8".to_owned()))
);
assert_eq!(
payload.get("out"),
Some(&RpcValue::String("file.iso".to_owned()))
);
assert_eq!(
payload.get("ftp-proxy-user"),
Some(&RpcValue::String("ftp-user".to_owned()))
);
assert_eq!(
payload.get("ftp-pasv"),
Some(&RpcValue::String("false".to_owned()))
);
}
other => panic!("unexpected getOption result: {other:?}"),
}
}
#[test]
fn get_option_reports_upstream_style_missing_gid_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "0000000000000002".to_owned();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetOption,
vec![RpcValue::String(gid.clone())],
));
let error = response
.error
.expect("missing gid should be rejected by getOption");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("Cannot get option for GID#{gid}"));
}
#[test]
fn get_option_reports_upstream_style_invalid_gid_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "bad-option-gid".to_owned();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetOption,
vec![RpcValue::String(gid.clone())],
));
let error = response
.error
.expect("invalid gid should be rejected by getOption");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("Invalid GID {gid}"));
}
#[test]
fn get_files_reports_upstream_style_invalid_gid_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "bad-files-gid".to_owned();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetFiles,
vec![RpcValue::String(gid.clone())],
));
let error = response
.error
.expect("invalid gid should be rejected by getFiles");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("Invalid GID {gid}"));
}
#[test]
fn change_option_reports_upstream_style_missing_gid_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "0000000000000003".to_owned();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeOption,
vec![
RpcValue::String(gid.clone()),
RpcValue::Object(BTreeMap::from([("split".to_owned(), RpcValue::Number(8))])),
],
));
let error = response
.error
.expect("missing gid should be rejected by changeOption");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("Cannot change option for GID#{gid}"));
}
#[test]
fn change_option_reports_upstream_style_invalid_gid_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "bad-change-option-gid".to_owned();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeOption,
vec![
RpcValue::String(gid.clone()),
RpcValue::Object(BTreeMap::from([("split".to_owned(), RpcValue::Number(8))])),
],
));
let error = response
.error
.expect("invalid gid should be rejected by changeOption");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("Invalid GID {gid}"));
}
#[test]
fn change_option_rejects_piece_length_for_dynamic_updates() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/file.iso");
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeOption,
vec![
RpcValue::String(gid),
RpcValue::Object(BTreeMap::from([(
"piece-length".to_owned(),
RpcValue::String("2M".to_owned()),
)])),
],
));
let error = response
.error
.expect("piece-length should be rejected for changeOption");
assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams);
assert!(error.message.contains("piece-length"));
}
#[test]
fn change_option_rejects_pause_for_dynamic_updates() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/file.iso");
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeOption,
vec![
RpcValue::String(gid),
RpcValue::Object(BTreeMap::from([("pause".to_owned(), RpcValue::Bool(true))])),
],
));
let error = response
.error
.expect("pause should be rejected for changeOption");
assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams);
assert!(error.message.contains("pause"));
}
#[test]
fn change_option_rejects_dry_run_for_dynamic_updates() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/file.iso");
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeOption,
vec![
RpcValue::String(gid),
RpcValue::Object(BTreeMap::from([(
"dry-run".to_owned(),
RpcValue::Bool(true),
)])),
],
));
let error = response
.error
.expect("dry-run should be rejected for changeOption");
assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams);
assert!(error.message.contains("dry-run"));
}
#[test]
fn change_option_rejects_metalink_base_uri_for_dynamic_updates() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/file.iso");
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeOption,
vec![
RpcValue::String(gid),
RpcValue::Object(BTreeMap::from([(
"metalink-base-uri".to_owned(),
RpcValue::String("https://example.org/base/".to_owned()),
)])),
],
));
let error = response
.error
.expect("metalink-base-uri should be rejected for changeOption");
assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams);
assert!(error.message.contains("metalink-base-uri"));
}
#[test]
fn change_option_rejects_parameterized_uri_for_dynamic_updates() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/file.iso");
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeOption,
vec![
RpcValue::String(gid),
RpcValue::Object(BTreeMap::from([(
"parameterized-uri".to_owned(),
RpcValue::Bool(true),
)])),
],
));
let error = response
.error
.expect("parameterized-uri should be rejected for changeOption");
assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams);
assert!(error.message.contains("parameterized-uri"));
}
#[test]
fn change_option_rejects_rpc_save_upload_metadata_for_dynamic_updates() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/file.iso");
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeOption,
vec![
RpcValue::String(gid),
RpcValue::Object(BTreeMap::from([(
"rpc-save-upload-metadata".to_owned(),
RpcValue::Bool(true),
)])),
],
));
let error = response
.error
.expect("rpc-save-upload-metadata should be rejected for changeOption");
assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams);
assert!(error.message.contains("rpc-save-upload-metadata"));
}
#[test]
fn get_option_exposes_default_and_inherited_values() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/file.iso");
let _ = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeGlobalOption,
vec![RpcValue::Object(BTreeMap::from([
(
"max-download-limit".to_owned(),
RpcValue::String("20K".to_owned()),
),
(
"all-proxy-user".to_owned(),
RpcValue::String("global-proxy-user".to_owned()),
),
]))],
));
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetOption,
vec![RpcValue::String(gid)],
));
match response.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("split"),
Some(&RpcValue::String("5".to_owned()))
);
assert_eq!(
payload.get("continue"),
Some(&RpcValue::String("false".to_owned()))
);
assert_eq!(
payload.get("max-download-limit"),
Some(&RpcValue::String("20K".to_owned()))
);
assert_eq!(
payload.get("all-proxy-user"),
Some(&RpcValue::String("global-proxy-user".to_owned()))
);
assert_eq!(
payload.get("ftp-pasv"),
Some(&RpcValue::String("true".to_owned()))
);
}
other => panic!("unexpected getOption default/inherited result: {other:?}"),
}
}
#[test]
fn uri_file_and_server_payloads_are_populated() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/path/file.iso");
dispatcher
.engine
.handle_mut(download_id(&gid))
.expect("group should exist")
.set_status(aria2_rust_pro_core::DownloadStatus::Active);
let uris = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetUris,
vec![RpcValue::String(gid.clone())],
));
let files = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetFiles,
vec![RpcValue::String(gid.clone())],
));
let servers = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetServers,
vec![RpcValue::String(gid.clone())],
));
match uris.result {
Some(RpcValue::Array(payload)) => {
assert_eq!(payload.len(), 1);
assert!(matches!(payload.first(), Some(RpcValue::Object(_))));
}
other => panic!("unexpected getUris result: {other:?}"),
}
match files.result {
Some(RpcValue::Array(payload)) => {
assert_eq!(payload.len(), 1);
match payload.first() {
Some(RpcValue::Object(file)) => {
assert_eq!(
file.get("path"),
Some(&RpcValue::String("file.iso".to_owned()))
);
}
other => panic!("unexpected getFiles payload: {other:?}"),
}
}
other => panic!("unexpected getFiles result: {other:?}"),
}
match servers.result {
Some(RpcValue::Array(payload)) => {
assert_eq!(payload.len(), 1);
assert!(matches!(payload.first(), Some(RpcValue::Object(_))));
}
other => panic!("unexpected getServers result: {other:?}"),
}
}
#[test]
fn get_servers_rejects_non_active_downloads_with_upstream_style_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/path/file.iso");
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetServers,
vec![RpcValue::String(gid.clone())],
));
match response.error {
Some(error) => {
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert!(
error
.message
.contains(&format!("No active download for GID#{gid}"))
);
}
other => panic!("unexpected getServers non-active result: {other:?}"),
}
}
#[test]
fn get_uris_files_and_peers_report_upstream_style_missing_gid_errors() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "0000000000000bad".to_owned();
let get_uris = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetUris,
vec![RpcValue::String(gid.clone())],
));
let get_files = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetFiles,
vec![RpcValue::String(gid.clone())],
));
let get_peers = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid.clone())],
));
match get_uris.error {
Some(error) => {
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(
error.message,
format!("No URI data is available for GID#{gid}")
);
}
other => panic!("unexpected getUris missing-gid result: {other:?}"),
}
match get_files.error {
Some(error) => {
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(
error.message,
format!("No file data is available for GID#{gid}")
);
}
other => panic!("unexpected getFiles missing-gid result: {other:?}"),
}
match get_peers.error {
Some(error) => {
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(
error.message,
format!("No peer data is available for GID#{gid}")
);
}
other => panic!("unexpected getPeers missing-gid result: {other:?}"),
}
}
#[test]
fn get_files_uses_dir_and_out_options_for_non_bt_path() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/downloads/source.bin");
let group = dispatcher
.engine
.handle_mut(download_id(&gid))
.expect("group should exist");
group.set_option("dir", "D:/downloads");
group.set_option("out", "renamed.iso");
let files = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetFiles,
vec![RpcValue::String(gid)],
));
let expected_path = std::path::PathBuf::from("D:/downloads")
.join("renamed.iso")
.to_string_lossy()
.into_owned();
match files.result {
Some(RpcValue::Array(payload)) => match payload.first() {
Some(RpcValue::Object(file)) => {
assert_eq!(file.get("path"), Some(&RpcValue::String(expected_path)));
}
other => panic!("unexpected getFiles payload: {other:?}"),
},
other => panic!("unexpected getFiles result: {other:?}"),
}
}
@@ -0,0 +1,556 @@
use super::*;
#[test]
fn change_position_reorders_waiting_queue_and_returns_destination() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid0 = add_uri(&mut dispatcher, "https://example.org/0.iso");
let gid1 = add_uri(&mut dispatcher, "https://example.org/1.iso");
let gid2 = add_uri(&mut dispatcher, "https://example.org/2.iso");
let gid3 = add_uri(&mut dispatcher, "https://example.org/3.iso");
let gid4 = add_uri(&mut dispatcher, "https://example.org/4.iso");
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangePosition,
vec![
RpcValue::String(gid1.clone()),
RpcValue::Number(4),
RpcValue::String("POS_SET".to_owned()),
],
));
assert_eq!(response.result, Some(RpcValue::Number(4)));
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangePosition,
vec![
RpcValue::String(gid2.clone()),
RpcValue::Number(3),
RpcValue::String("POS_SET".to_owned()),
],
));
assert_eq!(response.result, Some(RpcValue::Number(3)));
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangePosition,
vec![
RpcValue::String(gid2.clone()),
RpcValue::Number(1),
RpcValue::String("POS_SET".to_owned()),
],
));
assert_eq!(response.result, Some(RpcValue::Number(1)));
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangePosition,
vec![
RpcValue::String(gid1.clone()),
RpcValue::Number(1),
RpcValue::String("POS_CUR".to_owned()),
],
));
assert_eq!(response.result, Some(RpcValue::Number(4)));
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangePosition,
vec![
RpcValue::String(gid0.clone()),
RpcValue::Number(-2),
RpcValue::String("POS_END".to_owned()),
],
));
assert_eq!(response.result, Some(RpcValue::Number(2)));
let waiting = dispatcher.dispatch_json(request(RpcMethod::Aria2TellWaiting, vec![]));
let waiting_gids = match waiting.result {
Some(RpcValue::Array(entries)) => entries
.into_iter()
.map(|entry| match entry {
RpcValue::Object(payload) => match payload.get("gid") {
Some(RpcValue::String(gid)) => gid.clone(),
other => panic!("unexpected waiting payload: {other:?}"),
},
other => panic!("unexpected waiting row: {other:?}"),
})
.collect::<Vec<_>>(),
other => panic!("unexpected tellWaiting result: {other:?}"),
};
assert_eq!(waiting_gids, vec![gid2, gid3, gid0, gid4, gid1]);
}
#[test]
fn change_position_rejects_active_downloads_not_in_waiting_queue() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/active.iso");
let _ = dispatcher.engine.schedule_once();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangePosition,
vec![
RpcValue::String(gid.clone()),
RpcValue::Number(0),
RpcValue::String("POS_SET".to_owned()),
],
));
let error = response
.error
.expect("active gid should not be movable in waiting queue");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(
error.message,
format!("GID#{gid} not found in the waiting queue.")
);
}
#[test]
fn change_position_reports_upstream_style_invalid_gid_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "bad-position-gid".to_owned();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangePosition,
vec![
RpcValue::String(gid.clone()),
RpcValue::Number(0),
RpcValue::String("POS_SET".to_owned()),
],
));
let error = response
.error
.expect("invalid gid should be rejected by changePosition");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("Invalid GID {gid}"));
}
#[test]
fn change_position_reports_upstream_style_missing_waiting_gid_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "00000000000000aa".to_owned();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangePosition,
vec![
RpcValue::String(gid.clone()),
RpcValue::Number(0),
RpcValue::String("POS_SET".to_owned()),
],
));
let error = response
.error
.expect("missing gid should be rejected by changePosition");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(
error.message,
format!("GID#{gid} not found in the waiting queue.")
);
}
#[test]
fn get_uris_reports_used_and_waiting_entries_in_order() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2AddUri,
vec![RpcValue::Array(vec![
RpcValue::String("https://example.org/primary.iso".to_owned()),
RpcValue::String("https://mirror1.example.org/primary.iso".to_owned()),
RpcValue::String("https://mirror2.example.org/primary.iso".to_owned()),
])],
));
let gid = match response.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri result: {other:?}"),
};
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetUris,
vec![RpcValue::String(gid)],
));
match response.result {
Some(RpcValue::Array(entries)) => {
assert_eq!(entries.len(), 3);
let tuples = entries
.into_iter()
.map(|entry| match entry {
RpcValue::Object(payload) => {
let status = match payload.get("status") {
Some(RpcValue::String(value)) => value.clone(),
other => panic!("unexpected uri status: {other:?}"),
};
let uri = match payload.get("uri") {
Some(RpcValue::String(value)) => value.clone(),
other => panic!("unexpected uri value: {other:?}"),
};
(status, uri)
}
other => panic!("unexpected getUris row: {other:?}"),
})
.collect::<Vec<_>>();
assert_eq!(
tuples,
vec![
(
"used".to_owned(),
"https://example.org/primary.iso".to_owned(),
),
(
"waiting".to_owned(),
"https://mirror1.example.org/primary.iso".to_owned(),
),
(
"waiting".to_owned(),
"https://mirror2.example.org/primary.iso".to_owned(),
),
]
);
}
other => panic!("unexpected getUris result: {other:?}"),
}
}
#[test]
fn get_uris_reports_upstream_style_invalid_gid_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "bad-uris-gid".to_owned();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetUris,
vec![RpcValue::String(gid.clone())],
));
let error = response
.error
.expect("invalid gid should be rejected by getUris");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("Invalid GID {gid}"));
}
#[test]
fn change_uri_removes_and_inserts_uris_with_position_semantics() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2AddUri,
vec![RpcValue::Array(vec![
RpcValue::String("https://example.org/base.iso".to_owned()),
RpcValue::String("https://mirror1.example.org/base.iso".to_owned()),
RpcValue::String("https://mirror2.example.org/base.iso".to_owned()),
])],
));
let gid = match response.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri result: {other:?}"),
};
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeUri,
vec![
RpcValue::String(gid.clone()),
RpcValue::Number(1),
RpcValue::Array(vec![RpcValue::String(
"https://mirror1.example.org/base.iso".to_owned(),
)]),
RpcValue::Array(vec![
RpcValue::String("baduri".to_owned()),
RpcValue::String("https://mirror3.example.org/base.iso".to_owned()),
RpcValue::String("https://mirror4.example.org/base.iso".to_owned()),
]),
RpcValue::Number(1),
],
));
assert_eq!(
response.result,
Some(RpcValue::Array(vec![
RpcValue::Number(1),
RpcValue::Number(2),
]))
);
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetUris,
vec![RpcValue::String(gid)],
));
match response.result {
Some(RpcValue::Array(entries)) => {
let uris = entries
.into_iter()
.map(|entry| match entry {
RpcValue::Object(payload) => match payload.get("uri") {
Some(RpcValue::String(uri)) => uri.clone(),
other => panic!("unexpected uri payload: {other:?}"),
},
other => panic!("unexpected getUris row: {other:?}"),
})
.collect::<Vec<_>>();
assert_eq!(
uris,
vec![
"https://example.org/base.iso".to_owned(),
"https://mirror3.example.org/base.iso".to_owned(),
"https://mirror4.example.org/base.iso".to_owned(),
"https://mirror2.example.org/base.iso".to_owned(),
]
);
}
other => panic!("unexpected getUris result after changeUri: {other:?}"),
}
}
#[test]
fn change_uri_rejects_out_of_range_file_index() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/file.iso");
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeUri,
vec![
RpcValue::String(gid),
RpcValue::Number(2),
RpcValue::Array(Vec::new()),
RpcValue::Array(Vec::new()),
],
));
let error = response
.error
.expect("out-of-range fileIndex should be rejected");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert!(error.message.contains("fileIndex is out of range"));
}
#[test]
fn change_uri_reports_upstream_style_missing_gid_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let missing_gid = "0123456789abcdef".to_owned();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeUri,
vec![
RpcValue::String(missing_gid.clone()),
RpcValue::Number(1),
RpcValue::Array(Vec::new()),
RpcValue::Array(Vec::new()),
],
));
let error = response
.error
.expect("missing gid should be rejected by changeUri");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(
error.message,
format!("Cannot remove URIs from GID#{missing_gid}")
);
}
#[test]
fn change_uri_reports_upstream_style_invalid_gid_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "bad-change-uri-gid".to_owned();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeUri,
vec![
RpcValue::String(gid.clone()),
RpcValue::Number(1),
RpcValue::Array(Vec::new()),
RpcValue::Array(Vec::new()),
],
));
let error = response
.error
.expect("invalid gid should be rejected by changeUri");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("Invalid GID {gid}"));
}
#[test]
fn change_uri_skips_non_string_entries_in_uri_arrays() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/file.iso");
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeUri,
vec![
RpcValue::String(gid.clone()),
RpcValue::Number(1),
RpcValue::Array(vec![
RpcValue::Number(1),
RpcValue::Bool(false),
RpcValue::String("https://example.org/file.iso".to_owned()),
]),
RpcValue::Array(vec![
RpcValue::Object(BTreeMap::new()),
RpcValue::String("baduri".to_owned()),
RpcValue::String("https://mirror.example.org/file.iso".to_owned()),
RpcValue::Number(2),
RpcValue::String("https://mirror2.example.org/file.iso".to_owned()),
]),
RpcValue::Number(0),
],
));
assert_eq!(
response.result,
Some(RpcValue::Array(vec![
RpcValue::Number(1),
RpcValue::Number(2),
]))
);
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetUris,
vec![RpcValue::String(gid)],
));
match response.result {
Some(RpcValue::Array(entries)) => {
let uris = entries
.into_iter()
.map(|entry| match entry {
RpcValue::Object(payload) => match payload.get("uri") {
Some(RpcValue::String(uri)) => uri.clone(),
other => panic!("unexpected uri payload: {other:?}"),
},
other => panic!("unexpected getUris row: {other:?}"),
})
.collect::<Vec<_>>();
assert_eq!(
uris,
vec![
"https://mirror.example.org/file.iso".to_owned(),
"https://mirror2.example.org/file.iso".to_owned(),
]
);
}
other => panic!("unexpected getUris result after mixed changeUri arrays: {other:?}"),
}
}
#[test]
fn purge_download_result_removes_only_stopped_downloads() {
let mut dispatcher = InProcessRpcDispatcher::new();
let waiting_gid = add_uri(&mut dispatcher, "https://example.org/waiting.iso");
let paused_gid = add_uri(&mut dispatcher, "https://example.org/paused.iso");
let complete_gid = add_uri(&mut dispatcher, "https://example.org/complete.iso");
let removed_gid = add_uri(&mut dispatcher, "https://example.org/removed.iso");
let error_gid = add_uri(&mut dispatcher, "https://example.org/error.iso");
let _ = dispatcher.dispatch_json(request(
RpcMethod::Aria2Pause,
vec![RpcValue::String(paused_gid.clone())],
));
dispatcher
.engine
.complete(download_id(&complete_gid))
.expect("complete transition should succeed");
dispatcher
.engine
.remove(download_id(&removed_gid))
.expect("remove transition should succeed");
dispatcher
.engine
.fail(download_id(&error_gid))
.expect("error transition should succeed");
let response = dispatcher.dispatch_json(request(RpcMethod::Aria2PurgeDownloadResult, vec![]));
assert_eq!(response.result, Some(RpcValue::String("OK".to_owned())));
assert!(
dispatcher
.engine
.registry()
.get(download_id(&waiting_gid))
.is_some()
);
assert!(
dispatcher
.engine
.registry()
.get(download_id(&paused_gid))
.is_some()
);
assert!(
dispatcher
.engine
.registry()
.get(download_id(&complete_gid))
.is_none()
);
assert!(
dispatcher
.engine
.registry()
.get(download_id(&removed_gid))
.is_none()
);
assert!(
dispatcher
.engine
.registry()
.get(download_id(&error_gid))
.is_none()
);
}
#[test]
fn remove_download_result_removes_stopped_gid_and_preserves_live_queue() {
let mut dispatcher = InProcessRpcDispatcher::new();
let waiting_gid = add_uri(&mut dispatcher, "https://example.org/waiting.iso");
let complete_gid = add_uri(&mut dispatcher, "https://example.org/complete.iso");
dispatcher
.engine
.complete(download_id(&complete_gid))
.expect("complete transition should succeed");
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2RemoveDownloadResult,
vec![RpcValue::String(complete_gid.clone())],
));
assert_eq!(response.result, Some(RpcValue::String("OK".to_owned())));
assert!(
dispatcher
.engine
.registry()
.get(download_id(&complete_gid))
.is_none()
);
assert!(
dispatcher
.engine
.registry()
.get(download_id(&waiting_gid))
.is_some()
);
}
#[test]
fn remove_download_result_rejects_live_gid() {
let mut dispatcher = InProcessRpcDispatcher::new();
let waiting_gid = add_uri(&mut dispatcher, "https://example.org/waiting.iso");
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2RemoveDownloadResult,
vec![RpcValue::String(waiting_gid.clone())],
));
let error = response
.error
.expect("live gid should not be removable via removeDownloadResult");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert!(error.message.contains(&waiting_gid));
assert!(
dispatcher
.engine
.registry()
.get(download_id(&waiting_gid))
.is_some()
);
}
#[test]
fn remove_download_result_reports_upstream_style_invalid_gid_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "bad-remove-result-gid".to_owned();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2RemoveDownloadResult,
vec![RpcValue::String(gid.clone())],
));
let error = response
.error
.expect("invalid gid should be rejected by removeDownloadResult");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("Invalid GID {gid}"));
}
@@ -0,0 +1,7 @@
pub(super) use super::*;
mod file_views;
mod queue_mutation;
mod queue_views;
mod session_and_shutdown;
mod transfer_runtime;
@@ -0,0 +1,105 @@
use super::*;
#[test]
fn get_files_exposes_piece_bitfield_and_piece_metrics() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/pieces.bin");
let group = dispatcher
.engine
.handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse"))
.expect("group should exist");
group.set_piece_length(1024);
group.set_total_length(4096);
group.set_piece_state(PieceId(0), PieceState::Verified);
group.set_piece_state(PieceId(1), PieceState::Downloading);
group.set_completed_length(1_536);
let files = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetFiles,
vec![RpcValue::String(gid.clone())],
));
match files.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(file)) => {
assert_eq!(
file.get("pieceLength"),
Some(&RpcValue::String("1024".to_owned()))
);
assert_eq!(
file.get("numPieces"),
Some(&RpcValue::String("4".to_owned()))
);
assert_eq!(
file.get("bitfield"),
Some(&RpcValue::String("2100".to_owned()))
);
assert_eq!(
file.get("completedLength"),
Some(&RpcValue::String("1024".to_owned()))
);
}
other => panic!("unexpected file payload entry: {other:?}"),
},
other => panic!("unexpected getFiles result for bitfield test: {other:?}"),
}
}
#[test]
fn get_files_completed_length_counts_only_verified_pieces_for_bt_files() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/bt-layout.bin");
let group = dispatcher
.engine
.handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse"))
.expect("group should exist");
group.set_piece_length(1024);
group.set_total_length(4096);
group.set_piece_state(PieceId(0), PieceState::Verified);
group.set_piece_state(PieceId(1), PieceState::Downloading);
group.set_completed_length(1_536);
let bt = BtRuntimeState {
files: vec![
BtFileInfo {
path: "disc-1.mkv".to_owned(),
length: 2048,
piece_offset: Some(0),
selected: true,
},
BtFileInfo {
path: "disc-2.mkv".to_owned(),
length: 2048,
piece_offset: Some(2048),
selected: true,
},
],
..BtRuntimeState::default()
};
group.set_bt(bt);
let files = dispatcher.dispatch_json(request(
RpcMethod::Aria2GetFiles,
vec![RpcValue::String(gid.clone())],
));
match files.result {
Some(RpcValue::Array(entries)) => {
assert_eq!(entries.len(), 2);
let first = match &entries[0] {
RpcValue::Object(file) => file,
other => panic!("unexpected first file row: {other:?}"),
};
let second = match &entries[1] {
RpcValue::Object(file) => file,
other => panic!("unexpected second file row: {other:?}"),
};
assert_eq!(
first.get("completedLength"),
Some(&RpcValue::String("1024".to_owned()))
);
assert_eq!(
second.get("completedLength"),
Some(&RpcValue::String("0".to_owned()))
);
}
other => panic!("unexpected getFiles result for bt completedLength test: {other:?}"),
}
}
@@ -0,0 +1,155 @@
use super::*;
#[test]
fn pause_all_and_unpause_all_mutate_queue_state() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid1 = add_uri(&mut dispatcher, "https://example.org/a.iso");
let gid2 = add_uri(&mut dispatcher, "https://example.org/b.iso");
let _ = dispatcher.dispatch_json(request(
RpcMethod::Aria2Unpause,
vec![RpcValue::String(gid1.clone())],
));
let _ = dispatcher.dispatch_json(request(
RpcMethod::Aria2Unpause,
vec![RpcValue::String(gid2.clone())],
));
let paused = dispatcher.dispatch_json(request(RpcMethod::Aria2PauseAll, vec![]));
assert_eq!(paused.result, Some(RpcValue::String("OK".to_owned())));
let waiting = dispatcher.dispatch_json(request(RpcMethod::Aria2TellWaiting, vec![]));
match waiting.result {
Some(RpcValue::Array(entries)) => assert_eq!(entries.len(), 2),
other => panic!("unexpected tellWaiting result after pauseAll: {other:?}"),
}
let stopped = dispatcher.dispatch_json(request(RpcMethod::Aria2TellStopped, vec![]));
match stopped.result {
Some(RpcValue::Array(entries)) => assert_eq!(entries.len(), 0),
other => panic!("unexpected tellStopped result after pauseAll: {other:?}"),
}
let resumed = dispatcher.dispatch_json(request(RpcMethod::Aria2UnpauseAll, vec![]));
assert_eq!(resumed.result, Some(RpcValue::String("OK".to_owned())));
let waiting = dispatcher.dispatch_json(request(RpcMethod::Aria2TellWaiting, vec![]));
match waiting.result {
Some(RpcValue::Array(entries)) => assert_eq!(entries.len(), 2),
other => panic!("unexpected tellWaiting result after unpauseAll: {other:?}"),
}
}
#[test]
fn pause_all_and_unpause_all_only_touch_documented_statuses() {
let mut dispatcher = InProcessRpcDispatcher::new();
let waiting_gid = add_uri(&mut dispatcher, "https://example.org/waiting.iso");
let active_gid = add_uri(&mut dispatcher, "https://example.org/active.iso");
let paused_gid = add_uri(&mut dispatcher, "https://example.org/paused.iso");
let complete_gid = add_uri(&mut dispatcher, "https://example.org/complete.iso");
let removed_gid = add_uri(&mut dispatcher, "https://example.org/removed.iso");
let _ = dispatcher.dispatch_json(request(
RpcMethod::Aria2Unpause,
vec![RpcValue::String(active_gid.clone())],
));
let _ = dispatcher.dispatch_json(request(
RpcMethod::Aria2Pause,
vec![RpcValue::String(paused_gid.clone())],
));
dispatcher
.engine
.complete(download_id(&complete_gid))
.expect("complete transition should succeed");
dispatcher
.engine
.remove(download_id(&removed_gid))
.expect("remove transition should succeed");
let paused = dispatcher.dispatch_json(request(RpcMethod::Aria2PauseAll, vec![]));
assert_eq!(paused.result, Some(RpcValue::String("OK".to_owned())));
assert_eq!(
dispatcher
.engine
.registry()
.get(download_id(&waiting_gid))
.map(|group| group.status().clone()),
Some(aria2_rust_pro_core::DownloadStatus::Paused)
);
assert_eq!(
dispatcher
.engine
.registry()
.get(download_id(&active_gid))
.map(|group| group.status().clone()),
Some(aria2_rust_pro_core::DownloadStatus::Paused)
);
assert_eq!(
dispatcher
.engine
.registry()
.get(download_id(&paused_gid))
.map(|group| group.status().clone()),
Some(aria2_rust_pro_core::DownloadStatus::Paused)
);
assert_eq!(
dispatcher
.engine
.registry()
.get(download_id(&complete_gid))
.map(|group| group.status().clone()),
Some(aria2_rust_pro_core::DownloadStatus::Complete)
);
assert_eq!(
dispatcher
.engine
.registry()
.get(download_id(&removed_gid))
.map(|group| group.status().clone()),
Some(aria2_rust_pro_core::DownloadStatus::Removed)
);
let resumed = dispatcher.dispatch_json(request(RpcMethod::Aria2UnpauseAll, vec![]));
assert_eq!(resumed.result, Some(RpcValue::String("OK".to_owned())));
assert_eq!(
dispatcher
.engine
.registry()
.get(download_id(&waiting_gid))
.map(|group| group.status().clone()),
Some(aria2_rust_pro_core::DownloadStatus::Waiting)
);
assert_eq!(
dispatcher
.engine
.registry()
.get(download_id(&active_gid))
.map(|group| group.status().clone()),
Some(aria2_rust_pro_core::DownloadStatus::Waiting)
);
assert_eq!(
dispatcher
.engine
.registry()
.get(download_id(&paused_gid))
.map(|group| group.status().clone()),
Some(aria2_rust_pro_core::DownloadStatus::Waiting)
);
assert_eq!(
dispatcher
.engine
.registry()
.get(download_id(&complete_gid))
.map(|group| group.status().clone()),
Some(aria2_rust_pro_core::DownloadStatus::Complete)
);
assert_eq!(
dispatcher
.engine
.registry()
.get(download_id(&removed_gid))
.map(|group| group.status().clone()),
Some(aria2_rust_pro_core::DownloadStatus::Removed)
);
}
@@ -0,0 +1,291 @@
use super::*;
#[test]
fn tell_waiting_and_tell_stopped_respect_offset_and_max() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid1 = add_uri(&mut dispatcher, "https://example.org/1.iso");
let gid2 = add_uri(&mut dispatcher, "https://example.org/2.iso");
let gid3 = add_uri(&mut dispatcher, "https://example.org/3.iso");
let waiting_forward = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellWaiting,
vec![RpcValue::Number(0), RpcValue::Number(10)],
));
let forward_waiting_gids = match waiting_forward.result {
Some(RpcValue::Array(entries)) => entries
.iter()
.map(|entry| match entry {
RpcValue::Object(payload) => match payload.get("gid") {
Some(RpcValue::String(gid)) => gid.clone(),
other => panic!("unexpected waiting gid payload: {other:?}"),
},
other => panic!("unexpected waiting row: {other:?}"),
})
.collect::<Vec<_>>(),
other => panic!("unexpected tellWaiting forward result: {other:?}"),
};
let waiting_slice = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellWaiting,
vec![RpcValue::Number(1), RpcValue::Number(1)],
));
match waiting_slice.result {
Some(RpcValue::Array(entries)) => {
assert_eq!(entries.len(), 1);
match entries.first() {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("gid"),
Some(&RpcValue::String(forward_waiting_gids[1].clone()))
);
}
other => panic!("unexpected tellWaiting row: {other:?}"),
}
}
other => panic!("unexpected tellWaiting slice result: {other:?}"),
}
let _ = dispatcher.dispatch_json(request(RpcMethod::Aria2Pause, vec![RpcValue::String(gid1)]));
let _ = dispatcher.dispatch_json(request(RpcMethod::Aria2Pause, vec![RpcValue::String(gid2)]));
dispatcher
.engine
.complete(download_id(&gid3))
.expect("complete transition should succeed");
let stopped_slice = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStopped,
vec![RpcValue::Number(0), RpcValue::Number(2)],
));
match stopped_slice.result {
Some(RpcValue::Array(entries)) => {
assert_eq!(entries.len(), 1);
match entries.first() {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("gid"), Some(&RpcValue::String(gid3.clone())));
}
other => panic!("unexpected tellStopped row: {other:?}"),
}
}
other => panic!("unexpected tellStopped slice result: {other:?}"),
}
}
#[test]
fn tell_active_filters_requested_keys_only() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/active.iso");
let _ = dispatcher.engine.schedule_once();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellActive,
vec![RpcValue::Array(vec![
RpcValue::String("gid".to_owned()),
RpcValue::String("status".to_owned()),
])],
));
match response.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.len(), 2);
assert_eq!(payload.get("gid"), Some(&RpcValue::String(gid)));
assert_eq!(
payload.get("status"),
Some(&RpcValue::String("active".to_owned()))
);
}
other => panic!("unexpected tellActive filtered row: {other:?}"),
},
other => panic!("unexpected tellActive filtered result: {other:?}"),
}
}
#[test]
fn tell_waiting_and_tell_stopped_filter_requested_keys_only() {
let mut dispatcher = InProcessRpcDispatcher::new();
let waiting_gid = add_uri(&mut dispatcher, "https://example.org/waiting.iso");
let stopped_gid = add_uri(&mut dispatcher, "https://example.org/stopped.iso");
dispatcher
.engine
.complete(download_id(&stopped_gid))
.expect("complete transition should succeed");
let waiting = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellWaiting,
vec![
RpcValue::Number(0),
RpcValue::Number(10),
RpcValue::Array(vec![
RpcValue::String("gid".to_owned()),
RpcValue::String("status".to_owned()),
]),
],
));
match waiting.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.len(), 2);
assert_eq!(payload.get("gid"), Some(&RpcValue::String(waiting_gid)));
assert_eq!(
payload.get("status"),
Some(&RpcValue::String("waiting".to_owned()))
);
}
other => panic!("unexpected tellWaiting filtered row: {other:?}"),
},
other => panic!("unexpected tellWaiting filtered result: {other:?}"),
}
let stopped = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStopped,
vec![
RpcValue::Number(0),
RpcValue::Number(10),
RpcValue::Array(vec![
RpcValue::String("gid".to_owned()),
RpcValue::String("status".to_owned()),
]),
],
));
match stopped.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.len(), 2);
assert_eq!(payload.get("gid"), Some(&RpcValue::String(stopped_gid)));
assert_eq!(
payload.get("status"),
Some(&RpcValue::String("complete".to_owned()))
);
}
other => panic!("unexpected tellStopped filtered row: {other:?}"),
},
other => panic!("unexpected tellStopped filtered result: {other:?}"),
}
}
#[test]
fn tell_waiting_and_tell_stopped_support_negative_offsets() {
let mut dispatcher = InProcessRpcDispatcher::new();
let _gid1 = add_uri(&mut dispatcher, "https://example.org/a.iso");
let gid2 = add_uri(&mut dispatcher, "https://example.org/b.iso");
let gid3 = add_uri(&mut dispatcher, "https://example.org/c.iso");
let _ = dispatcher.dispatch_json(request(
RpcMethod::Aria2Pause,
vec![RpcValue::String(gid2.clone())],
));
dispatcher
.engine
.complete(download_id(&gid3))
.expect("complete transition should succeed");
let waiting_forward = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellWaiting,
vec![RpcValue::Number(0), RpcValue::Number(10)],
));
let forward_waiting_gids = match waiting_forward.result {
Some(RpcValue::Array(entries)) => entries
.iter()
.map(|entry| match entry {
RpcValue::Object(payload) => match payload.get("gid") {
Some(RpcValue::String(gid)) => gid.clone(),
other => panic!("unexpected waiting gid payload: {other:?}"),
},
other => panic!("unexpected waiting row: {other:?}"),
})
.collect::<Vec<_>>(),
other => panic!("unexpected tellWaiting forward result: {other:?}"),
};
let waiting_tail = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellWaiting,
vec![RpcValue::Number(-1), RpcValue::Number(2)],
));
match waiting_tail.result {
Some(RpcValue::Array(entries)) => {
assert_eq!(entries.len(), 2);
let tail_gids = entries
.iter()
.map(|entry| match entry {
RpcValue::Object(payload) => match payload.get("gid") {
Some(RpcValue::String(gid)) => gid.clone(),
other => panic!("unexpected waiting gid payload: {other:?}"),
},
other => panic!("unexpected waiting row: {other:?}"),
})
.collect::<Vec<_>>();
let expected = forward_waiting_gids
.iter()
.rev()
.take(2)
.cloned()
.collect::<Vec<_>>();
assert_eq!(tail_gids, expected);
}
other => panic!("unexpected tellWaiting negative-offset result: {other:?}"),
}
let stopped_tail = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStopped,
vec![RpcValue::Number(-1), RpcValue::Number(1)],
));
match stopped_tail.result {
Some(RpcValue::Array(entries)) => {
assert_eq!(entries.len(), 1);
match entries.first() {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("gid"), Some(&RpcValue::String(gid3.clone())));
}
other => panic!("unexpected tellStopped negative-offset row: {other:?}"),
}
}
other => panic!("unexpected tellStopped negative-offset result: {other:?}"),
}
}
#[test]
fn tell_stopped_orders_by_least_recently_stopped_first() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid_a = add_uri(&mut dispatcher, "https://example.org/a.iso");
let gid_b = add_uri(&mut dispatcher, "https://example.org/b.iso");
let gid_c = add_uri(&mut dispatcher, "https://example.org/c.iso");
let gid_d = add_uri(&mut dispatcher, "https://example.org/d.iso");
dispatcher
.engine
.complete(download_id(&gid_c))
.expect("complete transition should succeed");
dispatcher
.engine
.remove(download_id(&gid_a))
.expect("remove transition should succeed");
dispatcher
.engine
.complete(download_id(&gid_d))
.expect("complete transition should succeed");
dispatcher
.engine
.fail(download_id(&gid_b))
.expect("error transition should succeed");
let stopped = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStopped,
vec![RpcValue::Number(0), RpcValue::Number(10)],
));
let gids = match stopped.result {
Some(RpcValue::Array(entries)) => entries
.iter()
.map(|entry| match entry {
RpcValue::Object(payload) => match payload.get("gid") {
Some(RpcValue::String(gid)) => gid.clone(),
other => panic!("unexpected tellStopped gid payload: {other:?}"),
},
other => panic!("unexpected tellStopped row: {other:?}"),
})
.collect::<Vec<_>>(),
other => panic!("unexpected tellStopped result: {other:?}"),
};
assert_eq!(gids, vec![gid_c, gid_a, gid_d, gid_b]);
}
@@ -0,0 +1,32 @@
use super::*;
#[test]
fn save_session_uses_runtime_session_path() {
let session_path = temp_session_path("rpc-session.txt");
let runtime = RuntimeConfig::default().with_session_path(session_path.clone());
let mut dispatcher = InProcessRpcDispatcher::with_runtime(runtime);
let _gid = add_uri(&mut dispatcher, "https://example.org/path/file.iso");
let response = dispatcher.dispatch_json(request(RpcMethod::Aria2SaveSession, vec![]));
assert_eq!(response.result, Some(RpcValue::String("OK".to_owned())));
let session = load_session_file(&session_path).expect("saved session file should load");
assert_eq!(session.entries.len(), 1);
let root = session_path
.parent()
.expect("session path should have a parent")
.to_path_buf();
let _ = fs::remove_dir_all(root);
}
#[test]
fn shutdown_methods_return_ok() {
let mut dispatcher = InProcessRpcDispatcher::new();
let shutdown = dispatcher.dispatch_json(request(RpcMethod::Aria2Shutdown, vec![]));
assert_eq!(shutdown.result, Some(RpcValue::String("OK".to_owned())));
let force = dispatcher.dispatch_json(request(RpcMethod::Aria2ForceShutdown, vec![]));
assert_eq!(force.result, Some(RpcValue::String("OK".to_owned())));
}
@@ -0,0 +1,464 @@
use super::*;
#[test]
fn tell_status_and_global_stat_reflect_piece_backed_progress() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/progress.bin");
let group = dispatcher
.engine
.handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse"))
.expect("group should exist");
group.set_piece_length(1024);
group.set_total_length(2048);
group.set_piece_state(PieceId(0), PieceState::Verified);
group.set_download_speed(256);
group.set_num_connections(2);
group.set_status(aria2_rust_pro_core::DownloadStatus::Active);
let status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("totalLength"),
Some(&RpcValue::String("2048".to_owned()))
);
assert_eq!(
payload.get("completedLength"),
Some(&RpcValue::String("1024".to_owned()))
);
assert_eq!(
payload.get("connections"),
Some(&RpcValue::String("2".to_owned()))
);
assert_eq!(
payload.get("activeSegments"),
Some(&RpcValue::String("2".to_owned()))
);
}
other => panic!("unexpected tellStatus progress result: {other:?}"),
}
let global = dispatcher.dispatch_json(request(RpcMethod::Aria2TellGlobalStat, vec![]));
match global.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("numActive"),
Some(&RpcValue::String("1".to_owned()))
);
assert_eq!(
payload.get("numWaiting"),
Some(&RpcValue::String("0".to_owned()))
);
assert_eq!(
payload.get("downloadSpeed"),
Some(&RpcValue::String("256".to_owned()))
);
assert_eq!(
payload.get("uploadSpeed"),
Some(&RpcValue::String("0".to_owned()))
);
assert_eq!(
payload.get("numStopped"),
Some(&RpcValue::String("0".to_owned()))
);
assert_eq!(
payload.get("numStoppedTotal"),
Some(&RpcValue::String("0".to_owned()))
);
assert_eq!(payload.len(), 6);
}
other => panic!("unexpected tellGlobalStat progress result: {other:?}"),
}
}
#[test]
fn record_http_transfer_result_updates_rpc_visible_lengths() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/fixture.bin");
let response = aria2_rust_pro_protocol::HttpResponseModel {
status: 200,
reason: "OK".to_owned(),
version: aria2_rust_pro_protocol::HttpVersion::Http11,
headers: aria2_rust_pro_protocol::HttpResponseHeaders {
headers: vec![aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4096".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
}],
},
body: aria2_rust_pro_protocol::ResponseBody::Inline(vec![0_u8; 4096]),
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
};
dispatcher
.record_http_transfer_result(&gid, &response, 4, 2, true)
.expect("http result should update dispatcher state");
let status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("status"),
Some(&RpcValue::String("complete".to_owned()))
);
assert_eq!(
payload.get("totalLength"),
Some(&RpcValue::String("4096".to_owned()))
);
assert_eq!(
payload.get("completedLength"),
Some(&RpcValue::String("4096".to_owned()))
);
assert_eq!(
payload.get("connections"),
Some(&RpcValue::String("4".to_owned()))
);
assert_eq!(
payload.get("retryCount"),
Some(&RpcValue::String("2".to_owned()))
);
}
other => panic!("unexpected tellStatus after http result: {other:?}"),
}
}
#[test]
fn record_http_transfer_result_respects_terminal_completion_gate() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/checksum-gated.bin");
let response = aria2_rust_pro_protocol::HttpResponseModel {
status: 200,
reason: "OK".to_owned(),
version: aria2_rust_pro_protocol::HttpVersion::Http11,
headers: aria2_rust_pro_protocol::HttpResponseHeaders {
headers: vec![aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "4096".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
}],
},
body: aria2_rust_pro_protocol::ResponseBody::Inline(vec![0_u8; 4096]),
content_range: None,
partial_content: false,
checksum: Some(aria2_rust_pro_protocol::ChecksumSpec {
algorithm: "sha-1".to_owned(),
expected_hex: "0000000000000000000000000000000000000000".to_owned(),
actual_hex: None,
}),
redirected_from: None,
};
dispatcher
.record_http_transfer_result(&gid, &response, 4, 0, false)
.expect("gated http result should update dispatcher state without completion");
let status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("status"),
Some(&RpcValue::String("active".to_owned()))
);
assert_eq!(
payload.get("completedLength"),
Some(&RpcValue::String("4096".to_owned()))
);
}
other => panic!("unexpected tellStatus after gated http result: {other:?}"),
}
}
#[test]
fn record_http_transfer_result_keeps_partial_transfer_active() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/partial.bin");
let response = aria2_rust_pro_protocol::HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: aria2_rust_pro_protocol::HttpVersion::Http11,
headers: aria2_rust_pro_protocol::HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "1024".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 0-1023/4096".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: aria2_rust_pro_protocol::ResponseBody::Inline(vec![0_u8; 1024]),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 0,
end_inclusive: 1023,
total_size: Some(4096),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
};
dispatcher
.record_http_transfer_result(&gid, &response, 2, 1, true)
.expect("partial http result should be ingested");
let status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("status"),
Some(&RpcValue::String("active".to_owned()))
);
assert_eq!(
payload.get("totalLength"),
Some(&RpcValue::String("4096".to_owned()))
);
assert_eq!(
payload.get("completedLength"),
Some(&RpcValue::String("1024".to_owned()))
);
assert_eq!(
payload.get("retryCount"),
Some(&RpcValue::String("1".to_owned()))
);
}
other => panic!("unexpected tellStatus after partial http result: {other:?}"),
}
}
#[test]
fn record_http_transfer_result_recovers_piece_prefix_when_state_lags_completed_length() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/partial-prefix.bin");
let first_response = aria2_rust_pro_protocol::HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: aria2_rust_pro_protocol::HttpVersion::Http11,
headers: aria2_rust_pro_protocol::HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "1024".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 0-1023/4096".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: aria2_rust_pro_protocol::ResponseBody::Inline(vec![0_u8; 1024]),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 0,
end_inclusive: 1023,
total_size: Some(4096),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
};
dispatcher
.record_http_transfer_result(&gid, &first_response, 2, 0, true)
.expect("first partial response should be ingested");
let group = dispatcher
.engine
.handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse"))
.expect("group should exist");
group.set_piece_length(1024);
group.set_piece_state(PieceId(0), PieceState::Pending);
let second_response = aria2_rust_pro_protocol::HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: aria2_rust_pro_protocol::HttpVersion::Http11,
headers: aria2_rust_pro_protocol::HttpResponseHeaders {
headers: vec![
aria2_rust_pro_protocol::HttpHeader {
name: "content-length".to_owned(),
value: "1024".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
aria2_rust_pro_protocol::HttpHeader {
name: "content-range".to_owned(),
value: "bytes 1024-2047/4096".to_owned(),
kind: aria2_rust_pro_protocol::HeaderKind::Response,
},
],
},
body: aria2_rust_pro_protocol::ResponseBody::Inline(vec![0_u8; 1024]),
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
start: 1024,
end_inclusive: 2047,
total_size: Some(4096),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
};
dispatcher
.record_http_transfer_result(&gid, &second_response, 2, 0, true)
.expect("second partial response should be ingested");
let group = dispatcher
.engine
.handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse"))
.expect("group should still exist");
assert_eq!(group.piece_state(PieceId(0)), Some(PieceState::Verified));
assert_eq!(group.piece_state(PieceId(1)), Some(PieceState::Verified));
assert_eq!(group.completed_length(), 2048);
}
#[test]
fn record_http_transfer_result_marks_retry_relevant_failure_waiting() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/retry.bin");
let response = aria2_rust_pro_protocol::HttpResponseModel {
status: 503,
reason: "Service Unavailable".to_owned(),
version: aria2_rust_pro_protocol::HttpVersion::Http11,
headers: aria2_rust_pro_protocol::HttpResponseHeaders { headers: vec![] },
body: aria2_rust_pro_protocol::ResponseBody::Empty,
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
};
dispatcher
.record_http_transfer_result(&gid, &response, 1, 3, true)
.expect("retry-relevant failure should be ingested");
let status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("status"),
Some(&RpcValue::String("waiting".to_owned()))
);
assert_eq!(
payload.get("retryCount"),
Some(&RpcValue::String("3".to_owned()))
);
}
other => panic!("unexpected tellStatus after retry-relevant failure: {other:?}"),
}
}
#[test]
fn record_http_transfer_result_marks_non_retry_failure_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/not-found.bin");
let response = aria2_rust_pro_protocol::HttpResponseModel {
status: 404,
reason: "Not Found".to_owned(),
version: aria2_rust_pro_protocol::HttpVersion::Http11,
headers: aria2_rust_pro_protocol::HttpResponseHeaders { headers: vec![] },
body: aria2_rust_pro_protocol::ResponseBody::Empty,
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
};
dispatcher
.record_http_transfer_result(&gid, &response, 1, 0, true)
.expect("error failure should be ingested");
let status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("status"),
Some(&RpcValue::String("error".to_owned()))
);
}
other => panic!("unexpected tellStatus after non-retry failure: {other:?}"),
}
}
#[test]
fn tell_status_exposes_retry_attempts_and_resume_state() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/retry-telemetry.bin");
let group = dispatcher
.engine
.handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse"))
.expect("group should exist");
group.set_retry_count(2);
group.push_retry_attempt(aria2_rust_pro_core::RetryAttempt {
attempt: 1,
offset: 1024,
length: Some(2048),
error: Some("connection reset".to_owned()),
recoverable: true,
});
group.push_retry_attempt(aria2_rust_pro_core::RetryAttempt {
attempt: 2,
offset: 4096,
length: None,
error: Some("timeout".to_owned()),
recoverable: true,
});
group.set_resume_state(aria2_rust_pro_core::ResumeState {
persisted: true,
resume_offset: 4096,
validated_length: Some(2048),
segment_cursor: Some(PieceId(4)),
});
let status = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match status.result {
Some(RpcValue::Object(payload)) => {
assert!(matches!(
payload.get("retryAttempts"),
Some(RpcValue::Array(attempts)) if attempts.len() == 2
));
assert_eq!(
payload.get("activeSegments"),
Some(&RpcValue::String("0".to_owned()))
);
assert!(matches!(
payload.get("resumeState"),
Some(RpcValue::Object(state))
if state.get("resumeOffset")
== Some(&RpcValue::String("4096".to_owned()))
));
}
other => panic!("unexpected tellStatus retry telemetry result: {other:?}"),
}
}
@@ -0,0 +1,324 @@
use super::*;
#[test]
fn tell_status_returns_object_payload() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/file.iso");
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match response.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("status"),
Some(&RpcValue::String("waiting".to_owned()))
);
assert!(payload.contains_key("files"));
assert_eq!(payload.get("gid"), payload.get("gid"));
}
other => panic!("unexpected tellStatus result: {other:?}"),
}
}
#[test]
fn tell_status_reports_upstream_style_missing_gid_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "0000000000000001".to_owned();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
let error = response
.error
.expect("missing gid should be rejected by tellStatus");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("No such download for GID#{gid}"));
}
#[test]
fn tell_status_reports_upstream_style_invalid_gid_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "not-a-gid".to_owned();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
let error = response
.error
.expect("invalid gid should be rejected by tellStatus");
assert_eq!(error.code, crate::model::RpcErrorCode::ApplicationError);
assert_eq!(error.message, format!("Invalid GID {gid}"));
}
#[test]
fn tell_status_filters_requested_keys_only() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/file.iso");
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![
RpcValue::String(gid.clone()),
RpcValue::Array(vec![
RpcValue::String("gid".to_owned()),
RpcValue::String("status".to_owned()),
]),
],
));
match response.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.len(), 2);
assert_eq!(payload.get("gid"), Some(&RpcValue::String(gid)));
assert_eq!(
payload.get("status"),
Some(&RpcValue::String("waiting".to_owned()))
);
}
other => panic!("unexpected tellStatus filtered result: {other:?}"),
}
}
#[test]
fn tell_status_with_empty_keys_keeps_full_payload() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/file.iso");
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid), RpcValue::Array(Vec::new())],
));
match response.result {
Some(RpcValue::Object(payload)) => {
assert!(payload.contains_key("gid"));
assert!(payload.contains_key("status"));
assert!(payload.contains_key("files"));
}
other => panic!("unexpected tellStatus empty-keys result: {other:?}"),
}
}
#[test]
fn tell_global_stat_reflects_queue_counts() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_uri(&mut dispatcher, "https://example.org/file.iso");
dispatcher
.engine
.handle_mut(DownloadId::parse_hex(&gid).expect("gid should parse"))
.expect("group should exist")
.set_status(aria2_rust_pro_core::DownloadStatus::Active);
let response = dispatcher.dispatch_json(request(RpcMethod::Aria2TellGlobalStat, vec![]));
match response.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("numActive"),
Some(&RpcValue::String("1".to_owned()))
);
assert_eq!(
payload.get("numWaiting"),
Some(&RpcValue::String("0".to_owned()))
);
assert_eq!(
payload.get("numStopped"),
Some(&RpcValue::String("0".to_owned()))
);
assert_eq!(
payload.get("numStoppedTotal"),
Some(&RpcValue::String("0".to_owned()))
);
assert_eq!(payload.len(), 6);
}
other => panic!("unexpected tellGlobalStat result: {other:?}"),
}
}
#[test]
fn get_global_stat_is_public_method_name_and_legacy_alias_is_hidden() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_json(request_with_method_name("system.listMethods", vec![]));
match response.result {
Some(RpcValue::Array(methods)) => {
let methods = methods
.into_iter()
.map(|value| match value {
RpcValue::String(method) => method,
other => panic!("unexpected method entry: {other:?}"),
})
.collect::<Vec<_>>();
assert!(methods.iter().any(|method| method == "aria2.getGlobalStat"));
assert!(
!methods
.iter()
.any(|method| method == "aria2.tellGlobalStat")
);
}
other => panic!("unexpected system.listMethods result: {other:?}"),
}
}
#[test]
fn legacy_tell_global_stat_alias_still_dispatches() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response =
dispatcher.dispatch_json(request_with_method_name("aria2.tellGlobalStat", vec![]));
match response.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("numStoppedTotal"),
Some(&RpcValue::String("0".to_owned()))
);
}
other => panic!("unexpected legacy tellGlobalStat alias result: {other:?}"),
}
}
#[test]
fn global_options_round_trip_through_rpc() {
let mut dispatcher = InProcessRpcDispatcher::new();
let _ = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeGlobalOption,
vec![RpcValue::Object(BTreeMap::from([
(
"max-connection-per-server".to_owned(),
RpcValue::String("32".to_owned()),
),
("retry-on-403".to_owned(), RpcValue::Bool(true)),
(
"all-proxy-user".to_owned(),
RpcValue::String("proxy-user".to_owned()),
),
("ftp-pasv".to_owned(), RpcValue::Bool(false)),
]))],
));
let response = dispatcher.dispatch_json(request(RpcMethod::Aria2GetGlobalOption, vec![]));
match response.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("max-connection-per-server"),
Some(&RpcValue::String("32".to_owned()))
);
assert_eq!(
payload.get("retry-on-403"),
Some(&RpcValue::String("true".to_owned()))
);
assert_eq!(
payload.get("all-proxy-user"),
Some(&RpcValue::String("proxy-user".to_owned()))
);
assert_eq!(
payload.get("ftp-pasv"),
Some(&RpcValue::String("false".to_owned()))
);
}
other => panic!("unexpected getGlobalOption result: {other:?}"),
}
}
#[test]
fn change_global_option_rejects_checksum() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeGlobalOption,
vec![RpcValue::Object(BTreeMap::from([(
"checksum".to_owned(),
RpcValue::String("sha-1=deadbeef".to_owned()),
)]))],
));
let error = response
.error
.expect("checksum should be rejected for changeGlobalOption");
assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams);
assert!(error.message.contains("checksum"));
}
#[test]
fn change_global_option_rejects_out() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeGlobalOption,
vec![RpcValue::Object(BTreeMap::from([(
"out".to_owned(),
RpcValue::String("file.iso".to_owned()),
)]))],
));
let error = response
.error
.expect("out should be rejected for changeGlobalOption");
assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams);
assert!(error.message.contains("out"));
}
#[test]
fn change_global_option_rejects_index_out() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeGlobalOption,
vec![RpcValue::Object(BTreeMap::from([(
"index-out".to_owned(),
RpcValue::String("1=disc1.iso".to_owned()),
)]))],
));
let error = response
.error
.expect("index-out should be rejected for changeGlobalOption");
assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams);
assert!(error.message.contains("index-out"));
}
#[test]
fn change_global_option_rejects_pause() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeGlobalOption,
vec![RpcValue::Object(BTreeMap::from([(
"pause".to_owned(),
RpcValue::Bool(true),
)]))],
));
let error = response
.error
.expect("pause should be rejected for changeGlobalOption");
assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams);
assert!(error.message.contains("pause"));
}
#[test]
fn change_global_option_rejects_select_file() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatcher.dispatch_json(request(
RpcMethod::Aria2ChangeGlobalOption,
vec![RpcValue::Object(BTreeMap::from([(
"select-file".to_owned(),
RpcValue::String("1,2".to_owned()),
)]))],
));
let error = response
.error
.expect("select-file should be rejected for changeGlobalOption");
assert_eq!(error.code, crate::model::RpcErrorCode::InvalidParams);
assert!(error.message.contains("select-file"));
}
@@ -0,0 +1,454 @@
use aria2_rust_pro_core::{DownloadStatus, PieceId, PieceState, RequestContext, RequestGroup};
use aria2_rust_pro_protocol::{
HttpResponseModel, magnet::parse_magnet_bootstrap, metalink::metalink_download_plan,
parse_metalink_document, parse_torrent_metadata,
};
use base64::Engine;
use crate::{
jsonrpc::{JsonRpcRequest, JsonRpcResponse},
model::{RpcError, RpcValue},
};
use super::{
InProcessRpcDispatcher, build_bt_runtime_state, build_bt_runtime_state_from_magnet,
helpers::{
apply_group_options, apply_group_string_options, decode_metalink_payload,
is_retry_relevant_status, metalink_default_options, parse_optional_option_object,
parse_optional_position, parse_optional_uri_array, parse_uri_list_param, u32_from_usize,
usize_from_u64,
},
http_response_completed_length, http_response_delta_length, http_response_length,
missing_download_error, parse_gid_text,
};
impl InProcessRpcDispatcher {
/// Registers URI-style downloads directly without routing through the JSON-RPC surface.
///
/// # Errors
///
/// Returns an error when the supplied URI list is empty or a magnet URI is invalid.
pub fn add_uri_direct(
&mut self,
uris: Vec<String>,
options: Vec<(String, RpcValue)>,
) -> Result<String, RpcError> {
let string_options = options
.into_iter()
.filter_map(|(key, value)| match value {
RpcValue::String(value) => Some((key, value)),
RpcValue::Number(value) => Some((key, value.to_string())),
RpcValue::Bool(value) => {
Some((key, if value { "true" } else { "false" }.to_owned()))
}
RpcValue::Null => Some((key, String::new())),
RpcValue::Array(_) | RpcValue::Object(_) => None,
})
.collect::<Vec<_>>();
self.add_uri_direct_string_options(uris, string_options)
}
/// Registers URI-style downloads directly with already-normalized string options.
///
/// # Errors
///
/// Returns an error when the supplied URI list is empty or a magnet URI is invalid.
///
/// # Panics
///
/// Panics only if the single-URI branch observes the checked URI list as empty.
pub fn add_uri_direct_string_options(
&mut self,
uris: Vec<String>,
options: Vec<(String, String)>,
) -> Result<String, RpcError> {
let uri = uris
.first()
.ok_or_else(|| RpcError::invalid_params("aria2.addUri needs at least one uri"))?;
if uri
.get(.."magnet:?".len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("magnet:?"))
{
let parsed = parse_magnet_bootstrap(uri).map_err(|error| {
RpcError::invalid_params(&format!("invalid magnet uri: {error}"))
})?;
let bt_state = build_bt_runtime_state_from_magnet(uri, &parsed);
let primary_uri = bt_state
.trackers
.first()
.map(|tracker| tracker.url.clone())
.unwrap_or_else(|| uri.clone());
let mut context = RequestContext::new(primary_uri);
context.source = Some("magnet".to_owned());
context.note = bt_state.name.clone();
let gid = self.engine.add_request(context).gid();
if let Some(group) = self.engine.handle_mut(gid) {
group.set_bt(bt_state);
apply_group_string_options(group, options);
}
return Ok(gid.to_string());
}
if uris.len() == 1 {
let uri = uris
.into_iter()
.next()
.expect("checked single URI should remain present");
let gid = self.engine.add_uri(uri);
if let Some(group) = self.engine.handle_mut(gid.gid()) {
apply_group_string_options(group, options);
}
return Ok(gid.gid().to_string());
}
let uri = uri.clone();
let mut context = RequestContext::new(uri);
context.replace_uris(uris);
let gid = self.engine.add_request(context);
if let Some(group) = self.engine.handle_mut(gid.gid()) {
apply_group_string_options(group, options);
}
Ok(gid.gid().to_string())
}
/// Marks a tracked download as complete.
///
/// # Errors
///
/// Returns an error when `gid` is invalid or the download is no longer tracked.
pub fn mark_complete(&mut self, gid: &str) -> Result<(), RpcError> {
let gid = parse_gid_text(gid)?;
self.engine
.complete(gid)
.map_err(|_| missing_download_error(gid))
}
/// Prepares an HTTP download for an outbound transfer attempt.
///
/// # Errors
///
/// Returns an error when `gid` is invalid or the download is no longer tracked.
pub fn prepare_http_download(&mut self, gid: &str) -> Result<RequestGroup, RpcError> {
let gid = parse_gid_text(gid)?;
self.engine
.prepare_http_download(gid)
.map_err(|_| missing_download_error(gid))
}
/// Records the result of an HTTP transfer back into the engine state.
///
/// # Errors
///
/// Returns an error when `gid` is invalid or the download is no longer tracked.
pub fn record_http_transfer_result(
&mut self,
gid: &str,
response: &HttpResponseModel,
max_connections: u16,
retry_count: u32,
allow_terminal_complete: bool,
) -> Result<(), RpcError> {
let gid = parse_gid_text(gid)?;
let piece_length = self.engine.runtime().piece_length.max(1);
let response_total_length = http_response_length(response).unwrap_or(0);
let response_completed_length = http_response_completed_length(response).unwrap_or(0);
let response_delta =
http_response_delta_length(response).unwrap_or(response_completed_length);
let next_status = {
let group = self
.engine
.handle_mut(gid)
.ok_or_else(|| missing_download_error(gid))?;
let previous_completed_length = group.completed_length();
let total_length = group.total_length().max(response_total_length);
let completed_length = previous_completed_length.max(response_completed_length);
let piece_count = if total_length == 0 {
0_usize
} else {
usize_from_u64(total_length.div_ceil(piece_length))
};
let previous_verified_piece_count =
usize_from_u64(previous_completed_length / piece_length).min(piece_count);
let verified_piece_start = if previous_verified_piece_count == 0
|| group.piece_state(PieceId(u32_from_usize(
previous_verified_piece_count.saturating_sub(1),
))) == Some(PieceState::Verified)
{
previous_verified_piece_count
} else {
0
};
let should_complete = allow_terminal_complete
&& matches!(response.status, 200..=299)
&& total_length > 0
&& completed_length >= total_length;
let verified_piece_count = if should_complete {
piece_count
} else {
usize_from_u64(completed_length / piece_length)
}
.min(piece_count);
let next_status = if should_complete {
DownloadStatus::Complete
} else if is_retry_relevant_status(response.status) {
DownloadStatus::Waiting
} else if matches!(response.status, 200..=299)
|| (completed_length > 0 && completed_length < total_length)
{
DownloadStatus::Active
} else {
DownloadStatus::Error
};
group.set_piece_length(piece_length);
if total_length > 0 {
group.set_total_length(total_length);
}
group.set_completed_length(completed_length);
group.set_retry_count(retry_count);
group.set_num_connections(u32::from(max_connections));
group.set_download_speed(response_delta);
if matches!(response.status, 200..=299) && verified_piece_count > verified_piece_start {
for piece_index in verified_piece_start..verified_piece_count {
group.set_piece_state(
PieceId(u32_from_usize(piece_index)),
PieceState::Verified,
);
}
}
group.set_status(next_status);
next_status
};
if next_status == DownloadStatus::Complete {
self.engine
.complete(gid)
.map_err(|_| missing_download_error(gid))
} else {
Ok(())
}
}
/// Records a generic transport transfer result back into the engine state.
///
/// # Errors
///
/// Returns an error when `gid` is invalid or the download is no longer tracked.
pub fn record_transfer_result(
&mut self,
gid: &str,
total_length: u64,
completed_length: u64,
max_connections: u16,
success: bool,
retry_count: u32,
) -> Result<(), RpcError> {
let gid = parse_gid_text(gid)?;
let piece_length = self.engine.runtime().piece_length.max(1);
let piece_count = if total_length == 0 {
0_usize
} else {
usize_from_u64(total_length.div_ceil(piece_length))
};
{
let group = self
.engine
.handle_mut(gid)
.ok_or_else(|| missing_download_error(gid))?;
group.set_piece_length(piece_length);
group.set_total_length(total_length);
group.set_completed_length(completed_length);
group.set_retry_count(retry_count);
group.set_num_connections(u32::from(max_connections));
group.set_download_speed(completed_length);
if success {
for piece_index in 0..piece_count {
group.set_piece_state(
PieceId(u32_from_usize(piece_index)),
PieceState::Verified,
);
}
}
}
if success {
self.engine
.complete(gid)
.map_err(|_| missing_download_error(gid))
} else {
self.engine
.fail(gid)
.map_err(|_| missing_download_error(gid))
}
}
/// Handles `aria2.addUri` by validating RPC parameters and registering URI downloads.
pub(crate) fn handle_add_uri(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
let Some(first) = request.params.first() else {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params("aria2.addUri needs at least one uri"),
);
};
let uris = match parse_uri_list_param(first) {
Ok(uris) => uris,
Err(error) => {
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error));
}
};
let options = match parse_optional_option_object(request.params.get(1), "aria2.addUri") {
Ok(options) => options,
Err(error) => {
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error));
}
};
if let Err(error) = parse_optional_position(request.params.get(2), "aria2.addUri") {
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error));
}
match self.add_uri_direct(uris, options) {
Ok(gid) => JsonRpcResponse::success(request.id, RpcValue::String(gid)),
Err(error) => JsonRpcResponse::error(request.id, error),
}
}
/// Handles `aria2.addTorrent` by decoding torrent metadata and registering BT downloads.
pub(crate) fn handle_add_torrent(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
let Some(first) = request.params.first() else {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params("aria2.addTorrent needs torrent payload"),
);
};
if let Some(param) = request.params.get(1)
&& let Err(error) = parse_optional_uri_array(param, "aria2.addTorrent")
{
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error));
}
let options = match parse_optional_option_object(request.params.get(2), "aria2.addTorrent")
{
Ok(options) => options,
Err(error) => {
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error));
}
};
if let Err(error) = parse_optional_position(request.params.get(3), "aria2.addTorrent") {
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error));
}
let encoded = match first {
RpcValue::String(payload) => payload.clone(),
_ => {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params("torrent payload must be base64 string"),
);
}
};
let bytes = match base64::engine::general_purpose::STANDARD.decode(encoded.as_bytes()) {
Ok(bytes) => bytes,
Err(error) => {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params(&format!("invalid torrent base64 payload: {error}")),
);
}
};
let metadata = match parse_torrent_metadata(&bytes) {
Ok(metadata) => metadata,
Err(error) => {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params(&format!("invalid torrent metadata: {error}")),
);
}
};
let bt_state = build_bt_runtime_state(&metadata);
let primary_uri = bt_state
.trackers
.first()
.map(|tracker| tracker.url.clone())
.or_else(|| bt_state.magnet_uri.clone())
.unwrap_or_else(|| format!("bittorrent://{}", bt_state.info_hash));
let mut context = RequestContext::new(primary_uri);
context.source = Some("torrent".to_owned());
context.note = bt_state.name.clone();
let gid = self.engine.add_request(context).gid();
if let Some(group) = self.engine.handle_mut(gid) {
group.set_bt(bt_state);
group.set_total_length(metadata.total_length());
group.set_piece_length(metadata.info.piece_length);
for piece in &metadata.pieces {
group.set_piece_state(PieceId(piece.index), PieceState::Pending);
}
apply_group_options(group, options);
}
JsonRpcResponse::success(request.id, RpcValue::String(gid.to_string()))
}
/// Handles `aria2.addMetalink` by expanding actionable metalink files into downloads.
///
/// # Panics
///
/// Panics only if a metalink download plan entry contains no URI after plan validation.
pub(crate) fn handle_add_metalink(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
let Some(first) = request.params.first() else {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params("aria2.addMetalink needs metalink xml text"),
);
};
let options = match parse_optional_option_object(request.params.get(1), "aria2.addMetalink")
{
Ok(options) => options,
Err(error) => {
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error));
}
};
if let Err(error) = parse_optional_position(request.params.get(2), "aria2.addMetalink") {
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error));
}
let metalink_text = match first {
RpcValue::String(text) => decode_metalink_payload(text).unwrap_or_else(|| text.clone()),
_ => {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params("metalink payload must be string xml text"),
);
}
};
let document = match parse_metalink_document(&metalink_text) {
Ok(document) => document,
Err(error) => {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params(&format!("invalid metalink: {error}")),
);
}
};
let plan = metalink_download_plan(&document);
if plan.is_empty() {
return JsonRpcResponse::error(
request.id,
RpcError::invalid_params("metalink document contains no usable resource url"),
);
}
let gids = plan
.into_iter()
.map(|entry| {
let mut context = RequestContext::new(
entry
.uris
.first()
.cloned()
.expect("metalink download plan should contain at least one uri"),
);
context.replace_uris(entry.uris.clone());
let gid = self.engine.add_request(context).gid();
if let Some(group) = self.engine.handle_mut(gid) {
apply_group_options(group, metalink_default_options(&entry));
apply_group_options(group, options.clone());
}
RpcValue::String(gid.to_string())
})
.collect::<Vec<_>>();
JsonRpcResponse::success(request.id, RpcValue::Array(gids))
}
}
+641
View File
@@ -0,0 +1,641 @@
//! Method validation and compatibility-oriented RPC handler stubs.
#![expect(
clippy::needless_pass_by_value,
reason = "handler signatures intentionally mirror transport payloads and centralized compat wording"
)]
use std::collections::BTreeMap;
use aria2_rust_pro_compat::version_line;
use crate::{
jsonrpc::{
JsonRpcNotification, JsonRpcRequest, SYNTHETIC_INVALID_PARAMS_METHOD,
SYNTHETIC_INVALID_REQUEST_METHOD,
},
methods::{
RpcMethod, is_required_rpc_method, rpc_method, rpc_method_names, rpc_notification_names,
},
model::{RpcError, RpcErrorCode, RpcErrorKind, RpcMeta, RpcResultEnvelope, RpcValue},
xmlrpc::{
XmlRpcFault, XmlRpcMethodCall, XmlRpcMethodResponse, rpc_value_to_xmlrpc,
xmlrpc_value_to_rpc,
},
};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
/// Context passed into handler execution.
pub struct RpcHandlerContext {
/// Authentication state derived from the active transport.
pub auth: crate::model::RpcAuthContext,
/// Per-request metadata propagated through the stack.
pub meta: RpcMeta,
}
#[derive(Debug, Clone, Copy, Default)]
/// Registry of compatibility-oriented RPC handlers.
pub struct RpcHandlerRegistry;
impl RpcHandlerRegistry {
#[must_use]
/// Handles a JSON-RPC request and returns a normalized envelope.
pub fn handle_json(self, request: JsonRpcRequest, ctx: RpcHandlerContext) -> RpcResultEnvelope {
let _ = self;
let _ = ctx;
let synthetic_error_message = request
.params
.first()
.and_then(|value| match value {
RpcValue::String(message) => Some(message.as_str()),
_ => None,
})
.unwrap_or("Invalid Request.");
match request.method.as_str() {
SYNTHETIC_INVALID_REQUEST_METHOD => RpcResultEnvelope {
result: None,
error: Some(RpcError {
code: RpcErrorCode::InvalidRequest,
kind: RpcErrorKind::InvalidParams,
message: synthetic_error_message.to_owned(),
}),
},
SYNTHETIC_INVALID_PARAMS_METHOD => RpcResultEnvelope {
result: None,
error: Some(RpcError::invalid_params(synthetic_error_message)),
},
_ => Self::handle_method_request(request),
}
}
/// Routes a resolved method name through the compatibility stub table.
fn handle_method_request(request: JsonRpcRequest) -> RpcResultEnvelope {
let Some(method) = rpc_method(&request.method) else {
return unknown_or_stubbed_method(&request.method);
};
if let Some(error) = validate_method_params(method, &request.params) {
return error_envelope(error);
}
compatibility_response(method)
}
/// Handles a JSON-RPC notification.
pub fn handle_notification(self, _notification: JsonRpcNotification, _ctx: RpcHandlerContext) {
let _ = self;
}
#[must_use]
/// Handles an XML-RPC method call by reusing the JSON handler path.
pub fn handle_xml(
self,
request: XmlRpcMethodCall,
ctx: RpcHandlerContext,
) -> XmlRpcMethodResponse {
let meta = ctx.meta.clone();
let envelope = self.handle_json(
JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: request.method_name,
params: request
.params
.into_iter()
.map(|p| xmlrpc_value_to_rpc(p.value))
.collect(),
meta,
},
ctx,
);
match envelope.error {
Some(error) => XmlRpcMethodResponse {
value: None,
fault: Some(XmlRpcFault {
code: 1,
message: error.message.clone(),
error: Some(error),
}),
meta: RpcMeta::default(),
},
None => XmlRpcMethodResponse {
value: envelope.result.map(rpc_value_to_xmlrpc),
fault: None,
meta: RpcMeta::default(),
},
}
}
}
/// Produces the canned compatibility response for a resolved RPC method.
fn compatibility_response(method: RpcMethod) -> RpcResultEnvelope {
match method {
RpcMethod::Aria2AddUri
| RpcMethod::Aria2AddTorrent
| RpcMethod::Aria2AddMetalink
| RpcMethod::Aria2ChangeGlobalOption
| RpcMethod::Aria2ChangeOption
| RpcMethod::Aria2SaveSession
| RpcMethod::Aria2Shutdown
| RpcMethod::Aria2ForceShutdown => success_envelope(RpcValue::Null),
RpcMethod::Aria2Remove
| RpcMethod::Aria2ForceRemove
| RpcMethod::Aria2Pause
| RpcMethod::Aria2PauseAll
| RpcMethod::Aria2ForcePause
| RpcMethod::Aria2ForcePauseAll
| RpcMethod::Aria2Unpause
| RpcMethod::Aria2UnpauseAll => success_envelope(RpcValue::Bool(true)),
RpcMethod::Aria2TellStatus
| RpcMethod::Aria2TellGlobalStat
| RpcMethod::Aria2GetGlobalOption
| RpcMethod::Aria2GetOption
| RpcMethod::Aria2GetSessionInfo => success_envelope(RpcValue::Object(BTreeMap::new())),
RpcMethod::Aria2TellActive
| RpcMethod::Aria2TellWaiting
| RpcMethod::Aria2TellStopped
| RpcMethod::Aria2GetUris
| RpcMethod::Aria2GetFiles
| RpcMethod::Aria2GetPeers
| RpcMethod::Aria2GetServers
| RpcMethod::SystemMulticall => success_envelope(RpcValue::Array(Vec::new())),
RpcMethod::Aria2ChangeUri => success_envelope(RpcValue::Array(vec![
RpcValue::Number(0),
RpcValue::Number(0),
])),
RpcMethod::Aria2GetVersion => success_envelope(RpcValue::Object(BTreeMap::from([
("version".to_owned(), RpcValue::String(version_line())),
("rpcVersion".to_owned(), RpcValue::String("2.0".to_owned())),
]))),
RpcMethod::SystemListMethods => success_envelope(RpcValue::Array(
rpc_method_names()
.into_iter()
.map(|name| RpcValue::String(name.to_owned()))
.collect(),
)),
RpcMethod::SystemListNotifications => success_envelope(RpcValue::Array(
rpc_notification_names()
.into_iter()
.map(|name| RpcValue::String(name.to_owned()))
.collect(),
)),
_ => unknown_or_stubbed_method(method.as_str()),
}
}
/// Wraps a successful result payload in a normalized handler envelope.
const fn success_envelope(result: RpcValue) -> RpcResultEnvelope {
RpcResultEnvelope {
result: Some(result),
error: None,
}
}
/// Wraps an error payload in a normalized handler envelope.
const fn error_envelope(error: RpcError) -> RpcResultEnvelope {
RpcResultEnvelope {
result: None,
error: Some(error),
}
}
/// Distinguishes required-but-stubbed methods from truly unknown compatibility names.
fn unknown_or_stubbed_method(method: &str) -> RpcResultEnvelope {
if is_required_rpc_method(method) {
error_envelope(RpcError::unsupported("rpc method stubbed"))
} else {
error_envelope(RpcError::unknown_method(method))
}
}
/// Validates the documented positional parameter contract for compatibility methods.
fn validate_method_params(method: RpcMethod, params: &[RpcValue]) -> Option<RpcError> {
match method {
RpcMethod::SystemListMethods if !params.is_empty() => Some(RpcError::invalid_params(
"system.listMethods takes no parameters",
)),
RpcMethod::SystemListNotifications if !params.is_empty() => Some(RpcError::invalid_params(
"system.listNotifications takes no parameters",
)),
RpcMethod::Aria2GetVersion if !params.is_empty() => Some(RpcError::invalid_params(
"aria2.getVersion takes no parameters",
)),
RpcMethod::Aria2GetSessionInfo if !params.is_empty() => Some(RpcError::invalid_params(
"aria2.getSessionInfo takes no parameters",
)),
RpcMethod::Aria2TellGlobalStat if !params.is_empty() => Some(RpcError::invalid_params(
"aria2.getGlobalStat takes no parameters",
)),
RpcMethod::SystemMulticall if params.is_empty() => Some(RpcError::invalid_params(
"system.multicall requires method specs",
)),
RpcMethod::SystemMulticall if !matches!(params.first(), Some(RpcValue::Array(_))) => Some(
RpcError::invalid_params("system.multicall expected array of method specs"),
),
_ => None,
}
}
#[cfg(test)]
/// Unit tests for transport-neutral RPC handler behavior.
mod tests {
use super::*;
use crate::{
model::RpcAuthContext,
xmlrpc::{XmlRpcMember, XmlRpcParam, XmlRpcValue, xmlrpc_value_to_rpc},
};
/// Builds a default handler context for unit tests.
fn ctx() -> RpcHandlerContext {
RpcHandlerContext {
auth: RpcAuthContext::default(),
meta: RpcMeta::default(),
}
}
#[test]
/// Verifies that unknown `XML-RPC` methods return a shared fault payload.
fn handle_xml_unknown_method_returns_fault_with_error_payload() {
let registry = RpcHandlerRegistry;
let response = registry.handle_xml(
XmlRpcMethodCall {
method_name: "aria2.notFound".to_owned(),
params: Vec::new(),
meta: RpcMeta::default(),
},
ctx(),
);
assert!(response.value.is_none());
let fault = response.fault.expect("expected fault");
assert_eq!(fault.code, 1);
let expected = RpcError::unknown_method("aria2.notFound");
assert_eq!(fault.message, expected.message);
assert_eq!(fault.error, Some(expected));
}
#[test]
/// Verifies that nested `XML-RPC` values convert with the shared compatibility rules.
fn xml_param_conversion_handles_nested_values_with_shared_rules() {
let nested = XmlRpcValue::Struct(vec![XmlRpcMember {
name: "outer".to_owned(),
value: XmlRpcValue::Array(vec![
XmlRpcValue::Double(3.25),
XmlRpcValue::Base64(vec![0x41, 0x42]),
XmlRpcValue::Struct(vec![XmlRpcMember {
name: "inner".to_owned(),
value: XmlRpcValue::Bool(true),
}]),
]),
}]);
let expected = RpcValue::Object(BTreeMap::from([(
"outer".to_owned(),
RpcValue::Array(vec![
RpcValue::String("3.25".to_owned()),
RpcValue::String("QUI=".to_owned()),
RpcValue::Object(BTreeMap::from([("inner".to_owned(), RpcValue::Bool(true))])),
]),
)]));
assert_eq!(xmlrpc_value_to_rpc(nested), expected);
}
#[test]
/// Verifies that nested array and struct parameters are accepted for `XML-RPC` addUri.
fn handle_xml_accepts_nested_struct_and_array_params() {
let registry = RpcHandlerRegistry;
let response = registry.handle_xml(
XmlRpcMethodCall {
method_name: "aria2.addUri".to_owned(),
params: vec![XmlRpcParam {
value: XmlRpcValue::Array(vec![XmlRpcValue::Struct(vec![XmlRpcMember {
name: "k".to_owned(),
value: XmlRpcValue::Array(vec![XmlRpcValue::Int(1), XmlRpcValue::Nil]),
}])]),
}],
meta: RpcMeta::default(),
},
ctx(),
);
assert!(response.fault.is_none());
assert!(matches!(response.value, Some(XmlRpcValue::Nil)));
}
#[test]
/// Verifies that `system.listMethods` keeps the upstream public method ordering.
fn handle_json_list_methods_matches_upstream_order() {
let registry = RpcHandlerRegistry;
let response = registry.handle_json(
JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: "system.listMethods".to_owned(),
params: Vec::new(),
meta: RpcMeta::default(),
},
ctx(),
);
match response.result {
Some(RpcValue::Array(items)) => {
let names = items
.into_iter()
.map(|item| match item {
RpcValue::String(name) => name,
other => panic!("unexpected method item: {other:?}"),
})
.collect::<Vec<_>>();
assert_eq!(
names,
vec![
"aria2.addUri".to_owned(),
"aria2.addTorrent".to_owned(),
"aria2.getPeers".to_owned(),
"aria2.addMetalink".to_owned(),
"aria2.remove".to_owned(),
"aria2.pause".to_owned(),
"aria2.forcePause".to_owned(),
"aria2.pauseAll".to_owned(),
"aria2.forcePauseAll".to_owned(),
"aria2.unpause".to_owned(),
"aria2.unpauseAll".to_owned(),
"aria2.forceRemove".to_owned(),
"aria2.changePosition".to_owned(),
"aria2.tellStatus".to_owned(),
"aria2.getUris".to_owned(),
"aria2.getFiles".to_owned(),
"aria2.getServers".to_owned(),
"aria2.tellActive".to_owned(),
"aria2.tellWaiting".to_owned(),
"aria2.tellStopped".to_owned(),
"aria2.getOption".to_owned(),
"aria2.changeUri".to_owned(),
"aria2.changeOption".to_owned(),
"aria2.getGlobalOption".to_owned(),
"aria2.changeGlobalOption".to_owned(),
"aria2.purgeDownloadResult".to_owned(),
"aria2.removeDownloadResult".to_owned(),
"aria2.getVersion".to_owned(),
"aria2.getSessionInfo".to_owned(),
"aria2.shutdown".to_owned(),
"aria2.forceShutdown".to_owned(),
"aria2.getGlobalStat".to_owned(),
"aria2.saveSession".to_owned(),
"system.multicall".to_owned(),
"system.listMethods".to_owned(),
"system.listNotifications".to_owned(),
]
);
}
other => panic!("unexpected listMethods result: {other:?}"),
}
}
#[test]
/// Verifies that `system.listNotifications` returns the upstream aria2 names.
fn handle_json_list_notifications_matches_upstream_aria2_names() {
let registry = RpcHandlerRegistry;
let response = registry.handle_json(
JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: "system.listNotifications".to_owned(),
params: Vec::new(),
meta: RpcMeta::default(),
},
ctx(),
);
match response.result {
Some(RpcValue::Array(items)) => {
let names = items
.into_iter()
.map(|item| match item {
RpcValue::String(name) => name,
other => panic!("unexpected notification item: {other:?}"),
})
.collect::<Vec<_>>();
assert_eq!(
names,
vec![
"aria2.onDownloadStart".to_owned(),
"aria2.onDownloadPause".to_owned(),
"aria2.onDownloadStop".to_owned(),
"aria2.onDownloadComplete".to_owned(),
"aria2.onDownloadError".to_owned(),
"aria2.onBtDownloadComplete".to_owned(),
]
);
}
other => panic!("unexpected listNotifications result: {other:?}"),
}
}
#[test]
/// Verifies that `system.listMethods` rejects unexpected parameters.
fn handle_json_list_methods_rejects_unexpected_params() {
let registry = RpcHandlerRegistry;
let response = registry.handle_json(
JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: "system.listMethods".to_owned(),
params: vec![RpcValue::Bool(true)],
meta: RpcMeta::default(),
},
ctx(),
);
assert_eq!(
response.error,
Some(RpcError::invalid_params(
"system.listMethods takes no parameters"
))
);
}
#[test]
/// Verifies that `system.listNotifications` rejects unexpected parameters.
fn handle_json_list_notifications_rejects_unexpected_params() {
let registry = RpcHandlerRegistry;
let response = registry.handle_json(
JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: "system.listNotifications".to_owned(),
params: vec![RpcValue::Bool(true)],
meta: RpcMeta::default(),
},
ctx(),
);
assert_eq!(
response.error,
Some(RpcError::invalid_params(
"system.listNotifications takes no parameters"
))
);
}
#[test]
/// Verifies that `aria2.getVersion` rejects unexpected parameters.
fn handle_json_get_version_rejects_unexpected_params() {
let registry = RpcHandlerRegistry;
let response = registry.handle_json(
JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: "aria2.getVersion".to_owned(),
params: vec![RpcValue::String("token:abc".to_owned())],
meta: RpcMeta::default(),
},
ctx(),
);
assert_eq!(
response.error,
Some(RpcError::invalid_params(
"aria2.getVersion takes no parameters"
))
);
}
#[test]
/// Verifies that `system.multicall` rejects a missing method-spec array.
fn handle_json_multicall_rejects_missing_method_specs() {
let registry = RpcHandlerRegistry;
let response = registry.handle_json(
JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: "system.multicall".to_owned(),
params: Vec::new(),
meta: RpcMeta::default(),
},
ctx(),
);
assert_eq!(
response.error,
Some(RpcError::invalid_params(
"system.multicall requires method specs"
))
);
}
#[test]
/// Verifies that the legacy multicall alias still enforces array-shaped method specs.
fn handle_json_legacy_multicall_alias_rejects_non_array_method_specs() {
let registry = RpcHandlerRegistry;
let response = registry.handle_json(
JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: "aria2.multicall".to_owned(),
params: vec![RpcValue::Bool(true)],
meta: RpcMeta::default(),
},
ctx(),
);
assert_eq!(
response.error,
Some(RpcError::invalid_params(
"system.multicall expected array of method specs"
))
);
}
#[test]
/// Verifies that the synthetic invalid-request marker becomes a `JSON-RPC` invalid-request error.
fn handle_json_synthetic_invalid_request_returns_jsonrpc_invalid_request_error() {
let registry = RpcHandlerRegistry;
let response = registry.handle_json(
JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: Some(crate::jsonrpc::JsonRpcId::Null),
method: SYNTHETIC_INVALID_REQUEST_METHOD.to_owned(),
params: vec![RpcValue::String("Invalid Request.".to_owned())],
meta: RpcMeta::default(),
},
ctx(),
);
assert!(response.result.is_none());
assert_eq!(
response.error,
Some(RpcError {
code: RpcErrorCode::InvalidRequest,
kind: RpcErrorKind::InvalidParams,
message: "Invalid Request.".to_owned(),
})
);
}
#[test]
/// Verifies that the synthetic invalid-params marker becomes a `JSON-RPC` invalid-params error.
fn handle_json_synthetic_invalid_params_returns_jsonrpc_invalid_params_error() {
let registry = RpcHandlerRegistry;
let response = registry.handle_json(
JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: Some(crate::jsonrpc::JsonRpcId::String("q2".to_owned())),
method: SYNTHETIC_INVALID_PARAMS_METHOD.to_owned(),
params: vec![RpcValue::String("Invalid params.".to_owned())],
meta: RpcMeta::default(),
},
ctx(),
);
assert!(response.result.is_none());
assert_eq!(
response.error,
Some(RpcError {
code: RpcErrorCode::InvalidParams,
kind: RpcErrorKind::InvalidParams,
message: "Invalid params.".to_owned(),
})
);
}
#[test]
/// Verifies that `XML-RPC` listNotifications returns the prefixed aria2 event names.
fn handle_xml_list_notifications_returns_prefixed_names() {
let registry = RpcHandlerRegistry;
let response = registry.handle_xml(
XmlRpcMethodCall {
method_name: "system.listNotifications".to_owned(),
params: Vec::new(),
meta: RpcMeta::default(),
},
ctx(),
);
assert!(response.fault.is_none());
match response.value {
Some(XmlRpcValue::Array(items)) => {
let names = items
.into_iter()
.map(|item| match item {
XmlRpcValue::String(name) => name,
other => panic!("unexpected XML notification item: {other:?}"),
})
.collect::<Vec<_>>();
assert_eq!(
names,
vec![
"aria2.onDownloadStart".to_owned(),
"aria2.onDownloadPause".to_owned(),
"aria2.onDownloadStop".to_owned(),
"aria2.onDownloadComplete".to_owned(),
"aria2.onDownloadError".to_owned(),
"aria2.onBtDownloadComplete".to_owned(),
]
);
}
other => panic!("unexpected XML listNotifications result: {other:?}"),
}
}
}
+547
View File
@@ -0,0 +1,547 @@
//! JSON-RPC request and response types plus serde helpers.
#![expect(
clippy::redundant_pub_crate,
reason = "JSON-RPC model type names stay intentionally explicit for transport parity"
)]
use std::collections::BTreeMap;
use serde_json::{Map, Number, Value};
use crate::model::{RpcError, RpcMeta, RpcValue};
/// Synthetic method marker used when malformed payloads need an invalid-request envelope.
pub(super) const SYNTHETIC_INVALID_REQUEST_METHOD: &str = "__aria2_rust_pro.invalid_request__";
/// Synthetic method marker used when malformed payloads need an invalid-params envelope.
pub(super) const SYNTHETIC_INVALID_PARAMS_METHOD: &str = "__aria2_rust_pro.invalid_params__";
#[derive(Debug, Clone, PartialEq)]
/// JSON-RPC id values accepted by the compatibility surface.
pub enum JsonRpcId {
/// Explicit JSON `null` id.
Null,
/// Boolean id.
Bool(bool),
/// Integer id.
Number(i64),
/// String id.
String(String),
/// Structured array id retained for compatibility.
Array(Vec<RpcValue>),
/// Structured object id retained for compatibility.
Object(BTreeMap<String, RpcValue>),
}
#[derive(Debug, Clone, PartialEq)]
/// Canonical JSON-RPC request model used by the dispatcher.
pub struct JsonRpcRequest {
/// Advertised JSON-RPC version.
pub jsonrpc: Option<String>,
/// Request identifier, if one was provided.
pub id: Option<JsonRpcId>,
/// Requested method name.
pub method: String,
/// Positional parameters.
pub params: Vec<RpcValue>,
/// Supplemental per-request metadata.
pub meta: RpcMeta,
}
impl JsonRpcRequest {
#[must_use]
/// Creates an empty JSON-RPC 2.0 request for the given method name.
pub fn new(method: impl Into<String>) -> Self {
Self {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: method.into(),
params: Vec::new(),
meta: RpcMeta::default(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
/// Canonical JSON-RPC notification model.
pub struct JsonRpcNotification {
/// Advertised JSON-RPC version.
pub jsonrpc: Option<String>,
/// Notification method name.
pub method: String,
/// Positional parameters.
pub params: Vec<RpcValue>,
/// Supplemental per-request metadata.
pub meta: RpcMeta,
}
#[derive(Debug, Clone, PartialEq)]
/// Canonical JSON-RPC response model.
pub struct JsonRpcResponse {
/// Advertised JSON-RPC version.
pub jsonrpc: Option<String>,
/// Echoed request identifier.
pub id: Option<JsonRpcId>,
/// Successful result payload, if any.
pub result: Option<RpcValue>,
/// Error payload, if the request failed.
pub error: Option<RpcError>,
/// Supplemental metadata attached to the response.
pub meta: RpcMeta,
}
impl JsonRpcResponse {
#[must_use]
/// Creates a successful JSON-RPC response.
pub fn success(id: Option<JsonRpcId>, value: RpcValue) -> Self {
Self {
jsonrpc: Some("2.0".to_owned()),
id,
result: Some(value),
error: None,
meta: RpcMeta::default(),
}
}
#[must_use]
/// Creates an error JSON-RPC response.
pub fn error(id: Option<JsonRpcId>, error: RpcError) -> Self {
Self {
jsonrpc: Some("2.0".to_owned()),
id,
result: None,
error: Some(error),
meta: RpcMeta::default(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
/// Parsed top-level JSON-RPC payload.
pub(super) enum JsonRpcPayload {
/// Single request payload.
Single(JsonRpcRequest),
/// Batch payload retaining per-item parse failures.
Batch(Vec<Result<JsonRpcRequest, String>>),
}
/// Converts a transport-neutral RPC value into a `serde_json::Value`.
fn rpc_value_to_json(value: &RpcValue) -> Value {
match value {
RpcValue::Null => Value::Null,
RpcValue::Bool(value) => Value::Bool(*value),
RpcValue::Number(value) => Value::Number(Number::from(*value)),
RpcValue::String(value) => Value::String(value.clone()),
RpcValue::Array(values) => Value::Array(values.iter().map(rpc_value_to_json).collect()),
RpcValue::Object(values) => Value::Object(
values
.iter()
.map(|(key, value)| (key.clone(), rpc_value_to_json(value)))
.collect::<Map<String, Value>>(),
),
}
}
/// Converts a parsed JSON value into the transport-neutral RPC value tree.
fn json_value_to_rpc(value: Value) -> RpcValue {
match value {
Value::Null => RpcValue::Null,
Value::Bool(value) => RpcValue::Bool(value),
Value::Number(value) => RpcValue::Number(value.as_i64().unwrap_or_default()),
Value::String(value) => RpcValue::String(value),
Value::Array(values) => {
RpcValue::Array(values.into_iter().map(json_value_to_rpc).collect())
}
Value::Object(values) => RpcValue::Object(
values
.into_iter()
.map(|(key, value)| (key, json_value_to_rpc(value)))
.collect(),
),
}
}
/// Parses an `id` member into the compatibility-layer `JsonRpcId` shape.
fn parse_jsonrpc_id(value: &Value) -> Option<JsonRpcId> {
match value {
Value::Null => Some(JsonRpcId::Null),
Value::Bool(value) => Some(JsonRpcId::Bool(*value)),
Value::Number(value) => value.as_i64().map(JsonRpcId::Number),
Value::String(value) => Some(JsonRpcId::String(value.clone())),
Value::Array(values) => Some(JsonRpcId::Array(
values.iter().cloned().map(json_value_to_rpc).collect(),
)),
Value::Object(values) => Some(JsonRpcId::Object(
values
.iter()
.map(|(key, value)| (key.clone(), json_value_to_rpc(value.clone())))
.collect(),
)),
}
}
/// Converts an internal JSON-RPC id back into `serde_json` form for rendering.
fn jsonrpc_id_to_json(id: &JsonRpcId) -> Value {
match id {
JsonRpcId::Null => Value::Null,
JsonRpcId::Bool(value) => Value::Bool(*value),
JsonRpcId::Number(value) => Value::Number(Number::from(*value)),
JsonRpcId::String(value) => Value::String(value.clone()),
JsonRpcId::Array(values) => Value::Array(values.iter().map(rpc_value_to_json).collect()),
JsonRpcId::Object(values) => Value::Object(
values
.iter()
.map(|(key, value)| (key.clone(), rpc_value_to_json(value)))
.collect(),
),
}
}
/// Builds a synthetic request carrying a parse-time protocol error.
fn synthetic_error_request(
method: &'static str,
id: Option<JsonRpcId>,
message: impl Into<String>,
jsonrpc: Option<String>,
) -> JsonRpcRequest {
JsonRpcRequest {
jsonrpc,
id,
method: method.to_owned(),
params: vec![RpcValue::String(message.into())],
meta: RpcMeta::default(),
}
}
/// Builds the canonical invalid-request synthetic request wrapper.
fn invalid_request(id: Option<JsonRpcId>, jsonrpc: Option<String>) -> JsonRpcRequest {
synthetic_error_request(
SYNTHETIC_INVALID_REQUEST_METHOD,
id,
"Invalid Request.",
jsonrpc,
)
}
/// Builds the canonical invalid-parameters synthetic request wrapper.
fn invalid_params(id: Option<JsonRpcId>, jsonrpc: Option<String>) -> JsonRpcRequest {
synthetic_error_request(
SYNTHETIC_INVALID_PARAMS_METHOD,
id,
"Invalid params.",
jsonrpc,
)
}
/// Parses a request object while normalizing malformed members into synthetic requests.
fn jsonrpc_request_from_object(mut object: Map<String, Value>) -> JsonRpcRequest {
let has_id = object.contains_key("id");
let id = object.get("id").and_then(parse_jsonrpc_id);
let jsonrpc = object
.get("jsonrpc")
.and_then(Value::as_str)
.map(str::to_owned);
if !has_id {
return invalid_request(Some(JsonRpcId::Null), jsonrpc);
}
let Some(id) = id else {
return invalid_request(Some(JsonRpcId::Null), jsonrpc);
};
let method = object
.get("method")
.and_then(Value::as_str)
.map(str::to_owned);
let Some(method) = method else {
return invalid_request(Some(id), jsonrpc);
};
let params = match object.remove("params") {
Some(Value::Array(values)) => values.into_iter().map(json_value_to_rpc).collect(),
Some(_) => return invalid_params(Some(id), jsonrpc),
None => Vec::new(),
};
JsonRpcRequest {
jsonrpc,
id: Some(id),
method,
params,
meta: RpcMeta::default(),
}
}
/// Normalizes any JSON value into a request-shaped compatibility payload.
fn jsonrpc_request_from_value(value: Value) -> JsonRpcRequest {
match value {
Value::Object(object) => jsonrpc_request_from_object(object),
_ => invalid_request(Some(JsonRpcId::Null), Some("2.0".to_owned())),
}
}
/// Parses a single JSON-RPC request from raw JSON text.
///
/// # Errors
///
/// Returns an error when `json` is not valid JSON text.
pub fn jsonrpc_request_from_json(json: &str) -> Result<JsonRpcRequest, String> {
let value: Value = serde_json::from_str(json).map_err(|error| error.to_string())?;
Ok(jsonrpc_request_from_value(value))
}
/// Parses either a single or batch JSON-RPC payload from raw JSON text.
///
/// # Errors
///
/// Returns an error when `json` is not valid JSON text.
pub(super) fn jsonrpc_payload_from_json(json: &str) -> Result<JsonRpcPayload, String> {
let value: Value = serde_json::from_str(json).map_err(|error| error.to_string())?;
match value {
Value::Array(items) => Ok(JsonRpcPayload::Batch(
items
.into_iter()
.filter_map(|item| match item {
Value::Object(object) => Some(Ok(jsonrpc_request_from_object(object))),
_ => None,
})
.collect(),
)),
other => Ok(JsonRpcPayload::Single(jsonrpc_request_from_value(other))),
}
}
/// Renders a JSON-RPC response to a compact JSON string.
///
/// # Errors
///
/// Returns an error when the response cannot be serialized to JSON text.
pub(super) fn jsonrpc_response_to_json(response: &JsonRpcResponse) -> Result<String, String> {
serde_json::to_string(&jsonrpc_response_to_value(response)).map_err(|error| error.to_string())
}
/// Renders a JSON-RPC response to a `serde_json` value without a text round-trip.
fn jsonrpc_response_to_value(response: &JsonRpcResponse) -> Value {
let mut object = Map::new();
object.insert(
"jsonrpc".to_owned(),
Value::String(response.jsonrpc.clone().unwrap_or_else(|| "2.0".to_owned())),
);
object.insert(
"id".to_owned(),
response.id.as_ref().map_or(Value::Null, jsonrpc_id_to_json),
);
if let Some(result) = &response.result {
object.insert("result".to_owned(), rpc_value_to_json(result));
}
if let Some(error) = &response.error {
object.insert(
"error".to_owned(),
Value::Object(Map::from_iter([
(
"code".to_owned(),
Value::Number(Number::from(match error.code {
crate::model::RpcErrorCode::ParseError => -32700_i32,
crate::model::RpcErrorCode::InvalidRequest => -32600_i32,
crate::model::RpcErrorCode::MethodNotFound => -32601_i32,
crate::model::RpcErrorCode::InvalidParams => -32602_i32,
crate::model::RpcErrorCode::InternalError => -32603_i32,
crate::model::RpcErrorCode::ApplicationError => -32000_i32,
})),
),
("message".to_owned(), Value::String(error.message.clone())),
])),
);
}
Value::Object(object)
}
/// Renders a batch of JSON-RPC responses to a compact JSON array string.
///
/// # Errors
///
/// Returns an error when any response cannot be serialized to JSON text.
pub(super) fn jsonrpc_batch_response_to_json(
responses: &[JsonRpcResponse],
) -> Result<String, String> {
let rendered = responses
.iter()
.map(jsonrpc_response_to_value)
.collect::<Vec<_>>();
serde_json::to_string(&Value::Array(rendered)).map_err(|error| error.to_string())
}
#[cfg(test)]
/// Unit tests for `JSON-RPC` parsing and rendering helpers.
mod tests {
use super::*;
use crate::model::{RpcErrorCode, RpcErrorKind};
#[test]
/// Verifies that array parameters parse into the normalized request model.
fn parses_jsonrpc_request_with_array_params() {
let request = jsonrpc_request_from_json(
r#"{"jsonrpc":"2.0","id":"q1","method":"aria2.tellStatus","params":["token:abc","2089b05ecca3d829"]}"#,
)
.expect("request should parse");
assert_eq!(request.jsonrpc.as_deref(), Some("2.0"));
assert_eq!(request.id, Some(JsonRpcId::String("q1".to_owned())));
assert_eq!(request.method, "aria2.tellStatus");
assert_eq!(request.params.len(), 2);
}
#[test]
/// Verifies that boolean and structured ids round-trip into the compatibility id surface.
fn parses_jsonrpc_request_with_boolean_and_structured_id() {
let boolean_id = jsonrpc_request_from_json(
r#"{"jsonrpc":"2.0","id":true,"method":"aria2.getVersion","params":[]}"#,
)
.expect("boolean id request should parse");
assert_eq!(boolean_id.id, Some(JsonRpcId::Bool(true)));
let object_id = jsonrpc_request_from_json(
r#"{"jsonrpc":"2.0","id":{"client":"ui","seq":3},"method":"aria2.getVersion","params":[]}"#,
)
.expect("object id request should parse");
assert_eq!(
object_id.id,
Some(JsonRpcId::Object(BTreeMap::from([
("client".to_owned(), RpcValue::String("ui".to_owned())),
("seq".to_owned(), RpcValue::Number(3)),
])))
);
}
#[test]
/// Verifies that a missing request id becomes the synthetic invalid-request wrapper.
fn missing_jsonrpc_id_becomes_synthetic_invalid_request() {
let request = jsonrpc_request_from_json(
r#"{"jsonrpc":"2.0","method":"aria2.getVersion","params":[]}"#,
)
.expect("missing id request should still parse");
assert_eq!(request.id, Some(JsonRpcId::Null));
assert_eq!(request.method, SYNTHETIC_INVALID_REQUEST_METHOD);
assert_eq!(
request.params,
vec![RpcValue::String("Invalid Request.".to_owned())]
);
}
#[test]
/// Verifies that named parameters become the synthetic invalid-params wrapper.
fn named_params_become_synthetic_invalid_params_request() {
let request = jsonrpc_request_from_json(
r#"{"jsonrpc":"2.0","id":"q2","method":"aria2.getVersion","params":{"gid":"abc"}}"#,
)
.expect("named params request should still parse");
assert_eq!(request.id, Some(JsonRpcId::String("q2".to_owned())));
assert_eq!(request.method, SYNTHETIC_INVALID_PARAMS_METHOD);
assert_eq!(
request.params,
vec![RpcValue::String("Invalid params.".to_owned())]
);
}
#[test]
/// Verifies that error responses render into the expected compact `JSON-RPC` shape.
fn renders_jsonrpc_error_response() {
let json = jsonrpc_response_to_json(&JsonRpcResponse {
jsonrpc: Some("2.0".to_owned()),
id: Some(JsonRpcId::Number(7)),
result: None,
error: Some(RpcError {
code: RpcErrorCode::InvalidParams,
kind: RpcErrorKind::InvalidParams,
message: "bad params".to_owned(),
}),
meta: RpcMeta::default(),
})
.expect("response should render");
assert!(json.contains("\"jsonrpc\":\"2.0\""));
assert!(json.contains("\"id\":7"));
assert!(json.contains("\"code\":-32602"));
assert!(json.contains("\"message\":\"bad params\""));
}
#[test]
/// Verifies that batch payloads preserve per-item request parsing.
fn parses_jsonrpc_batch_payload() {
let payload = jsonrpc_payload_from_json(
r#"[{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]},{"jsonrpc":"2.0","method":"aria2.tellActive","params":[]}]"#,
)
.expect("batch payload should parse");
match payload {
JsonRpcPayload::Batch(items) => {
let [first, second] = items.as_slice() else {
panic!("expected exactly two batch items, got {}", items.len());
};
assert!(first.as_ref().is_ok_and(|request| request.id.is_some()));
assert!(
second
.as_ref()
.is_ok_and(|request| request.id == Some(JsonRpcId::Null))
);
}
other @ JsonRpcPayload::Single(_) => {
panic!("expected batch payload, got {other:?}")
}
}
}
#[test]
/// Verifies that an empty batch parses as an empty batch payload.
fn parses_empty_jsonrpc_batch_payload_as_empty_batch() {
let payload = jsonrpc_payload_from_json("[]").expect("empty batch should parse");
assert_eq!(payload, JsonRpcPayload::Batch(Vec::new()));
}
#[test]
/// Verifies that non-object batch members are ignored like upstream aria2.
fn batch_payload_ignores_non_object_members_like_upstream_aria2() {
let payload = jsonrpc_payload_from_json(
r#"[{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]},7,true]"#,
)
.expect("batch payload should parse");
match payload {
JsonRpcPayload::Batch(items) => {
let [first] = items.as_slice() else {
panic!("expected exactly one batch item, got {}", items.len());
};
assert!(
first
.as_ref()
.is_ok_and(|request| request.id == Some(JsonRpcId::Number(1)))
);
}
other @ JsonRpcPayload::Single(_) => {
panic!("expected batch payload, got {other:?}")
}
}
}
#[test]
/// Verifies that batch responses render as a compact JSON array.
fn renders_jsonrpc_batch_response() {
let json = jsonrpc_batch_response_to_json(&[
JsonRpcResponse::success(
Some(JsonRpcId::Number(1)),
RpcValue::String("ok".to_owned()),
),
JsonRpcResponse::error(
Some(JsonRpcId::Number(2)),
RpcError {
code: RpcErrorCode::InvalidRequest,
kind: RpcErrorKind::InvalidParams,
message: "bad request".to_owned(),
},
),
])
.expect("batch response should render");
assert!(json.starts_with('['));
assert!(json.contains("\"id\":1"));
assert!(json.contains("\"id\":2"));
assert!(json.contains("\"bad request\""));
}
}
+70
View File
@@ -0,0 +1,70 @@
//! RPC compatibility surface for the `aria2-rust-pro` workspace.
//!
//! This crate owns request parsing, transport-neutral routing, JSON-RPC and
//! XML-RPC compatibility types, and the in-process dispatcher used by the
//! server-facing crates.
#![expect(
clippy::multiple_crate_versions,
reason = "workspace dependency resolution is shared across crates and not owned by rpc alone"
)]
#![forbid(unsafe_code)]
#[cfg(test)]
use aria2_rust_pro_storage as _;
/// In-process RPC dispatcher backed by the download engine.
pub(crate) mod dispatcher;
/// RPC method registry and request handlers.
pub(crate) mod handlers;
/// JSON-RPC request and response types plus serde helpers.
pub(crate) mod jsonrpc;
/// Canonical RPC method metadata.
pub(crate) mod methods;
/// Shared RPC value, error, and metadata model types.
pub(crate) mod model;
/// Transport-neutral request routing helpers.
pub(crate) mod router;
/// Minimal HTTP and WebSocket server glue for the RPC surface.
pub(crate) mod server;
/// Session and authentication token state.
pub(crate) mod session;
/// WebSocket notification fan-out helpers.
pub(crate) mod websocket;
/// XML-RPC request and response types plus parser/renderer helpers.
pub(crate) mod xmlrpc;
/// Re-exports of the in-process dispatcher and lightweight internal summaries.
pub use dispatcher::{InProcessRpcDispatcher, RpcStatusSummary};
/// Re-exports of the primary JSON-RPC message types and testable wire codecs.
pub use jsonrpc::{
JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, jsonrpc_request_from_json,
};
/// Re-exports of the RPC method catalog and lookup helpers.
pub use methods::{
REQUIRED_RPC_METHODS, RPC_METHOD_LEDGER, RpcMethod, is_required_rpc_method, rpc_method_names,
};
/// Re-exports of the shared RPC model types.
pub use model::{
RpcAuthContext, RpcError, RpcErrorCode, RpcErrorKind, RpcMeta, RpcOptionMap, RpcResultEnvelope,
RpcValue,
};
/// Re-exports of the transport-neutral router types.
pub use router::{RpcDispatchRequest, RpcDispatchResult, RpcRouter};
/// Re-exports of the server configuration and entrypoints.
pub use server::{
RpcListenerStub, RpcServerConfig, RpcServerTransport, RpcServerTransportConfig,
serve_rpc_listener,
};
/// Re-exports of RPC session state types.
pub use session::{RpcAuthToken, RpcSession, RpcSessionInfo, RpcSessionStore};
/// Re-exports of WebSocket notification types and registries.
pub use websocket::{
RpcNotificationEvent, RpcNotificationKind, RpcWebSocketFrame, WebSocketNotificationRegistry,
WebSocketSessionRegistry, WebSocketSessionState, WebSocketSubscription,
};
/// Re-exports of XML-RPC model types and wire codec helpers.
pub use xmlrpc::{
XmlRpcFault, XmlRpcMember, XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, XmlRpcValue,
rpc_value_to_xmlrpc, xmlrpc_method_call_from_xml, xmlrpc_method_call_to_xml,
xmlrpc_method_response_from_xml, xmlrpc_method_response_to_xml, xmlrpc_value_to_rpc,
};
+337
View File
@@ -0,0 +1,337 @@
//! Canonical RPC method catalog and lookup helpers.
#![expect(
clippy::redundant_pub_crate,
reason = "method-catalog helpers stay crate-internal while retaining explicit visibilities"
)]
#[cfg(test)]
use crate::model::RpcValue;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
/// RPC methods supported or reserved by the aria2-compatible surface.
pub enum RpcMethod {
/// Adds one or more download URIs.
Aria2AddUri,
/// Adds a torrent payload.
Aria2AddTorrent,
/// Adds a metalink payload.
Aria2AddMetalink,
/// Removes a download.
Aria2Remove,
/// Force-removes a download.
Aria2ForceRemove,
/// Pauses a download.
Aria2Pause,
/// Pauses all active downloads.
Aria2PauseAll,
/// Force-pauses a download.
Aria2ForcePause,
/// Force-pauses all active downloads.
Aria2ForcePauseAll,
/// Resumes a paused download.
Aria2Unpause,
/// Resumes all paused downloads.
Aria2UnpauseAll,
/// Returns status for a single download.
Aria2TellStatus,
/// Returns active downloads.
Aria2TellActive,
/// Returns waiting downloads.
Aria2TellWaiting,
/// Returns stopped downloads.
Aria2TellStopped,
/// Returns URIs for a download.
Aria2GetUris,
/// Returns files for a download.
Aria2GetFiles,
/// Returns peers for a `BitTorrent` download.
Aria2GetPeers,
/// Returns servers for a download.
Aria2GetServers,
/// Changes queue position for a download.
Aria2ChangePosition,
/// Changes URIs attached to a download.
Aria2ChangeUri,
/// Purges completed download results.
Aria2PurgeDownloadResult,
/// Removes one completed download result.
Aria2RemoveDownloadResult,
/// Returns aggregate global statistics.
Aria2TellGlobalStat,
/// Returns per-download options.
Aria2GetOption,
/// Changes per-download options.
Aria2ChangeOption,
/// Returns global options.
Aria2GetGlobalOption,
/// Changes global options.
Aria2ChangeGlobalOption,
/// Returns version and enabled feature metadata.
Aria2GetVersion,
/// Returns session metadata.
Aria2GetSessionInfo,
/// Persists the current session.
Aria2SaveSession,
/// Requests graceful shutdown.
Aria2Shutdown,
/// Requests forced shutdown.
Aria2ForceShutdown,
/// Executes a batch of nested method calls.
SystemMulticall,
/// Returns the method catalog.
SystemListMethods,
/// Returns the notification catalog.
SystemListNotifications,
}
impl RpcMethod {
/// Returns the canonical method name exposed on the wire.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Aria2AddUri => "aria2.addUri",
Self::Aria2AddTorrent => "aria2.addTorrent",
Self::Aria2AddMetalink => "aria2.addMetalink",
Self::Aria2Remove => "aria2.remove",
Self::Aria2ForceRemove => "aria2.forceRemove",
Self::Aria2Pause => "aria2.pause",
Self::Aria2PauseAll => "aria2.pauseAll",
Self::Aria2ForcePause => "aria2.forcePause",
Self::Aria2ForcePauseAll => "aria2.forcePauseAll",
Self::Aria2Unpause => "aria2.unpause",
Self::Aria2UnpauseAll => "aria2.unpauseAll",
Self::Aria2TellStatus => "aria2.tellStatus",
Self::Aria2TellActive => "aria2.tellActive",
Self::Aria2TellWaiting => "aria2.tellWaiting",
Self::Aria2TellStopped => "aria2.tellStopped",
Self::Aria2GetUris => "aria2.getUris",
Self::Aria2GetFiles => "aria2.getFiles",
Self::Aria2GetPeers => "aria2.getPeers",
Self::Aria2GetServers => "aria2.getServers",
Self::Aria2ChangePosition => "aria2.changePosition",
Self::Aria2ChangeUri => "aria2.changeUri",
Self::Aria2PurgeDownloadResult => "aria2.purgeDownloadResult",
Self::Aria2RemoveDownloadResult => "aria2.removeDownloadResult",
Self::Aria2TellGlobalStat => "aria2.getGlobalStat",
Self::Aria2GetOption => "aria2.getOption",
Self::Aria2ChangeOption => "aria2.changeOption",
Self::Aria2GetGlobalOption => "aria2.getGlobalOption",
Self::Aria2ChangeGlobalOption => "aria2.changeGlobalOption",
Self::Aria2GetVersion => "aria2.getVersion",
Self::Aria2GetSessionInfo => "aria2.getSessionInfo",
Self::Aria2SaveSession => "aria2.saveSession",
Self::Aria2Shutdown => "aria2.shutdown",
Self::Aria2ForceShutdown => "aria2.forceShutdown",
Self::SystemMulticall => "system.multicall",
Self::SystemListMethods => "system.listMethods",
Self::SystemListNotifications => "system.listNotifications",
}
}
/// Returns accepted legacy aliases for compatibility handling.
#[must_use]
pub const fn legacy_aliases(self) -> &'static [&'static str] {
match self {
Self::Aria2TellGlobalStat => &["aria2.tellGlobalStat"],
Self::SystemMulticall => &["aria2.multicall"],
_ => &[],
}
}
}
/// Canonical method ledger in the order exposed to compatibility consumers.
pub const RPC_METHOD_LEDGER: &[RpcMethod] = &[
RpcMethod::Aria2AddUri,
RpcMethod::Aria2AddTorrent,
RpcMethod::Aria2GetPeers,
RpcMethod::Aria2AddMetalink,
RpcMethod::Aria2Remove,
RpcMethod::Aria2Pause,
RpcMethod::Aria2ForcePause,
RpcMethod::Aria2PauseAll,
RpcMethod::Aria2ForcePauseAll,
RpcMethod::Aria2Unpause,
RpcMethod::Aria2UnpauseAll,
RpcMethod::Aria2ForceRemove,
RpcMethod::Aria2ChangePosition,
RpcMethod::Aria2TellStatus,
RpcMethod::Aria2GetUris,
RpcMethod::Aria2GetFiles,
RpcMethod::Aria2GetServers,
RpcMethod::Aria2TellActive,
RpcMethod::Aria2TellWaiting,
RpcMethod::Aria2TellStopped,
RpcMethod::Aria2GetOption,
RpcMethod::Aria2ChangeUri,
RpcMethod::Aria2ChangeOption,
RpcMethod::Aria2GetGlobalOption,
RpcMethod::Aria2ChangeGlobalOption,
RpcMethod::Aria2PurgeDownloadResult,
RpcMethod::Aria2RemoveDownloadResult,
RpcMethod::Aria2GetVersion,
RpcMethod::Aria2GetSessionInfo,
RpcMethod::Aria2Shutdown,
RpcMethod::Aria2ForceShutdown,
RpcMethod::Aria2TellGlobalStat,
RpcMethod::Aria2SaveSession,
RpcMethod::SystemMulticall,
RpcMethod::SystemListMethods,
RpcMethod::SystemListNotifications,
];
/// Required upstream-compatible method names that should resolve distinctly.
pub const REQUIRED_RPC_METHODS: &[&str] = &[
"aria2.addUri",
"aria2.addTorrent",
"aria2.getPeers",
"aria2.addMetalink",
"aria2.remove",
"aria2.pause",
"aria2.forcePause",
"aria2.pauseAll",
"aria2.forcePauseAll",
"aria2.unpause",
"aria2.unpauseAll",
"aria2.forceRemove",
"aria2.changePosition",
"aria2.tellStatus",
"aria2.getUris",
"aria2.getFiles",
"aria2.getServers",
"aria2.tellActive",
"aria2.tellWaiting",
"aria2.tellStopped",
"aria2.getOption",
"aria2.changeUri",
"aria2.changeOption",
"aria2.getGlobalOption",
"aria2.changeGlobalOption",
"aria2.purgeDownloadResult",
"aria2.removeDownloadResult",
"aria2.getVersion",
"aria2.getSessionInfo",
"aria2.shutdown",
"aria2.forceShutdown",
"aria2.getGlobalStat",
"aria2.saveSession",
"system.multicall",
"system.listMethods",
"system.listNotifications",
];
/// Canonical WebSocket notification names in upstream-compatible order.
const RPC_NOTIFICATION_NAMES: &[&str] = &[
"aria2.onDownloadStart",
"aria2.onDownloadPause",
"aria2.onDownloadStop",
"aria2.onDownloadComplete",
"aria2.onDownloadError",
"aria2.onBtDownloadComplete",
];
/// Returns whether a method name is part of the required compatibility set.
#[must_use]
pub fn is_required_rpc_method(method: &str) -> bool {
REQUIRED_RPC_METHODS.contains(&method)
}
/// Resolves a method name or legacy alias into the canonical method variant.
#[must_use]
pub(super) fn rpc_method(name: &str) -> Option<RpcMethod> {
RPC_METHOD_LEDGER
.iter()
.copied()
.find(|method| method.as_str() == name || method.legacy_aliases().contains(&name))
}
/// Returns the canonical RPC method names in ledger order.
#[must_use]
pub fn rpc_method_names() -> Vec<&'static str> {
RPC_METHOD_LEDGER
.iter()
.copied()
.map(RpcMethod::as_str)
.collect()
}
/// Returns the canonical WebSocket notification method names.
#[must_use]
pub(super) fn rpc_notification_names() -> Vec<&'static str> {
RPC_NOTIFICATION_NAMES.to_vec()
}
/// Builds an RPC value containing the canonical method name list.
#[must_use]
#[cfg(test)]
fn method_name_value_list() -> RpcValue {
RpcValue::Array(
rpc_method_names()
.into_iter()
.map(|m| RpcValue::String(m.to_owned()))
.collect(),
)
}
#[cfg(test)]
/// Unit tests for the public RPC method and notification catalog.
mod tests {
use super::{
RpcMethod, method_name_value_list, rpc_method, rpc_method_names, rpc_notification_names,
};
use crate::model::RpcValue;
#[test]
/// Verifies that legacy aliases resolve internally without leaking into public catalogs.
fn legacy_aliases_resolve_but_do_not_leak_into_public_method_names() {
assert_eq!(
rpc_method("aria2.tellGlobalStat"),
Some(RpcMethod::Aria2TellGlobalStat)
);
assert_eq!(
rpc_method("aria2.multicall"),
Some(RpcMethod::SystemMulticall)
);
let method_names = rpc_method_names();
assert!(method_names.contains(&"aria2.getGlobalStat"));
assert!(method_names.contains(&"system.multicall"));
assert!(!method_names.contains(&"aria2.tellGlobalStat"));
assert!(!method_names.contains(&"aria2.multicall"));
}
#[test]
/// Verifies that public notification names keep the upstream aria2 order.
fn public_notification_names_keep_upstream_order() {
assert_eq!(
rpc_notification_names(),
vec![
"aria2.onDownloadStart",
"aria2.onDownloadPause",
"aria2.onDownloadStop",
"aria2.onDownloadComplete",
"aria2.onDownloadError",
"aria2.onBtDownloadComplete",
]
);
}
#[test]
/// Verifies that `system.listMethods` emits only public method names.
fn method_name_value_list_uses_public_names_only() {
match method_name_value_list() {
RpcValue::Array(values) => {
let names = values
.into_iter()
.map(|value| match value {
RpcValue::String(name) => name,
other => panic!("unexpected method value: {other:?}"),
})
.collect::<Vec<_>>();
assert!(names.iter().any(|name| name == "aria2.getGlobalStat"));
assert!(!names.iter().any(|name| name == "aria2.tellGlobalStat"));
assert!(!names.iter().any(|name| name == "aria2.multicall"));
}
other => panic!("unexpected method list payload: {other:?}"),
}
}
}
+217
View File
@@ -0,0 +1,217 @@
//! Shared value, error, and metadata types used across RPC transports.
#![expect(
clippy::redundant_pub_crate,
reason = "shared RPC model items stay crate-internal while retaining explicit visibilities"
)]
use std::collections::BTreeMap;
/// Canonical RPC option map shape for aria2-compatible option payloads.
pub type RpcOptionMap = BTreeMap<String, String>;
/// BitTorrent-specific status fields that aria2-compatible clients may request.
pub(super) const BT_STATUS_FIELDS: &[&str] = &[
"infoHash",
"numSeeders",
"seeder",
"connections",
"activeSegments",
"pieceLength",
"numPieces",
"completedPieces",
"bitfield",
"announceList",
"followedBy",
"following",
"belongsTo",
"verifiedLength",
"verifyIntegrityPending",
"isBt",
"metadataOnly",
"magnetUri",
"creationDate",
"comment",
"mode",
];
#[derive(Debug, Clone, PartialEq)]
/// Transport-neutral RPC value representation.
pub enum RpcValue {
/// Null or absent value.
Null,
/// Boolean scalar value.
Bool(bool),
/// Signed integer value.
Number(i64),
/// UTF-8 string value.
String(String),
/// Ordered list of nested values.
Array(Vec<Self>),
/// Map of named nested values.
Object(BTreeMap<String, Self>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Broad category for RPC failures.
pub enum RpcErrorKind {
/// The method name is not recognized.
UnknownMethod,
/// Parameters are malformed or unsupported.
InvalidParams,
/// Authentication or authorization failed.
Unauthorized,
/// The method exists but is not implemented by this backend.
Unsupported,
/// The backend encountered an unexpected internal failure.
Internal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// JSON-RPC compatible error code surface.
pub enum RpcErrorCode {
/// Invalid JSON payload syntax.
ParseError = -32700,
/// Payload shape is not a valid JSON-RPC request.
InvalidRequest = -32600,
/// Method name is unknown.
MethodNotFound = -32601,
/// Parameters are invalid for the method.
InvalidParams = -32602,
/// Generic server-side failure.
InternalError = -32603,
/// Application-defined aria2-compatible failure.
ApplicationError = -32000,
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// Normalized error payload shared across transports.
pub struct RpcError {
/// JSON-RPC compatible numeric code.
pub code: RpcErrorCode,
/// Broad error classification.
pub kind: RpcErrorKind,
/// Human-readable failure message.
pub message: String,
}
impl RpcError {
#[must_use]
/// Builds a method-not-found error in aria2-compatible wording.
pub fn unknown_method(method: &str) -> Self {
Self {
code: RpcErrorCode::MethodNotFound,
kind: RpcErrorKind::UnknownMethod,
message: format!("Method not found: {method}"),
}
}
#[must_use]
/// Builds a parse error with the provided message.
pub fn parse_error(message: &str) -> Self {
Self {
code: RpcErrorCode::ParseError,
kind: RpcErrorKind::InvalidParams,
message: message.to_owned(),
}
}
#[must_use]
/// Builds an invalid-request error with the provided message.
pub fn invalid_request(message: &str) -> Self {
Self {
code: RpcErrorCode::InvalidRequest,
kind: RpcErrorKind::InvalidParams,
message: message.to_owned(),
}
}
#[must_use]
/// Builds an unsupported-method error with the provided message.
pub fn unsupported(message: &str) -> Self {
Self {
code: RpcErrorCode::ApplicationError,
kind: RpcErrorKind::Unsupported,
message: message.to_owned(),
}
}
#[must_use]
/// Builds an invalid-parameters error with the provided message.
pub fn invalid_params(message: &str) -> Self {
Self {
code: RpcErrorCode::InvalidParams,
kind: RpcErrorKind::InvalidParams,
message: message.to_owned(),
}
}
#[must_use]
/// Builds an authorization failure with the provided message.
pub fn unauthorized(message: &str) -> Self {
Self {
code: RpcErrorCode::ApplicationError,
kind: RpcErrorKind::Unauthorized,
message: message.to_owned(),
}
}
#[must_use]
/// Returns the XML-RPC fault code used for this normalized error.
pub const fn xml_fault_code(&self) -> i32 {
1
}
}
#[derive(Debug, Clone, PartialEq)]
/// Transport-neutral result envelope returned by handlers.
pub struct RpcResultEnvelope {
/// Successful result payload, if any.
pub result: Option<RpcValue>,
/// Error payload, if the request failed.
pub error: Option<RpcError>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
/// Optional metadata attached to inbound or outbound RPC traffic.
pub struct RpcMeta {
/// Correlation identifier propagated across RPC hops.
pub trace_id: Option<String>,
/// Logical client identifier, if known.
pub client: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
/// Authentication context derived from the active transport session.
pub struct RpcAuthContext {
/// Secret token presented by the client.
pub token: Option<String>,
/// Transport session identifier, if one has been established.
pub session_id: Option<String>,
/// Whether the current request is authenticated.
pub authenticated: bool,
}
#[cfg(test)]
/// Unit tests for the shared RPC model surface.
mod tests {
use super::*;
#[test]
/// Verifies that unknown methods use the upstream aria2 wording.
fn unknown_method_uses_upstream_style_message() {
let error = RpcError::unknown_method("aria2.notFound");
assert_eq!(error.code, RpcErrorCode::MethodNotFound);
assert_eq!(error.kind, RpcErrorKind::UnknownMethod);
assert_eq!(error.message, "Method not found: aria2.notFound");
assert_eq!(error.xml_fault_code(), 1);
}
#[test]
/// Verifies that parse and invalid-request errors keep their transport codes.
fn parse_and_invalid_request_keep_transport_codes() {
let parse = RpcError::parse_error("unexpected trailing token");
let invalid = RpcError::invalid_request("jsonrpc batch request must not be empty");
assert_eq!(parse.code, RpcErrorCode::ParseError);
assert_eq!(parse.kind, RpcErrorKind::InvalidParams);
assert_eq!(parse.message, "unexpected trailing token");
assert_eq!(invalid.code, RpcErrorCode::InvalidRequest);
assert_eq!(invalid.kind, RpcErrorKind::InvalidParams);
assert_eq!(invalid.message, "jsonrpc batch request must not be empty");
}
}
+280
View File
@@ -0,0 +1,280 @@
//! Transport-neutral routing between request shapes and handler outputs.
use crate::{
handlers::{RpcHandlerContext, RpcHandlerRegistry},
jsonrpc::{JsonRpcNotification, JsonRpcRequest, JsonRpcResponse},
model::{RpcError, RpcMeta, RpcResultEnvelope, RpcValue},
xmlrpc::{
XmlRpcFault, XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, rpc_value_to_xmlrpc,
xmlrpc_value_to_rpc,
},
};
#[derive(Debug, Clone, PartialEq)]
/// Transport-neutral inbound request variants accepted by the router.
pub enum RpcDispatchRequest {
/// `JSON-RPC` request expecting a response.
Json(JsonRpcRequest),
/// `JSON-RPC` notification with no response body.
JsonNotification(JsonRpcNotification),
/// `XML-RPC` method call expecting a response.
Xml(XmlRpcMethodCall),
}
#[derive(Debug, Clone, PartialEq)]
/// Transport-neutral outbound response variants produced by the router.
pub enum RpcDispatchResult {
/// `JSON-RPC` response payload.
Json(JsonRpcResponse),
/// `XML-RPC` response payload.
Xml(XmlRpcMethodResponse),
/// No response should be emitted.
Empty,
}
#[derive(Debug, Clone, Copy, Default)]
/// Shared router that normalizes transport-specific requests into handler calls.
pub struct RpcRouter {
/// Shared handler registry reused across `JSON-RPC` and `XML-RPC` dispatch paths.
handlers: RpcHandlerRegistry,
}
impl RpcRouter {
#[must_use]
/// Creates a router with the default handler registry.
pub const fn new() -> Self {
Self {
handlers: RpcHandlerRegistry,
}
}
#[must_use]
/// Returns the shared handler registry.
pub const fn handlers(&self) -> &RpcHandlerRegistry {
&self.handlers
}
#[must_use]
/// Returns a mutable handler registry reference.
pub const fn handlers_mut(&mut self) -> &mut RpcHandlerRegistry {
&mut self.handlers
}
/// Dispatches a transport-neutral request through the handler registry.
pub fn dispatch(
&mut self,
request: RpcDispatchRequest,
ctx: RpcHandlerContext,
) -> RpcDispatchResult {
match request {
RpcDispatchRequest::Json(request) => {
let request_id = request.id.clone();
let envelope = self.handlers.handle_json(request, ctx);
RpcDispatchResult::Json(envelope.into_response(request_id))
}
RpcDispatchRequest::JsonNotification(notification) => {
self.handlers.handle_notification(notification, ctx);
RpcDispatchResult::Empty
}
RpcDispatchRequest::Xml(request) => RpcDispatchResult::Xml(
self.handlers
.handle_json(json_request_from_xmlrpc(request), ctx)
.into_xml_response(),
),
}
}
}
impl RpcResultEnvelope {
#[must_use]
/// Converts an envelope into a `JSON-RPC` response with the provided id.
pub fn into_response(self, id: Option<crate::jsonrpc::JsonRpcId>) -> JsonRpcResponse {
let (result, error) = self.normalize_for_response();
JsonRpcResponse {
jsonrpc: Some("2.0".to_owned()),
id,
result,
error,
meta: RpcMeta::default(),
}
}
#[must_use]
/// Converts an envelope into an `XML-RPC` method response.
pub fn into_xml_response(self) -> XmlRpcMethodResponse {
let (result, error) = self.normalize_for_response();
match error {
Some(error) => {
let message = error.message.clone();
XmlRpcMethodResponse {
value: None,
fault: Some(XmlRpcFault {
code: error.xml_fault_code(),
message,
error: Some(error),
}),
meta: RpcMeta::default(),
}
}
None => XmlRpcMethodResponse {
value: Some(rpc_value_to_xmlrpc(
result.map_or(RpcValue::Null, |result| result),
)),
fault: None,
meta: RpcMeta::default(),
},
}
}
/// Normalizes the envelope so transport encoders always see either a result or an error.
fn normalize_for_response(self) -> (Option<RpcValue>, Option<RpcError>) {
match (self.result, self.error) {
(_, Some(error)) => (None, Some(error)),
(Some(result), None) => (Some(result), None),
(None, None) => (Some(RpcValue::Null), None),
}
}
}
/// Converts a normalized error into an envelope with no successful result payload.
impl From<RpcError> for RpcResultEnvelope {
fn from(error: RpcError) -> Self {
Self {
result: None,
error: Some(error),
}
}
}
/// Re-expresses an `XML-RPC` method call as the equivalent `JSON-RPC` request shape.
fn json_request_from_xmlrpc(request: XmlRpcMethodCall) -> JsonRpcRequest {
JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: request.method_name,
params: request
.params
.into_iter()
.map(|XmlRpcParam { value }| xmlrpc_value_to_rpc(value))
.collect(),
meta: request.meta,
}
}
#[cfg(test)]
/// Unit tests for the transport-neutral RPC router.
mod tests {
use super::*;
use crate::{
jsonrpc::JsonRpcId,
model::{RpcAuthContext, RpcErrorCode, RpcErrorKind},
xmlrpc::XmlRpcValue,
};
/// Builds a default handler context for router tests.
fn ctx() -> RpcHandlerContext {
RpcHandlerContext {
auth: RpcAuthContext::default(),
meta: RpcMeta::default(),
}
}
#[test]
/// Verifies that an empty envelope becomes a `null` `JSON-RPC` result.
fn json_response_normalizes_empty_envelope_to_null_result() {
let response = RpcResultEnvelope {
result: None,
error: None,
}
.into_response(Some(JsonRpcId::Number(7)));
assert_eq!(response.id, Some(JsonRpcId::Number(7)));
assert_eq!(response.result, Some(RpcValue::Null));
assert!(response.error.is_none());
}
#[test]
/// Verifies that error payloads take precedence over successful results.
fn json_response_prefers_error_over_result() {
let response = RpcResultEnvelope {
result: Some(RpcValue::String("ignored".to_owned())),
error: Some(RpcError::invalid_params("bad params")),
}
.into_response(Some(JsonRpcId::String("req-1".to_owned())));
assert_eq!(response.id, Some(JsonRpcId::String("req-1".to_owned())));
assert!(response.result.is_none());
let error = response.error.expect("error should win");
assert_eq!(error.code, RpcErrorCode::InvalidParams);
assert_eq!(error.kind, RpcErrorKind::InvalidParams);
assert_eq!(error.message, "bad params");
}
#[test]
/// Verifies that an empty envelope becomes an `XML-RPC` nil value.
fn xml_response_normalizes_empty_envelope_to_nil_value() {
let response = RpcResultEnvelope {
result: None,
error: None,
}
.into_xml_response();
assert!(response.fault.is_none());
assert_eq!(response.value, Some(XmlRpcValue::Nil));
}
#[test]
/// Verifies that unknown `XML-RPC` methods reuse the shared fault shape.
fn xml_dispatch_uses_shared_fault_shape_for_unknown_method() {
let mut router = RpcRouter::new();
let response = match router.dispatch(
RpcDispatchRequest::Xml(XmlRpcMethodCall {
method_name: "aria2.notFound".to_owned(),
params: Vec::new(),
meta: RpcMeta::default(),
}),
ctx(),
) {
RpcDispatchResult::Xml(response) => response,
other => panic!("unexpected dispatch result: {other:?}"),
};
assert!(response.value.is_none());
let fault = response.fault.expect("fault expected");
assert_eq!(fault.code, 1);
assert_eq!(fault.message, "Method not found: aria2.notFound");
assert_eq!(
fault.error,
Some(RpcError::unknown_method("aria2.notFound"))
);
}
#[test]
/// Verifies that `XML-RPC` dispatch reuses the shared JSON-backed handler results.
fn xml_dispatch_reuses_json_handler_results_for_list_methods() {
let mut router = RpcRouter::new();
let response = match router.dispatch(
RpcDispatchRequest::Xml(XmlRpcMethodCall {
method_name: "system.listMethods".to_owned(),
params: Vec::new(),
meta: RpcMeta::default(),
}),
ctx(),
) {
RpcDispatchResult::Xml(response) => response,
other => panic!("unexpected dispatch result: {other:?}"),
};
assert!(response.fault.is_none());
match response.value {
Some(XmlRpcValue::Array(items)) => {
assert!(!items.is_empty(), "listMethods should not be empty");
assert!(items.iter().any(|item| matches!(
item,
XmlRpcValue::String(name) if name == "system.listMethods"
)));
}
other => panic!("unexpected XML value: {other:?}"),
}
}
}
+38
View File
@@ -0,0 +1,38 @@
//! Minimal HTTP and WebSocket server glue for the RPC surface.
#![expect(
clippy::arithmetic_side_effects,
clippy::as_conversions,
clippy::case_sensitive_file_extension_comparisons,
clippy::cast_possible_truncation,
clippy::indexing_slicing,
clippy::missing_const_for_fn,
clippy::needless_pass_by_value,
clippy::question_mark,
reason = "the server glue keeps low-level transport code explicit to preserve wire compatibility"
)]
/// Shared runtime configuration and lightweight listener stub types.
mod config;
/// HTTP request parsing and RPC response shaping helpers.
mod http_surface;
/// Listener runtime and accepted-connection worker orchestration.
mod transport_runtime;
/// WebSocket frame dispatch helpers layered over the shared RPC dispatcher.
mod websocket_dispatch;
/// WebSocket upgrade validation and response shaping helpers.
mod websocket_handshake;
/// Upgraded WebSocket session runtime and flush-loop helpers.
mod websocket_session;
/// WebSocket upgrade/runtime facade shared by the listener runtime.
mod websocket_surface;
/// WebSocket frame codec helpers for upgraded streams.
mod websocket_wire;
use self::config::WEBSOCKET_IDLE_POLL_INTERVAL;
pub use self::{
config::{RpcListenerStub, RpcServerConfig},
transport_runtime::{RpcServerTransport, RpcServerTransportConfig, serve_rpc_listener},
};
#[cfg(test)]
mod tests;
@@ -0,0 +1,56 @@
use std::{net::SocketAddr, time::Duration};
/// Idle poll interval used while waiting for queued WebSocket notifications.
pub(super) const WEBSOCKET_IDLE_POLL_INTERVAL: Duration = Duration::from_millis(100);
#[derive(Debug, Clone, PartialEq, Eq)]
/// Runtime configuration for the RPC HTTP server.
pub struct RpcServerConfig {
/// Whether JSON-RPC over HTTP is enabled.
pub enable_json_rpc: bool,
/// Whether XML-RPC over HTTP is enabled.
pub enable_xml_rpc: bool,
/// Whether JSON-RPC over WebSocket is enabled.
pub enable_websocket_rpc: bool,
/// Socket address the listener binds to.
pub listen_addr: SocketAddr,
/// Shared secret token required by protected methods.
pub secret_token: Option<String>,
/// Allowed CORS origin, if cross-origin requests are permitted.
pub allow_origin: Option<String>,
}
impl Default for RpcServerConfig {
fn default() -> Self {
Self {
enable_json_rpc: true,
enable_xml_rpc: true,
enable_websocket_rpc: false,
listen_addr: SocketAddr::from(([127, 0, 0, 1], 6800)),
secret_token: None,
allow_origin: None,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
/// Minimal listener stub used by tests and integration layers.
pub struct RpcListenerStub {
/// Bound socket address, if the stub has been bound.
pub bound_addr: Option<SocketAddr>,
/// Whether the stub is considered active.
pub active: bool,
}
impl RpcListenerStub {
/// Marks the stub as bound to the provided address.
pub fn bind(&mut self, addr: SocketAddr) {
self.bound_addr = Some(addr);
self.active = true;
}
/// Marks the stub as closed.
pub fn close(&mut self) {
self.active = false;
}
}
@@ -0,0 +1,544 @@
use std::{
collections::BTreeMap,
io::{self, Read},
net::TcpStream,
str,
sync::{Arc, Mutex},
time::Duration,
};
use crate::{
InProcessRpcDispatcher, JsonRpcRequest,
jsonrpc::{
JsonRpcPayload, JsonRpcResponse, jsonrpc_batch_response_to_json, jsonrpc_payload_from_json,
jsonrpc_response_to_json,
},
model::{RpcError, RpcErrorCode, RpcMeta, RpcValue},
xmlrpc::{
XmlRpcFault, XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, XmlRpcValue,
xmlrpc_method_call_from_xml, xmlrpc_method_response_to_xml,
},
};
use super::{RpcServerConfig, websocket_surface::websocket_upgrade_response};
#[derive(Debug, Clone)]
/// Buffered HTTP request model used by the synchronous transport helpers.
pub(super) struct HttpRequest {
/// Request method token from the HTTP start line.
pub(super) method: String,
/// Raw request target path from the HTTP start line.
pub(super) path: String,
/// Lower-cased request headers.
pub(super) headers: BTreeMap<String, String>,
/// Fully buffered request body bytes.
pub(super) body: Vec<u8>,
}
#[derive(Debug)]
/// Prepared HTTP RPC request after transport-level validation and body parsing.
enum PreparedHttpRpcRequest {
/// An XML-RPC request body ready for dispatcher execution.
Xml(XmlRpcMethodCall),
/// A JSON-RPC payload ready for dispatcher execution.
Json(JsonRpcPayload),
}
/// Strips any query string or fragment from an inbound request target.
pub(super) fn request_path_without_query_or_fragment(path: &str) -> &str {
path.split(['?', '#']).next().unwrap_or(path)
}
/// Normalizes supported RPC paths so equivalent HTTP targets share one route key.
pub(super) fn normalized_rpc_path(path: &str) -> &str {
let path = request_path_without_query_or_fragment(path);
if path.len() > 1 {
path.trim_end_matches('/')
} else {
path
}
}
/// Looks up an HTTP header by name using case-insensitive matching.
pub(super) fn header_value<'a>(
headers: &'a BTreeMap<String, String>,
name: &str,
) -> Option<&'a str> {
headers
.iter()
.find(|(header_name, _)| header_name.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
/// Returns whether an HTTP header exists, ignoring header-name case.
pub(super) fn has_header(headers: &BTreeMap<String, String>, name: &str) -> bool {
header_value(headers, name).is_some()
}
/// Reads and buffers a single HTTP request from a client stream.
pub(super) fn read_http_request(
stream: &mut TcpStream,
request_timeout: Duration,
) -> io::Result<Option<HttpRequest>> {
stream.set_read_timeout(Some(request_timeout))?;
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk)?;
if read == 0 {
if buffer.is_empty() {
return Ok(None);
}
break;
}
buffer.extend_from_slice(&chunk[..read]);
if buffer.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
if buffer.len() > 1024 * 1024 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"request too large",
));
}
}
let header_end = buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| index + 4)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing request headers"))?;
let (header_bytes, body_prefix) = buffer.split_at(header_end);
let header_text = str::from_utf8(header_bytes)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?;
let mut header_lines = header_text.lines();
let request_line = header_lines
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing request line"))?;
let mut request_parts = request_line.split_whitespace();
let method = request_parts
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing method"))?
.to_owned();
let path = request_parts
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing path"))?
.to_owned();
let headers = header_lines
.filter_map(|line| line.split_once(':'))
.map(|(name, value)| (name.trim().to_ascii_lowercase(), value.trim().to_owned()))
.collect::<BTreeMap<_, _>>();
let content_length = headers
.get("content-length")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(0);
let mut body = body_prefix.to_vec();
while body.len() < content_length {
let read = stream.read(&mut chunk)?;
if read == 0 {
break;
}
body.extend_from_slice(&chunk[..read]);
}
body.truncate(content_length);
Ok(Some(HttpRequest {
method,
path,
headers,
body,
}))
}
/// Builds a minimal HTTP response with the supplied content type and body.
pub(super) fn http_response(status: &str, content_type: &str, body: &[u8]) -> Vec<u8> {
let mut response = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.into_bytes();
response.extend_from_slice(body);
response
}
/// Builds an empty `204 No Content` HTTP response.
pub(super) fn http_no_content_response() -> Vec<u8> {
b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec()
}
/// Builds a minimal HTTP response while allowing additional headers to be injected.
pub(super) fn http_response_with_headers(
status: &str,
content_type: Option<&str>,
extra_headers: &[(&str, String)],
body: &[u8],
) -> Vec<u8> {
let mut response = format!("HTTP/1.1 {status}\r\n").into_bytes();
if let Some(content_type) = content_type {
response.extend_from_slice(format!("Content-Type: {content_type}\r\n").as_bytes());
}
response.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
for (name, value) in extra_headers {
response.extend_from_slice(format!("{name}: {value}\r\n").as_bytes());
}
response.extend_from_slice(b"\r\n");
response.extend_from_slice(body);
response
}
/// Produces the CORS headers required for either a normal RPC response or a preflight reply.
pub(super) fn cors_response_headers(
config: &RpcServerConfig,
requested_headers: Option<&str>,
preflight: bool,
) -> Vec<(&'static str, String)> {
let Some(origin) = &config.allow_origin else {
return Vec::new();
};
let mut headers = vec![("Access-Control-Allow-Origin", origin.clone())];
if preflight {
headers.push((
"Access-Control-Allow-Methods",
"POST, GET, OPTIONS".to_owned(),
));
headers.push((
"Access-Control-Allow-Headers",
requested_headers
.filter(|value| !value.trim().is_empty())
.unwrap_or("content-type")
.to_owned(),
));
}
headers
}
/// Builds an RPC HTTP response and attaches configured CORS headers.
pub(super) fn rpc_http_response(
config: &RpcServerConfig,
status: &str,
content_type: &str,
body: &[u8],
) -> Vec<u8> {
let headers = cors_response_headers(config, None, false);
http_response_with_headers(status, Some(content_type), &headers, body)
}
/// Builds an empty RPC HTTP response and attaches configured CORS headers.
pub(super) fn rpc_http_no_content_response(
config: &RpcServerConfig,
requested_headers: Option<&str>,
) -> Vec<u8> {
let headers = cors_response_headers(config, requested_headers, true);
http_response_with_headers("204 No Content", None, &headers, b"")
}
/// Returns whether an HTTP request looks like a WebSocket upgrade handshake for RPC.
pub(super) fn is_websocket_upgrade_candidate(request: &HttpRequest) -> bool {
request.method.eq_ignore_ascii_case("GET")
&& normalized_rpc_path(&request.path) == "/jsonrpc"
&& (header_value(&request.headers, "upgrade")
.is_some_and(|value| value.eq_ignore_ascii_case("websocket"))
|| has_header(&request.headers, "sec-websocket-key")
|| has_header(&request.headers, "sec-websocket-version")
|| header_value(&request.headers, "connection")
.is_some_and(|value| contains_ascii_case_insensitive(value, "upgrade")))
}
/// Removes an optional leading `token:` secret from JSON-RPC positional parameters.
fn extract_rpc_token(params: &mut Vec<RpcValue>) -> Option<String> {
if let Some(RpcValue::String(token)) = params.first()
&& let Some(value) = token.strip_prefix("token:")
{
let value = value.to_owned();
params.remove(0);
return Some(value);
}
None
}
/// Returns whether an RPC method remains callable without the shared secret token.
fn rpc_method_skips_secret(method: &str) -> bool {
matches!(method, "system.listMethods" | "system.listNotifications")
}
/// Dispatches one JSON-RPC request after applying the shared-secret compatibility rules.
pub(super) fn dispatch_json_request(
dispatcher: &mut InProcessRpcDispatcher,
config: &RpcServerConfig,
mut request: JsonRpcRequest,
) -> JsonRpcResponse {
let provided_token = extract_rpc_token(&mut request.params);
if let Some(secret) = &config.secret_token
&& !rpc_method_skips_secret(&request.method)
&& provided_token.as_deref() != Some(secret.as_str())
{
return JsonRpcResponse::error(
request.id,
RpcError::unauthorized("RPC secret required or invalid token"),
);
}
dispatcher.dispatch_json(request)
}
/// Dispatches one XML-RPC request after applying the shared-secret compatibility rules.
pub(super) fn dispatch_xml_request(
dispatcher: &mut InProcessRpcDispatcher,
config: &RpcServerConfig,
mut request: XmlRpcMethodCall,
) -> XmlRpcMethodResponse {
let provided_token = if let Some(XmlRpcParam {
value: XmlRpcValue::String(token),
}) = request.params.first()
&& let Some(value) = token.strip_prefix("token:")
{
Some(value.to_owned())
} else {
None
};
if provided_token.is_some() {
request.params.remove(0);
}
if let Some(secret) = &config.secret_token
&& !rpc_method_skips_secret(&request.method_name)
&& provided_token.as_deref() != Some(secret.as_str())
{
return XmlRpcMethodResponse {
value: None,
fault: Some(XmlRpcFault {
code: 1,
message: "RPC secret required or invalid token".to_owned(),
error: Some(RpcError::unauthorized(
"RPC secret required or invalid token",
)),
}),
meta: RpcMeta::default(),
};
}
dispatcher.dispatch_xml(request)
}
/// Trims a UTF-8 BOM, comments, and processing instructions before XML-RPC sniffing.
fn trim_xml_prelude(mut body: &str) -> &str {
loop {
body = body.trim_start();
if let Some(rest) = body.strip_prefix("<?")
&& let Some(end) = rest.find("?>")
{
body = &rest[end + 2..];
continue;
}
if let Some(rest) = body.strip_prefix("<!--")
&& let Some(end) = rest.find("-->")
{
body = &rest[end + 3..];
continue;
}
return body;
}
}
/// Returns whether an HTTP request body should be treated as XML-RPC input.
fn looks_like_xmlrpc_request_body(body_text: &str) -> bool {
let body = trim_xml_prelude(body_text.trim_start_matches('\u{feff}'));
body.starts_with("<methodCall") || body.starts_with("<methodResponse")
}
/// Handles one HTTP RPC request, including JSON-RPC, XML-RPC, CORS, and upgrade paths.
#[cfg(test)]
pub(super) fn handle_rpc_http_request(
dispatcher: &mut InProcessRpcDispatcher,
config: &RpcServerConfig,
request: HttpRequest,
) -> Vec<u8> {
match prepare_rpc_http_request(config, request) {
Ok(Some(prepared)) => render_rpc_http_dispatch_response(dispatcher, config, prepared),
Ok(None) => http_no_content_response(),
Err(response) => response,
}
}
/// Handles one HTTP RPC request while locking the dispatcher only for the actual dispatch path.
pub(super) fn handle_rpc_http_request_shared(
dispatcher: &Arc<Mutex<InProcessRpcDispatcher>>,
config: &RpcServerConfig,
request: HttpRequest,
) -> io::Result<Vec<u8>> {
match prepare_rpc_http_request(config, request) {
Ok(Some(prepared)) => {
let mut dispatcher = dispatcher
.lock()
.map_err(|_| io::Error::other("rpc dispatcher mutex poisoned"))?;
Ok(render_rpc_http_dispatch_response(
&mut dispatcher,
config,
prepared,
))
}
Ok(None) => Ok(http_no_content_response()),
Err(response) => Ok(response),
}
}
/// Parses and validates one HTTP RPC request before any dispatcher locking occurs.
fn prepare_rpc_http_request(
config: &RpcServerConfig,
request: HttpRequest,
) -> Result<Option<PreparedHttpRpcRequest>, Vec<u8>> {
if is_websocket_upgrade_candidate(&request) {
return Err(websocket_upgrade_response(config, &request));
}
let path = normalized_rpc_path(&request.path);
let is_rpc_endpoint = matches!(path, "/jsonrpc" | "/rpc");
if request.method.eq_ignore_ascii_case("OPTIONS")
&& is_rpc_endpoint
&& config.allow_origin.is_some()
{
return Err(rpc_http_no_content_response(
config,
header_value(&request.headers, "access-control-request-headers"),
));
}
if request.method != "POST" {
return Err(http_response(
"405 Method Not Allowed",
"text/plain",
b"method not allowed",
));
}
let content_type = header_value(&request.headers, "content-type").unwrap_or("");
let body_text = String::from_utf8_lossy(&request.body);
let normalized_xml_body = trim_xml_prelude(body_text.trim_start_matches('\u{feff}'));
let is_xml = contains_ascii_case_insensitive(content_type, "xml")
|| path.ends_with(".xml")
|| (path == "/rpc" && looks_like_xmlrpc_request_body(&body_text))
|| request.body.starts_with(b"<?xml")
|| body_text.trim_start().starts_with("<?xml");
if is_xml {
if !config.enable_xml_rpc {
return Err(http_response(
"404 Not Found",
"text/plain",
b"xml-rpc disabled",
));
}
let request = match xmlrpc_method_call_from_xml(normalized_xml_body) {
Ok(request) => request,
Err(error) => {
return Err(http_response(
"400 Bad Request",
"text/plain",
error.as_bytes(),
));
}
};
Ok(Some(PreparedHttpRpcRequest::Xml(request)))
} else {
if !config.enable_json_rpc {
return Err(http_response(
"404 Not Found",
"text/plain",
b"json-rpc disabled",
));
}
let payload = match jsonrpc_payload_from_json(&body_text) {
Ok(payload) => payload,
Err(error) => {
let response = JsonRpcResponse::error(
None,
RpcError {
code: if error.contains("must not be empty") {
RpcErrorCode::InvalidRequest
} else {
RpcErrorCode::ParseError
},
kind: crate::model::RpcErrorKind::InvalidParams,
message: error,
},
);
let body = jsonrpc_response_to_json(&response)
.unwrap_or_else(|render_error| format!(r#"{{"error":"{render_error}"}}"#));
return Err(rpc_http_response(
config,
"200 OK",
"application/json",
body.as_bytes(),
));
}
};
Ok(Some(PreparedHttpRpcRequest::Json(payload)))
}
}
/// Returns whether `needle` appears in `haystack`, ignoring ASCII case.
pub(super) fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
if needle.is_empty() {
return true;
}
haystack
.as_bytes()
.windows(needle.len())
.any(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
}
/// Renders a prepared HTTP RPC request once dispatcher access has been acquired.
fn render_rpc_http_dispatch_response(
dispatcher: &mut InProcessRpcDispatcher,
config: &RpcServerConfig,
prepared: PreparedHttpRpcRequest,
) -> Vec<u8> {
match prepared {
PreparedHttpRpcRequest::Xml(request) => {
let response = dispatch_xml_request(dispatcher, config, request);
let body = xmlrpc_method_response_to_xml(&response);
rpc_http_response(config, "200 OK", "text/xml", body.as_bytes())
}
PreparedHttpRpcRequest::Json(payload) => {
render_json_http_dispatch_response(dispatcher, config, payload)
}
}
}
/// Renders a prepared JSON-RPC HTTP request once dispatcher access has been acquired.
fn render_json_http_dispatch_response(
dispatcher: &mut InProcessRpcDispatcher,
config: &RpcServerConfig,
payload: JsonRpcPayload,
) -> Vec<u8> {
match payload {
JsonRpcPayload::Single(request) => {
let response = dispatch_json_request(dispatcher, config, request);
if response.id.is_none() {
return http_no_content_response();
}
let body = jsonrpc_response_to_json(&response)
.unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#));
rpc_http_response(config, "200 OK", "application/json", body.as_bytes())
}
JsonRpcPayload::Batch(items) => {
let mut responses = Vec::new();
for item in items {
match item {
Ok(request) => {
let response = dispatch_json_request(dispatcher, config, request);
if response.id.is_some() {
responses.push(response);
}
}
Err(error) => responses.push(JsonRpcResponse::error(
None,
RpcError {
code: RpcErrorCode::InvalidRequest,
kind: crate::model::RpcErrorKind::InvalidParams,
message: error,
},
)),
}
}
let body = jsonrpc_batch_response_to_json(&responses)
.unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#));
rpc_http_response(config, "200 OK", "application/json", body.as_bytes())
}
}
}
@@ -0,0 +1,48 @@
use std::{
collections::BTreeMap,
io::Write,
net::{Shutdown, TcpListener, TcpStream},
sync::{Arc, Mutex},
thread,
time::Duration,
};
use super::{
http_surface::{
HttpRequest, dispatch_json_request, dispatch_xml_request, handle_rpc_http_request,
},
websocket_session::flush_websocket_session_queue_shared,
websocket_surface::{
flush_websocket_session_queue, handle_websocket_rpc_frame,
process_websocket_session_frames, read_websocket_frame, serve_upgraded_websocket_session,
websocket_frame_from_bytes, websocket_frame_to_bytes,
},
*,
};
use crate::{
InProcessRpcDispatcher, JsonRpcRequest, RpcMeta, RpcMethod, RpcNotificationEvent,
RpcNotificationKind, RpcValue, RpcWebSocketFrame, WebSocketSessionRegistry, XmlRpcMethodCall,
XmlRpcParam, XmlRpcValue, jsonrpc::JsonRpcId,
};
mod http_surface;
mod websocket_dispatch;
mod websocket_handshake;
mod websocket_session;
#[doc(hidden)]
fn masked_text_frame_bytes(text: &str) -> Vec<u8> {
let payload = text.as_bytes();
let mask = [0x11, 0x22, 0x33, 0x44];
let masked_payload = payload
.iter()
.enumerate()
.map(|(index, byte)| byte ^ mask[index % mask.len()])
.collect::<Vec<_>>();
let payload_len =
u8::try_from(payload.len()).expect("test websocket payload should fit in a short frame");
let mut frame = vec![0x81, 0x80 | payload_len];
frame.extend_from_slice(&mask);
frame.extend_from_slice(&masked_payload);
frame
}
@@ -0,0 +1,519 @@
use super::*;
#[test]
fn serves_jsonrpc_version_over_http() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig::default(),
HttpRequest {
method: "POST".to_owned(),
path: "/jsonrpc".to_owned(),
headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]),
body: br#"{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]}"#.to_vec(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("200 OK"));
assert!(response.contains("\"version\""));
}
#[test]
fn serves_xmlrpc_version_over_http() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig::default(),
HttpRequest {
method: "POST".to_owned(),
path: "/rpc".to_owned(),
headers: BTreeMap::from([("content-type".to_owned(), "text/xml".to_owned())]),
body: br#"<?xml version="1.0"?><methodCall><methodName>aria2.getVersion</methodName><params/></methodCall>"#.to_vec(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("200 OK"));
assert!(response.contains("<methodResponse>"));
assert!(response.contains("<name>version</name>"));
}
#[test]
fn serves_xmlrpc_with_comments_and_spaced_empty_params_over_http() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig {
secret_token: Some("secret".to_owned()),
..RpcServerConfig::default()
},
HttpRequest {
method: "POST".to_owned(),
path: "/rpc".to_owned(),
headers: BTreeMap::from([("content-type".to_owned(), "text/xml".to_owned())]),
body: br#"<?xml version="1.0"?>
<!-- client prelude -->
<?client probe?>
<methodCall>
<methodName>system.listNotifications</methodName>
<params />
</methodCall>"#
.to_vec(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("200 OK"));
assert!(response.contains("aria2.onDownloadStart"));
assert!(response.contains("aria2.onDownloadError"));
}
#[test]
fn dispatch_json_rejects_bad_secret() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatch_json_request(
&mut dispatcher,
&RpcServerConfig {
secret_token: Some("secret".to_owned()),
..RpcServerConfig::default()
},
JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: Some(JsonRpcId::Number(1)),
method: RpcMethod::Aria2GetVersion.as_str().to_owned(),
params: vec![RpcValue::String("token:wrong".to_owned())],
meta: RpcMeta::default(),
},
);
assert!(response.error.is_some());
}
#[test]
fn dispatch_json_allows_system_list_methods_without_secret() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatch_json_request(
&mut dispatcher,
&RpcServerConfig {
secret_token: Some("secret".to_owned()),
..RpcServerConfig::default()
},
JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: Some(JsonRpcId::Number(2)),
method: RpcMethod::SystemListMethods.as_str().to_owned(),
params: Vec::new(),
meta: RpcMeta::default(),
},
);
assert!(response.error.is_none());
}
#[test]
fn dispatch_xml_allows_system_list_notifications_without_secret() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatch_xml_request(
&mut dispatcher,
&RpcServerConfig {
secret_token: Some("secret".to_owned()),
..RpcServerConfig::default()
},
XmlRpcMethodCall {
method_name: "system.listNotifications".to_owned(),
params: Vec::new(),
meta: RpcMeta::default(),
},
);
assert!(response.fault.is_none());
match response.value {
Some(XmlRpcValue::Array(values)) => assert!(!values.is_empty()),
other => panic!("unexpected XML-RPC response value: {other:?}"),
}
}
#[test]
fn dispatch_xml_rejects_invalid_secret_with_upstream_fault_shape() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = dispatch_xml_request(
&mut dispatcher,
&RpcServerConfig {
secret_token: Some("secret".to_owned()),
..RpcServerConfig::default()
},
XmlRpcMethodCall {
method_name: "aria2.getVersion".to_owned(),
params: vec![XmlRpcParam {
value: XmlRpcValue::String("token:wrong".to_owned()),
}],
meta: RpcMeta::default(),
},
);
assert!(response.value.is_none());
let fault = response.fault.expect("expected XML-RPC fault");
assert_eq!(fault.code, 1);
assert_eq!(fault.message, "RPC secret required or invalid token");
}
#[test]
fn serves_jsonrpc_batch_over_http() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig::default(),
HttpRequest {
method: "POST".to_owned(),
path: "/jsonrpc".to_owned(),
headers: BTreeMap::from([(
"content-type".to_owned(),
"application/json".to_owned(),
)]),
body: br#"[{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]},{"jsonrpc":"2.0","id":2,"method":"system.listMethods","params":[]}]"#
.to_vec(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("200 OK"));
assert!(response.contains("[{"));
assert!(response.contains("\"id\":1"));
assert!(response.contains("\"id\":2"));
assert!(response.contains("\"version\""));
assert!(response.contains("system.listMethods"));
}
#[test]
fn serves_jsonrpc_batch_without_ids_as_invalid_request_errors() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig::default(),
HttpRequest {
method: "POST".to_owned(),
path: "/jsonrpc".to_owned(),
headers: BTreeMap::from([(
"content-type".to_owned(),
"application/json".to_owned(),
)]),
body: br#"[{"jsonrpc":"2.0","method":"aria2.getVersion","params":[]},{"jsonrpc":"2.0","method":"system.listMethods","params":[]}]"#
.to_vec(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("200 OK"));
assert!(response.contains("\"id\":null"));
assert!(response.contains("\"code\":-32600"));
assert!(response.contains("\"message\":\"Invalid Request.\""));
}
#[test]
fn serves_jsonrpc_batch_ignores_non_object_members_like_upstream_aria2() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig::default(),
HttpRequest {
method: "POST".to_owned(),
path: "/jsonrpc".to_owned(),
headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]),
body: br#"[{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]},7]"#
.to_vec(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("200 OK"));
assert!(response.contains("\"id\":1"));
assert!(!response.contains("\"code\":-32600"));
assert!(!response.contains("jsonrpc request must be an object"));
}
#[test]
fn serves_empty_jsonrpc_batch_as_empty_array_like_upstream_aria2() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig::default(),
HttpRequest {
method: "POST".to_owned(),
path: "/jsonrpc".to_owned(),
headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]),
body: br"[]".to_vec(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("200 OK"));
assert!(response.ends_with("[]"));
assert!(!response.contains("204 No Content"));
}
#[test]
fn serves_jsonrpc_batch_with_only_non_object_members_as_empty_array() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig::default(),
HttpRequest {
method: "POST".to_owned(),
path: "/jsonrpc".to_owned(),
headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]),
body: br#"[7,true,"noop"]"#.to_vec(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("200 OK"));
assert!(response.ends_with("[]"));
assert!(!response.contains("204 No Content"));
}
#[test]
fn serves_jsonrpc_list_notifications_with_upstream_names_over_http() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig::default(),
HttpRequest {
method: "POST".to_owned(),
path: "/jsonrpc".to_owned(),
headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]),
body: br#"{"jsonrpc":"2.0","id":7,"method":"system.listNotifications","params":[]}"#
.to_vec(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("200 OK"));
assert!(response.contains("\"id\":7"));
assert!(response.contains("aria2.onDownloadStart"));
assert!(response.contains("aria2.onDownloadComplete"));
assert!(response.contains("aria2.onBtDownloadComplete"));
}
#[test]
fn serves_jsonrpc_list_methods_without_secret_over_http() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig {
secret_token: Some("secret".to_owned()),
..RpcServerConfig::default()
},
HttpRequest {
method: "POST".to_owned(),
path: "/jsonrpc".to_owned(),
headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]),
body: br#"{"jsonrpc":"2.0","id":8,"method":"system.listMethods","params":[]}"#.to_vec(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("200 OK"));
assert!(response.contains("\"id\":8"));
assert!(response.contains("aria2.addUri"));
}
#[test]
fn serves_single_jsonrpc_request_without_id_as_invalid_request() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig::default(),
HttpRequest {
method: "POST".to_owned(),
path: "/jsonrpc".to_owned(),
headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]),
body: br#"{"jsonrpc":"2.0","method":"aria2.getVersion","params":[]}"#.to_vec(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("200 OK"));
assert!(response.contains("\"id\":null"));
assert!(response.contains("\"code\":-32600"));
assert!(response.contains("\"message\":\"Invalid Request.\""));
}
#[test]
fn serves_jsonrpc_named_params_as_invalid_params_with_same_id() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig::default(),
HttpRequest {
method: "POST".to_owned(),
path: "/jsonrpc".to_owned(),
headers: BTreeMap::from([(
"content-type".to_owned(),
"application/json".to_owned(),
)]),
body: br#"{"jsonrpc":"2.0","id":"named","method":"aria2.getVersion","params":{"foo":"bar"}}"#
.to_vec(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("200 OK"));
assert!(response.contains("\"id\":\"named\""));
assert!(response.contains("\"code\":-32602"));
assert!(response.contains("\"message\":\"Invalid params.\""));
}
#[test]
fn serves_xmlrpc_list_notifications_without_secret_over_http() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig {
secret_token: Some("secret".to_owned()),
..RpcServerConfig::default()
},
HttpRequest {
method: "POST".to_owned(),
path: "/rpc".to_owned(),
headers: BTreeMap::from([("content-type".to_owned(), "text/xml".to_owned())]),
body: br#"<?xml version="1.0"?><methodCall><methodName>system.listNotifications</methodName><params/></methodCall>"#
.to_vec(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("200 OK"));
assert!(response.contains("aria2.onDownloadStart"));
assert!(response.contains("aria2.onBtDownloadComplete"));
}
#[test]
fn serves_xmlrpc_on_rpc_path_without_content_type_when_body_is_method_call() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig::default(),
HttpRequest {
method: "POST".to_owned(),
path: "/rpc".to_owned(),
headers: BTreeMap::new(),
body: br"<methodCall><methodName>aria2.getVersion</methodName><params/></methodCall>"
.to_vec(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("200 OK"));
assert!(response.contains("<methodResponse>"));
assert!(response.contains("<name>version</name>"));
}
#[test]
fn serves_xmlrpc_with_case_insensitive_content_type_header_name_and_value() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig::default(),
HttpRequest {
method: "POST".to_owned(),
path: "/jsonrpc".to_owned(),
headers: BTreeMap::from([(
"Content-Type".to_owned(),
"Text/XML; charset=utf-8".to_owned(),
)]),
body: br"<methodCall><methodName>aria2.getVersion</methodName><params/></methodCall>"
.to_vec(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("200 OK"));
assert!(response.contains("<methodResponse>"));
assert!(response.contains("<name>version</name>"));
}
#[test]
fn serves_jsonrpc_on_normalized_jsonrpc_path_with_query_and_trailing_slash() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig::default(),
HttpRequest {
method: "POST".to_owned(),
path: "/jsonrpc/?tm=1".to_owned(),
headers: BTreeMap::from([("content-type".to_owned(), "text/plain".to_owned())]),
body: br#"{"jsonrpc":"2.0","id":21,"method":"aria2.getVersion","params":[]}"#.to_vec(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("200 OK"));
assert!(response.contains(r#""id":21"#));
assert!(response.contains(r#""version""#));
}
#[test]
fn serves_xmlrpc_on_normalized_rpc_path_without_content_type_when_body_has_bom_and_comment() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig::default(),
HttpRequest {
method: "POST".to_owned(),
path: "/rpc/?view=compat".to_owned(),
headers: BTreeMap::new(),
body: "\u{feff}<!--probe--><methodCall><methodName>aria2.getVersion</methodName><params/></methodCall>"
.as_bytes()
.to_vec(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("200 OK"));
assert!(response.contains("<methodResponse>"));
assert!(response.contains("<name>version</name>"));
}
#[test]
fn serves_cors_preflight_for_jsonrpc_when_allow_origin_is_configured() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig {
allow_origin: Some("https://webui.example".to_owned()),
..RpcServerConfig::default()
},
HttpRequest {
method: "OPTIONS".to_owned(),
path: "/jsonrpc?cors=1".to_owned(),
headers: BTreeMap::from([
("origin".to_owned(), "https://webui.example".to_owned()),
(
"access-control-request-method".to_owned(),
"POST".to_owned(),
),
(
"access-control-request-headers".to_owned(),
"content-type,x-requested-with".to_owned(),
),
]),
body: Vec::new(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("204 No Content"));
assert!(response.contains("Access-Control-Allow-Origin: https://webui.example"));
assert!(response.contains("Access-Control-Allow-Methods: POST, GET, OPTIONS"));
assert!(response.contains("Access-Control-Allow-Headers: content-type,x-requested-with"));
}
#[test]
fn includes_allow_origin_header_on_http_jsonrpc_success_responses() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig {
allow_origin: Some("https://webui.example".to_owned()),
..RpcServerConfig::default()
},
HttpRequest {
method: "POST".to_owned(),
path: "/jsonrpc".to_owned(),
headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]),
body: br#"{"jsonrpc":"2.0","id":22,"method":"aria2.getVersion","params":[]}"#.to_vec(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("200 OK"));
assert!(response.contains("Access-Control-Allow-Origin: https://webui.example"));
assert!(response.contains(r#""id":22"#));
}
@@ -0,0 +1,272 @@
use super::*;
#[test]
fn websocket_frame_roundtrips_masked_text_payload() {
let payload = br#"{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]}"#;
let mask = [0x11, 0x22, 0x33, 0x44];
let masked_payload = payload
.iter()
.enumerate()
.map(|(index, byte)| byte ^ mask[index % 4])
.collect::<Vec<_>>();
let mut frame = vec![0x81, 0x80 | (payload.len() as u8)];
frame.extend_from_slice(&mask);
frame.extend_from_slice(&masked_payload);
let parsed = websocket_frame_from_bytes(&frame).expect("masked text frame should parse");
assert_eq!(
parsed,
RpcWebSocketFrame::Text(
r#"{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]}"#.to_owned()
)
);
}
#[test]
fn websocket_frame_roundtrips_masked_binary_payload() {
let payload = br#"{"jsonrpc":"2.0","id":71,"method":"aria2.getVersion","params":[]}"#;
let mask = [0x11, 0x22, 0x33, 0x44];
let masked_payload = payload
.iter()
.enumerate()
.map(|(index, byte)| byte ^ mask[index % 4])
.collect::<Vec<_>>();
let mut frame = vec![0x82, 0x80 | (payload.len() as u8)];
frame.extend_from_slice(&mask);
frame.extend_from_slice(&masked_payload);
let parsed = websocket_frame_from_bytes(&frame).expect("masked binary frame should parse");
assert_eq!(parsed, RpcWebSocketFrame::Binary(payload.to_vec()));
}
#[test]
fn websocket_dispatches_text_request_and_returns_text_response() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_websocket_rpc_frame(
&mut dispatcher,
&RpcServerConfig::default(),
RpcWebSocketFrame::Text(
r#"{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]}"#.to_owned(),
),
);
match response {
Some(RpcWebSocketFrame::Text(text)) => {
assert!(text.contains(r#""id":1"#));
assert!(text.contains(r#""version""#));
}
other => panic!("unexpected websocket rpc response: {other:?}"),
}
}
#[test]
fn websocket_dispatches_binary_json_request_and_returns_text_response() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_websocket_rpc_frame(
&mut dispatcher,
&RpcServerConfig::default(),
RpcWebSocketFrame::Binary(
br#"{"jsonrpc":"2.0","id":72,"method":"aria2.getVersion","params":[]}"#.to_vec(),
),
);
match response {
Some(RpcWebSocketFrame::Text(text)) => {
assert!(text.contains(r#""id":72"#));
assert!(text.contains(r#""version""#));
}
other => panic!("unexpected websocket binary rpc response: {other:?}"),
}
}
#[test]
fn websocket_dispatches_system_list_methods_without_secret() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_websocket_rpc_frame(
&mut dispatcher,
&RpcServerConfig {
secret_token: Some("secret".to_owned()),
..RpcServerConfig::default()
},
RpcWebSocketFrame::Text(
r#"{"jsonrpc":"2.0","id":11,"method":"system.listMethods","params":[]}"#.to_owned(),
),
);
match response {
Some(RpcWebSocketFrame::Text(text)) => {
assert!(text.contains(r#""id":11"#));
assert!(text.contains("aria2.addUri"));
}
other => panic!("unexpected websocket system.listMethods response: {other:?}"),
}
}
#[test]
fn websocket_dispatches_protected_method_with_secret_token() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_websocket_rpc_frame(
&mut dispatcher,
&RpcServerConfig {
secret_token: Some("secret".to_owned()),
..RpcServerConfig::default()
},
RpcWebSocketFrame::Text(
r#"{"jsonrpc":"2.0","id":12,"method":"aria2.getVersion","params":["token:secret"]}"#
.to_owned(),
),
);
match response {
Some(RpcWebSocketFrame::Text(text)) => {
assert!(text.contains(r#""id":12"#));
assert!(text.contains(r#""version""#));
assert!(!text.contains(r#""code":-32000"#));
}
other => panic!("unexpected websocket protected-method response: {other:?}"),
}
}
#[test]
fn websocket_rejects_invalid_secret_with_error_frame() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_websocket_rpc_frame(
&mut dispatcher,
&RpcServerConfig {
secret_token: Some("secret".to_owned()),
..RpcServerConfig::default()
},
RpcWebSocketFrame::Text(
r#"{"jsonrpc":"2.0","id":13,"method":"aria2.getVersion","params":["token:wrong"]}"#
.to_owned(),
),
);
match response {
Some(RpcWebSocketFrame::Text(text)) => {
assert!(text.contains(r#""id":13"#));
assert!(text.contains(r#""code":-32000"#));
assert!(text.contains("RPC secret required or invalid token"));
}
other => panic!("unexpected websocket invalid-secret response: {other:?}"),
}
}
#[test]
fn websocket_batch_mixes_authorized_and_unauthorized_requests_independently() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_websocket_rpc_frame(
&mut dispatcher,
&RpcServerConfig {
secret_token: Some("secret".to_owned()),
..RpcServerConfig::default()
},
RpcWebSocketFrame::Text(
concat!(
r#"[{"jsonrpc":"2.0","id":31,"method":"aria2.getVersion","params":["token:secret"]},"#,
r#"{"jsonrpc":"2.0","id":32,"method":"aria2.getVersion","params":["token:wrong"]},"#,
r#"{"jsonrpc":"2.0","id":33,"method":"system.listMethods","params":[]}]"#
)
.to_owned(),
),
);
match response {
Some(RpcWebSocketFrame::Text(text)) => {
assert!(text.starts_with('['));
assert!(text.contains(r#""id":31"#));
assert!(text.contains(r#""id":32"#));
assert!(text.contains(r#""id":33"#));
assert!(text.contains(r#""version""#));
assert!(text.contains("system.listMethods"));
assert!(text.contains(r#""code":-32000"#));
assert!(text.contains("RPC secret required or invalid token"));
}
other => panic!("unexpected websocket batch response: {other:?}"),
}
}
#[test]
fn websocket_batch_ignores_non_object_members_like_upstream_aria2() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_websocket_rpc_frame(
&mut dispatcher,
&RpcServerConfig::default(),
RpcWebSocketFrame::Text(
r#"[{"jsonrpc":"2.0","id":41,"method":"aria2.getVersion","params":[]},7]"#.to_owned(),
),
);
match response {
Some(RpcWebSocketFrame::Text(text)) => {
assert!(text.starts_with('['));
assert!(text.contains(r#""id":41"#));
assert!(text.contains(r#""version""#));
assert!(!text.contains(r#""code":-32600"#));
}
other => panic!("unexpected websocket mixed batch response: {other:?}"),
}
}
#[test]
fn websocket_empty_batch_returns_empty_array_response() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_websocket_rpc_frame(
&mut dispatcher,
&RpcServerConfig::default(),
RpcWebSocketFrame::Text("[]".to_owned()),
);
assert_eq!(response, Some(RpcWebSocketFrame::Text("[]".to_owned())));
}
#[test]
fn websocket_batch_with_only_non_object_members_returns_empty_array_response() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_websocket_rpc_frame(
&mut dispatcher,
&RpcServerConfig::default(),
RpcWebSocketFrame::Text(r#"[7,true,"noop"]"#.to_owned()),
);
assert_eq!(response, Some(RpcWebSocketFrame::Text("[]".to_owned())));
}
#[test]
fn websocket_request_without_id_returns_invalid_request_frame() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_websocket_rpc_frame(
&mut dispatcher,
&RpcServerConfig::default(),
RpcWebSocketFrame::Text(
r#"{"jsonrpc":"2.0","method":"aria2.getVersion","params":[]}"#.to_owned(),
),
);
match response {
Some(RpcWebSocketFrame::Text(text)) => {
assert!(text.contains(r#""id":null"#));
assert!(text.contains(r#""code":-32600"#));
assert!(text.contains(r#""message":"Invalid Request.""#));
}
other => panic!("unexpected websocket invalid-request response: {other:?}"),
}
}
#[test]
fn websocket_ping_returns_pong() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_websocket_rpc_frame(
&mut dispatcher,
&RpcServerConfig::default(),
RpcWebSocketFrame::Ping(vec![1, 2, 3, 4]),
);
assert_eq!(response, Some(RpcWebSocketFrame::Pong(vec![1, 2, 3, 4])));
}
#[test]
fn websocket_response_frame_serializes_as_text_opcode() {
let bytes = websocket_frame_to_bytes(&RpcWebSocketFrame::Text("ok".to_owned()));
assert_eq!(bytes, vec![0x81, 0x02, b'o', b'k']);
}
@@ -0,0 +1,205 @@
use super::*;
#[test]
fn serves_websocket_upgrade_handshake_on_jsonrpc_path() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig {
enable_websocket_rpc: true,
..RpcServerConfig::default()
},
HttpRequest {
method: "GET".to_owned(),
path: "/jsonrpc".to_owned(),
headers: BTreeMap::from([
("upgrade".to_owned(), "websocket".to_owned()),
("connection".to_owned(), "Upgrade".to_owned()),
(
"sec-websocket-key".to_owned(),
"dGhlIHNhbXBsZSBub25jZQ==".to_owned(),
),
("sec-websocket-version".to_owned(), "13".to_owned()),
]),
body: Vec::new(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("101 Switching Protocols"));
assert!(response.contains("Upgrade: websocket"));
assert!(response.contains("Connection: Upgrade"));
assert!(response.contains("Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo="));
}
#[test]
fn serves_websocket_upgrade_with_case_insensitive_header_names_and_values() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig {
enable_websocket_rpc: true,
..RpcServerConfig::default()
},
HttpRequest {
method: "GET".to_owned(),
path: "/jsonrpc".to_owned(),
headers: BTreeMap::from([
("Upgrade".to_owned(), "WebSocket".to_owned()),
("Connection".to_owned(), "keep-alive, Upgrade".to_owned()),
(
"Sec-WebSocket-Key".to_owned(),
"dGhlIHNhbXBsZSBub25jZQ==".to_owned(),
),
("Sec-WebSocket-Version".to_owned(), "13".to_owned()),
]),
body: Vec::new(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("101 Switching Protocols"));
assert!(response.contains("Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo="));
}
#[test]
fn websocket_upgrade_does_not_echo_subprotocol_header() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig {
enable_websocket_rpc: true,
..RpcServerConfig::default()
},
HttpRequest {
method: "GET".to_owned(),
path: "/jsonrpc".to_owned(),
headers: BTreeMap::from([
("upgrade".to_owned(), "websocket".to_owned()),
("connection".to_owned(), "keep-alive, Upgrade".to_owned()),
(
"sec-websocket-key".to_owned(),
"dGhlIHNhbXBsZSBub25jZQ==".to_owned(),
),
("sec-websocket-version".to_owned(), "13".to_owned()),
(
"sec-websocket-protocol".to_owned(),
"graphql-ws, jsonrpc".to_owned(),
),
]),
body: Vec::new(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("101 Switching Protocols"));
assert!(!response.contains("Sec-WebSocket-Protocol:"));
}
#[test]
fn rejects_websocket_upgrade_when_disabled() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig::default(),
HttpRequest {
method: "GET".to_owned(),
path: "/jsonrpc".to_owned(),
headers: BTreeMap::from([
("upgrade".to_owned(), "websocket".to_owned()),
("connection".to_owned(), "Upgrade".to_owned()),
(
"sec-websocket-key".to_owned(),
"dGhlIHNhbXBsZSBub25jZQ==".to_owned(),
),
("sec-websocket-version".to_owned(), "13".to_owned()),
]),
body: Vec::new(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("404 Not Found"));
assert!(response.contains("websocket-rpc disabled"));
}
#[test]
fn rejects_websocket_upgrade_with_missing_key() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig {
enable_websocket_rpc: true,
..RpcServerConfig::default()
},
HttpRequest {
method: "GET".to_owned(),
path: "/jsonrpc".to_owned(),
headers: BTreeMap::from([
("upgrade".to_owned(), "websocket".to_owned()),
("connection".to_owned(), "Upgrade".to_owned()),
("sec-websocket-version".to_owned(), "13".to_owned()),
]),
body: Vec::new(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("400 Bad Request"));
assert!(response.contains("missing sec-websocket-key"));
}
#[test]
fn rejects_websocket_upgrade_with_unsupported_version_using_426() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig {
enable_websocket_rpc: true,
..RpcServerConfig::default()
},
HttpRequest {
method: "GET".to_owned(),
path: "/jsonrpc".to_owned(),
headers: BTreeMap::from([
("upgrade".to_owned(), "websocket".to_owned()),
("connection".to_owned(), "Upgrade".to_owned()),
(
"sec-websocket-key".to_owned(),
"dGhlIHNhbXBsZSBub25jZQ==".to_owned(),
),
("sec-websocket-version".to_owned(), "12".to_owned()),
]),
body: Vec::new(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("426 Upgrade Required"));
assert!(response.contains("Sec-WebSocket-Version: 13"));
assert!(response.contains("unsupported websocket version"));
}
#[test]
fn serves_websocket_upgrade_handshake_on_normalized_jsonrpc_path() {
let mut dispatcher = InProcessRpcDispatcher::new();
let response = handle_rpc_http_request(
&mut dispatcher,
&RpcServerConfig {
enable_websocket_rpc: true,
..RpcServerConfig::default()
},
HttpRequest {
method: "GET".to_owned(),
path: "/jsonrpc/?transport=ws".to_owned(),
headers: BTreeMap::from([
("upgrade".to_owned(), "websocket".to_owned()),
("connection".to_owned(), "keep-alive, Upgrade".to_owned()),
(
"sec-websocket-key".to_owned(),
"dGhlIHNhbXBsZSBub25jZQ==".to_owned(),
),
("sec-websocket-version".to_owned(), "13".to_owned()),
]),
body: Vec::new(),
},
);
let response = String::from_utf8(response).expect("utf8 response");
assert!(response.contains("101 Switching Protocols"));
assert!(response.contains("Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo="));
}
@@ -0,0 +1,331 @@
use super::*;
#[test]
fn websocket_session_processes_multiple_requests_until_close() {
let mut dispatcher = InProcessRpcDispatcher::new();
let responses = process_websocket_session_frames(
&mut dispatcher,
&RpcServerConfig::default(),
vec![
RpcWebSocketFrame::Text(
r#"{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]}"#.to_owned(),
),
RpcWebSocketFrame::Ping(vec![9, 8, 7]),
RpcWebSocketFrame::Text(
r#"{"jsonrpc":"2.0","id":2,"method":"system.listMethods","params":[]}"#.to_owned(),
),
RpcWebSocketFrame::Close,
RpcWebSocketFrame::Text(
r#"{"jsonrpc":"2.0","id":3,"method":"aria2.getVersion","params":[]}"#.to_owned(),
),
],
);
assert_eq!(responses.len(), 4);
match &responses[0] {
RpcWebSocketFrame::Text(text) => {
assert!(text.contains(r#""id":1"#));
assert!(text.contains(r#""version""#));
}
other => panic!("unexpected first websocket response: {other:?}"),
}
assert_eq!(responses[1], RpcWebSocketFrame::Pong(vec![9, 8, 7]));
match &responses[2] {
RpcWebSocketFrame::Text(text) => {
assert!(text.contains(r#""id":2"#));
assert!(text.contains("system.listMethods"));
}
other => panic!("unexpected third websocket response: {other:?}"),
}
assert_eq!(responses[3], RpcWebSocketFrame::Close);
}
#[test]
fn websocket_session_returns_invalid_request_for_missing_id_and_continues() {
let mut dispatcher = InProcessRpcDispatcher::new();
let responses = process_websocket_session_frames(
&mut dispatcher,
&RpcServerConfig::default(),
vec![
RpcWebSocketFrame::Text(
r#"{"jsonrpc":"2.0","method":"aria2.getVersion","params":[]}"#.to_owned(),
),
RpcWebSocketFrame::Text(
r#"{"jsonrpc":"2.0","id":4,"method":"aria2.getVersion","params":[]}"#.to_owned(),
),
],
);
assert_eq!(responses.len(), 2);
match &responses[0] {
RpcWebSocketFrame::Text(text) => {
assert!(text.contains(r#""id":null"#));
assert!(text.contains(r#""code":-32600"#));
}
other => panic!("unexpected websocket invalid-request response: {other:?}"),
}
match &responses[1] {
RpcWebSocketFrame::Text(text) => assert!(text.contains(r#""id":4"#)),
other => panic!("unexpected websocket response after invalid request: {other:?}"),
}
}
#[test]
fn flush_websocket_session_queue_writes_all_pending_frames_in_order() {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener should bind");
let addr = listener
.local_addr()
.expect("listener address should resolve");
let writer = thread::spawn(move || {
let mut stream = TcpStream::connect(addr).expect("client should connect");
let mut sessions = WebSocketSessionRegistry::default();
sessions.connect("sess-1");
sessions.queue_event_for_all(&RpcNotificationEvent {
kind: RpcNotificationKind::DownloadStarted,
method: String::new(),
gid: Some("abc".to_owned()),
payload: None,
meta: RpcMeta::default(),
});
sessions.queue_frame_for_session("sess-1", RpcWebSocketFrame::Ping(vec![7, 8, 9]));
flush_websocket_session_queue(&mut stream, &mut sessions, "sess-1")
.expect("queued websocket frames should flush");
assert_eq!(sessions.pending_count("sess-1"), Some(0));
});
let (mut accepted, _) = listener.accept().expect("server side should accept");
let first = read_websocket_frame(&mut accepted)
.expect("first frame should read")
.expect("first frame should exist");
let second = read_websocket_frame(&mut accepted)
.expect("second frame should read")
.expect("second frame should exist");
writer.join().expect("writer thread should join");
match first {
RpcWebSocketFrame::Text(text) => {
assert!(text.contains("aria2.onDownloadStart"));
assert!(text.contains(r#""gid":"abc""#));
}
other => panic!("unexpected first flushed frame: {other:?}"),
}
assert_eq!(second, RpcWebSocketFrame::Ping(vec![7, 8, 9]));
}
#[test]
fn flush_websocket_session_queue_shared_writes_pending_frames_without_borrowing_registry() {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener should bind");
let addr = listener
.local_addr()
.expect("listener address should resolve");
let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default()));
let writer_sessions = Arc::clone(&sessions);
let writer = thread::spawn(move || {
let mut stream = TcpStream::connect(addr).expect("client should connect");
{
let mut sessions = writer_sessions
.lock()
.expect("sessions lock should succeed");
sessions.connect("sess-1");
sessions.queue_frame_for_session("sess-1", RpcWebSocketFrame::Ping(vec![1]));
sessions.queue_frame_for_session("sess-1", RpcWebSocketFrame::Ping(vec![2]));
}
flush_websocket_session_queue_shared(&mut stream, &writer_sessions, "sess-1")
.expect("shared queued websocket frames should flush");
assert_eq!(
writer_sessions
.lock()
.expect("sessions lock should succeed")
.pending_count("sess-1"),
Some(0)
);
});
let (mut accepted, _) = listener.accept().expect("server side should accept");
let first = read_websocket_frame(&mut accepted)
.expect("first shared frame should read")
.expect("first shared frame should exist");
let second = read_websocket_frame(&mut accepted)
.expect("second shared frame should read")
.expect("second shared frame should exist");
writer.join().expect("writer thread should join");
assert_eq!(first, RpcWebSocketFrame::Ping(vec![1]));
assert_eq!(second, RpcWebSocketFrame::Ping(vec![2]));
}
#[test]
fn upgraded_websocket_session_processes_masked_request_then_flushes_notification() {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener should bind");
let addr = listener
.local_addr()
.expect("listener address should resolve");
let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default()));
let server_sessions = Arc::clone(&sessions);
let server = thread::spawn(move || {
let (stream, _) = listener
.accept()
.expect("server should accept websocket peer");
serve_upgraded_websocket_session(
stream,
&RpcServerConfig {
enable_websocket_rpc: true,
..RpcServerConfig::default()
},
Arc::new(Mutex::new(InProcessRpcDispatcher::new())),
server_sessions,
"sess-live-request".to_owned(),
)
.expect("websocket session should complete cleanly");
});
let mut client = TcpStream::connect(addr).expect("client should connect");
client
.set_read_timeout(Some(Duration::from_secs(2)))
.expect("client read timeout should configure");
for _ in 0..20 {
if sessions
.lock()
.expect("sessions lock should succeed")
.contains("sess-live-request")
{
break;
}
thread::sleep(Duration::from_millis(20));
}
let request = masked_text_frame_bytes(
r#"{"jsonrpc":"2.0","id":51,"method":"aria2.getVersion","params":[]}"#,
);
client
.write_all(&request)
.expect("client websocket request should write");
client
.flush()
.expect("client websocket request should flush");
let response = read_websocket_frame(&mut client)
.expect("client should receive websocket RPC response")
.expect("response frame should exist");
match response {
RpcWebSocketFrame::Text(text) => {
assert!(text.contains(r#""id":51"#));
assert!(text.contains(r#""version""#));
}
other => panic!("unexpected websocket response frame: {other:?}"),
}
sessions
.lock()
.expect("sessions lock should succeed")
.queue_event_for_all(&RpcNotificationEvent {
kind: RpcNotificationKind::DownloadComplete,
method: String::new(),
gid: Some("after-request".to_owned()),
payload: None,
meta: RpcMeta::default(),
});
let notification = read_websocket_frame(&mut client)
.expect("client should receive websocket notification after request")
.expect("notification frame should exist");
match notification {
RpcWebSocketFrame::Text(text) => {
assert!(text.contains("aria2.onDownloadComplete"));
assert!(text.contains(r#""gid":"after-request""#));
}
other => panic!("unexpected websocket notification frame: {other:?}"),
}
client
.shutdown(Shutdown::Both)
.expect("client shutdown should succeed");
drop(client);
server.join().expect("server thread should join");
assert!(
!sessions
.lock()
.expect("sessions lock should succeed")
.contains("sess-live-request")
);
}
#[test]
fn upgraded_websocket_session_flushes_runtime_events_while_client_is_idle() {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener should bind");
let addr = listener
.local_addr()
.expect("listener address should resolve");
let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default()));
let server_sessions = Arc::clone(&sessions);
let server = thread::spawn(move || {
let (stream, _) = listener
.accept()
.expect("server should accept websocket peer");
serve_upgraded_websocket_session(
stream,
&RpcServerConfig {
enable_websocket_rpc: true,
..RpcServerConfig::default()
},
Arc::new(Mutex::new(InProcessRpcDispatcher::new())),
server_sessions,
"sess-live".to_owned(),
)
.expect("websocket session should complete cleanly");
});
let mut client = TcpStream::connect(addr).expect("client should connect");
client
.set_read_timeout(Some(Duration::from_secs(2)))
.expect("client read timeout should configure");
for _ in 0..20 {
if sessions
.lock()
.expect("sessions lock should succeed")
.contains("sess-live")
{
break;
}
thread::sleep(Duration::from_millis(20));
}
sessions
.lock()
.expect("sessions lock should succeed")
.queue_event_for_all(&RpcNotificationEvent {
kind: RpcNotificationKind::DownloadStarted,
method: String::new(),
gid: Some("idle-gid".to_owned()),
payload: None,
meta: RpcMeta::default(),
});
let frame = read_websocket_frame(&mut client)
.expect("idle client should receive queued websocket notification")
.expect("notification frame should exist");
match frame {
RpcWebSocketFrame::Text(text) => {
assert!(text.contains("aria2.onDownloadStart"));
assert!(text.contains(r#""gid":"idle-gid""#));
}
other => panic!("unexpected idle websocket frame: {other:?}"),
}
client
.shutdown(Shutdown::Both)
.expect("client shutdown should succeed");
drop(client);
server.join().expect("server thread should join");
assert!(
!sessions
.lock()
.expect("sessions lock should succeed")
.contains("sess-live")
);
}
@@ -0,0 +1,463 @@
use std::{
io::{self, Write},
net::{TcpListener, TcpStream},
sync::{
Arc, Mutex,
mpsc::{self, Receiver, Sender, TryRecvError},
},
thread,
time::Duration,
};
use crate::{
InProcessRpcDispatcher,
websocket::{RuntimeEventWebSocketBridge, WebSocketSessionRegistry},
};
use super::{
RpcServerConfig,
http_surface::{
handle_rpc_http_request_shared, is_websocket_upgrade_candidate, read_http_request,
},
websocket_surface::serve_upgraded_websocket_session,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Transport-level tuning knobs for the RPC listener.
pub struct RpcServerTransportConfig {
/// Maximum simultaneously active connection workers allowed by the listener.
pub max_connections: usize,
/// Idle keep-alive window in seconds.
pub keep_alive_secs: u64,
/// Per-request timeout in seconds.
pub request_timeout_secs: u64,
}
impl Default for RpcServerTransportConfig {
fn default() -> Self {
Self {
max_connections: 64,
keep_alive_secs: 30,
request_timeout_secs: 30,
}
}
}
impl RpcServerTransportConfig {
/// Returns the effective per-request timeout, clamped away from zero.
fn request_timeout(self) -> Duration {
Duration::from_secs(self.request_timeout_secs.max(1))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Lightweight transport state exposed for tests and integration wiring.
pub struct RpcServerTransport {
/// Static transport configuration.
pub config: RpcServerTransportConfig,
/// Number of accepted connections observed so far.
pub accepted_connections: usize,
}
impl RpcServerTransport {
#[must_use]
/// Creates a new transport state wrapper from the provided configuration.
pub fn new(config: RpcServerTransportConfig) -> Self {
Self {
config,
accepted_connections: 0,
}
}
}
/// Serves RPC requests for the provided configuration and dispatcher.
///
/// # Errors
///
/// Returns an error when listener setup, socket accept, or transport I/O fails.
pub fn serve_rpc_listener(
listener: TcpListener,
config: RpcServerConfig,
dispatcher: Arc<Mutex<InProcessRpcDispatcher>>,
) -> io::Result<()> {
RpcListenerRuntime::new(
listener,
config,
RpcServerTransport::new(RpcServerTransportConfig::default()),
dispatcher,
)?
.serve()
}
/// Listener runtime that bounds accepted connection workers and WebSocket session state.
struct RpcListenerRuntime {
/// Bound TCP listener used to accept RPC connections.
listener: TcpListener,
/// Shared RPC server configuration applied to each accepted connection.
config: RpcServerConfig,
/// Transport-level counters and tuning knobs for the listener loop.
transport: RpcServerTransport,
/// Shared in-process RPC dispatcher used by HTTP and WebSocket requests.
dispatcher: Arc<Mutex<InProcessRpcDispatcher>>,
/// Shared WebSocket session registry bridged from runtime events.
websocket_sessions: Arc<Mutex<WebSocketSessionRegistry>>,
/// Monotonic identifier source for upgraded WebSocket sessions.
next_websocket_session_id: u64,
/// Number of connection workers currently running.
active_connection_workers: usize,
/// Receive side of worker completion notifications.
worker_results_rx: Receiver<io::Result<()>>,
/// Send side cloned into worker threads for completion notifications.
worker_results_tx: Sender<io::Result<()>>,
}
impl RpcListenerRuntime {
/// Builds a nonblocking listener runtime and attaches the runtime event bridge.
fn new(
listener: TcpListener,
config: RpcServerConfig,
transport: RpcServerTransport,
dispatcher: Arc<Mutex<InProcessRpcDispatcher>>,
) -> io::Result<Self> {
listener.set_nonblocking(true)?;
let websocket_sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default()));
let (worker_results_tx, worker_results_rx) = mpsc::channel();
{
let mut dispatcher = dispatcher
.lock()
.map_err(|_| io::Error::other("rpc dispatcher mutex poisoned"))?;
dispatcher.register_runtime_listener(RuntimeEventWebSocketBridge::new(Arc::clone(
&websocket_sessions,
)));
}
Ok(Self {
listener,
config,
transport,
dispatcher,
websocket_sessions,
next_websocket_session_id: 1,
active_connection_workers: 0,
worker_results_rx,
worker_results_tx,
})
}
/// Serves accepted connections until the listener returns a terminal I/O error.
fn serve(mut self) -> io::Result<()> {
loop {
self.drain_completed_connection_workers()?;
if self.active_connection_workers >= self.connection_worker_limit() {
self.wait_for_connection_capacity()?;
continue;
}
match self.listener.accept() {
Ok((stream, _addr)) => self.spawn_ready_connection_worker(stream),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(25));
}
Err(error) => return Err(error),
}
}
}
/// Starts a worker thread for one accepted connection and tracks its completion.
fn spawn_ready_connection_worker(&mut self, stream: TcpStream) {
self.transport.accepted_connections = self.transport.accepted_connections.saturating_add(1);
self.active_connection_workers = self.active_connection_workers.saturating_add(1);
let session_id = format!("ws-{}", self.next_websocket_session_id);
self.next_websocket_session_id = self.next_websocket_session_id.saturating_add(1);
let request_timeout = self.transport.config.request_timeout();
let config = self.config.clone();
let dispatcher = Arc::clone(&self.dispatcher);
let websocket_sessions = Arc::clone(&self.websocket_sessions);
let worker_results_tx = self.worker_results_tx.clone();
thread::spawn(move || {
let result = serve_ready_connection(
stream,
request_timeout,
&config,
dispatcher,
websocket_sessions,
session_id,
);
let _ = worker_results_tx.send(result);
});
}
/// Returns the effective maximum number of concurrent connection workers.
fn connection_worker_limit(&self) -> usize {
self.transport.config.max_connections.max(1)
}
/// Drains all currently completed worker results and propagates terminal errors.
fn drain_completed_connection_workers(&mut self) -> io::Result<()> {
loop {
match self.worker_results_rx.try_recv() {
Ok(result) => {
self.active_connection_workers =
self.active_connection_workers.saturating_sub(1);
result?;
}
Err(TryRecvError::Empty) => return Ok(()),
Err(TryRecvError::Disconnected) => {
if self.active_connection_workers == 0 {
return Ok(());
}
return Err(io::Error::other("rpc worker result channel disconnected"));
}
}
}
}
/// Waits briefly until at least one connection-worker slot becomes available.
fn wait_for_connection_capacity(&mut self) -> io::Result<()> {
match self
.worker_results_rx
.recv_timeout(Duration::from_millis(25))
{
Ok(result) => {
self.active_connection_workers = self.active_connection_workers.saturating_sub(1);
result?;
self.drain_completed_connection_workers()
}
Err(mpsc::RecvTimeoutError::Timeout) => Ok(()),
Err(mpsc::RecvTimeoutError::Disconnected) => {
if self.active_connection_workers == 0 {
return Ok(());
}
Err(io::Error::other("rpc worker result channel disconnected"))
}
}
}
#[cfg(test)]
fn serve_until_accept_count(mut self, accept_limit: usize) -> io::Result<()> {
loop {
self.drain_completed_connection_workers()?;
if self.transport.accepted_connections >= accept_limit {
if self.active_connection_workers == 0 {
return Ok(());
}
self.wait_for_connection_capacity()?;
continue;
}
if self.active_connection_workers >= self.connection_worker_limit() {
self.wait_for_connection_capacity()?;
continue;
}
match self.listener.accept() {
Ok((stream, _addr)) => self.spawn_ready_connection_worker(stream),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(error) => return Err(error),
}
}
}
}
/// Serves one already-accepted TCP connection through HTTP or WebSocket RPC handling.
fn serve_ready_connection(
mut stream: TcpStream,
request_timeout: Duration,
config: &RpcServerConfig,
dispatcher: Arc<Mutex<InProcessRpcDispatcher>>,
websocket_sessions: Arc<Mutex<WebSocketSessionRegistry>>,
session_id: String,
) -> io::Result<()> {
// The listener runs in nonblocking mode so accept loops can poll capacity,
// but worker-owned client streams should use blocking IO with timeouts.
stream.set_nonblocking(false)?;
stream.set_write_timeout(Some(request_timeout))?;
if let Some(request) = read_http_request(&mut stream, request_timeout)? {
let websocket_candidate = is_websocket_upgrade_candidate(&request);
let response = handle_rpc_http_request_shared(&dispatcher, config, request)?;
let _ = stream.write_all(&response);
let _ = stream.flush();
if websocket_candidate && response.starts_with(b"HTTP/1.1 101 ") {
serve_upgraded_websocket_session(
stream,
config,
dispatcher,
websocket_sessions,
session_id,
)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::{
io::{self, Read},
net::Shutdown,
};
use super::*;
fn http_header_end(buffer: &[u8]) -> Option<usize> {
buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|position| position + 4)
}
fn expected_http_response_len(buffer: &[u8]) -> Option<usize> {
let header_end = http_header_end(buffer)?;
let headers = std::str::from_utf8(&buffer[..header_end])
.expect("http response headers should be valid utf8");
let content_length = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.eq_ignore_ascii_case("Content-Length") {
return Some(
value
.trim()
.parse::<usize>()
.expect("content-length should be a valid usize"),
);
}
None
})
.unwrap_or(0);
Some(header_end + content_length)
}
fn read_http_response(stream: &mut TcpStream) -> String {
let mut buffer = Vec::new();
let mut chunk = [0_u8; 512];
loop {
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(read) => {
buffer.extend_from_slice(&chunk[..read]);
if let Some(expected_len) = expected_http_response_len(&buffer)
&& buffer.len() >= expected_len
{
break;
}
}
Err(error) if error.kind() == io::ErrorKind::ConnectionReset => {
if let Some(expected_len) = expected_http_response_len(&buffer)
&& buffer.len() >= expected_len
{
break;
}
if http_header_end(&buffer).is_some() {
break;
}
panic!(
"http response reset before completion: {error}; partial={}",
String::from_utf8_lossy(&buffer)
);
}
Err(error) => panic!("http response should be readable: {error}"),
}
}
String::from_utf8(buffer).expect("response should be valid utf8")
}
#[test]
fn accepted_websocket_worker_does_not_block_a_second_http_rpc_client() {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener should bind");
let addr = listener
.local_addr()
.expect("listener address should resolve");
let runtime = RpcListenerRuntime::new(
listener,
RpcServerConfig {
enable_websocket_rpc: true,
..RpcServerConfig::default()
},
RpcServerTransport::new(RpcServerTransportConfig {
max_connections: 2,
..RpcServerTransportConfig::default()
}),
Arc::new(Mutex::new(InProcessRpcDispatcher::new())),
)
.expect("listener runtime should initialize");
let server = thread::spawn(move || {
runtime
.serve_until_accept_count(2)
.expect("listener runtime should serve two clients");
});
let mut websocket_client =
TcpStream::connect(addr).expect("websocket client should connect");
websocket_client
.set_read_timeout(Some(Duration::from_secs(2)))
.expect("websocket client timeout should configure");
websocket_client
.write_all(
concat!(
"GET /jsonrpc HTTP/1.1\r\n",
"Host: 127.0.0.1\r\n",
"Upgrade: websocket\r\n",
"Connection: Upgrade\r\n",
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n",
"Sec-WebSocket-Version: 13\r\n",
"\r\n"
)
.as_bytes(),
)
.expect("websocket upgrade request should write");
websocket_client
.flush()
.expect("websocket upgrade request should flush");
let websocket_handshake = read_http_response(&mut websocket_client);
assert!(
websocket_handshake.starts_with("HTTP/1.1 101 "),
"unexpected websocket handshake: {websocket_handshake:?}"
);
let mut http_client = TcpStream::connect(addr).expect("http client should connect");
http_client
.set_read_timeout(Some(Duration::from_secs(2)))
.expect("http client timeout should configure");
let jsonrpc_body = r#"{"jsonrpc":"2.0","id":7,"method":"aria2.getVersion","params":[]}"#;
let http_request = format!(
concat!(
"POST /jsonrpc HTTP/1.1\r\n",
"Host: 127.0.0.1\r\n",
"Content-Type: application/json\r\n",
"Content-Length: {}\r\n",
"\r\n",
"{}"
),
jsonrpc_body.len(),
jsonrpc_body
);
http_client
.write_all(http_request.as_bytes())
.expect("jsonrpc request should write");
http_client.flush().expect("jsonrpc request should flush");
let http_response = read_http_response(&mut http_client);
assert!(
http_response.contains("200 OK"),
"unexpected http status response: {http_response:?}"
);
assert!(
http_response.contains(r#""id":7"#),
"missing jsonrpc id in response: {http_response:?}"
);
assert!(
http_response.contains(r#""version""#),
"missing version payload in response: {http_response:?}"
);
http_client
.shutdown(Shutdown::Both)
.expect("http client shutdown should succeed");
websocket_client
.shutdown(Shutdown::Both)
.expect("websocket client shutdown should succeed");
drop(http_client);
drop(websocket_client);
server.join().expect("server thread should join");
}
}
@@ -0,0 +1,177 @@
use std::{
io,
sync::{Arc, Mutex},
};
use crate::{
InProcessRpcDispatcher,
jsonrpc::{
JsonRpcPayload, JsonRpcResponse, jsonrpc_batch_response_to_json, jsonrpc_payload_from_json,
jsonrpc_response_to_json,
},
model::{RpcError, RpcErrorCode},
websocket::RpcWebSocketFrame,
};
use super::{RpcServerConfig, http_surface::dispatch_json_request};
/// Converts a WebSocket parse failure into the JSON-RPC error frame expected by clients.
fn websocket_parse_error_response(message: String) -> RpcWebSocketFrame {
let response = JsonRpcResponse::error(
None,
RpcError {
code: if message.contains("must not be empty") {
RpcErrorCode::InvalidRequest
} else {
RpcErrorCode::ParseError
},
kind: crate::model::RpcErrorKind::InvalidParams,
message,
},
);
let body = jsonrpc_response_to_json(&response)
.unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#));
RpcWebSocketFrame::Text(body)
}
/// Dispatches a text WebSocket frame as a JSON-RPC request or batch payload.
#[cfg(test)]
fn dispatch_websocket_text_frame(
dispatcher: &mut InProcessRpcDispatcher,
config: &RpcServerConfig,
text: &str,
) -> Option<RpcWebSocketFrame> {
match parse_websocket_jsonrpc_payload(text) {
Ok(payload) => dispatch_websocket_payload(dispatcher, config, payload),
Err(error) => Some(websocket_parse_error_response(error)),
}
}
/// Parses a WebSocket JSON-RPC payload before any dispatcher locking occurs.
fn parse_websocket_jsonrpc_payload(text: &str) -> Result<JsonRpcPayload, String> {
jsonrpc_payload_from_json(text)
}
/// Dispatches a previously parsed WebSocket JSON-RPC payload.
fn dispatch_websocket_payload(
dispatcher: &mut InProcessRpcDispatcher,
config: &RpcServerConfig,
payload: JsonRpcPayload,
) -> Option<RpcWebSocketFrame> {
match payload {
JsonRpcPayload::Single(request) => {
let response = dispatch_json_request(dispatcher, config, request);
if response.id.is_none() {
return None;
}
let body = jsonrpc_response_to_json(&response)
.unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#));
Some(RpcWebSocketFrame::Text(body))
}
JsonRpcPayload::Batch(items) => {
let mut responses = Vec::new();
for item in items {
match item {
Ok(request) => {
let response = dispatch_json_request(dispatcher, config, request);
if response.id.is_some() {
responses.push(response);
}
}
Err(error) => responses.push(JsonRpcResponse::error(
None,
RpcError {
code: RpcErrorCode::InvalidRequest,
kind: crate::model::RpcErrorKind::InvalidParams,
message: error,
},
)),
}
}
let body = jsonrpc_batch_response_to_json(&responses)
.unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#));
Some(RpcWebSocketFrame::Text(body))
}
}
}
/// Routes a decoded WebSocket frame through shared dispatcher access while keeping
/// control-frame and parse-error handling outside the dispatcher mutex.
pub(super) fn handle_websocket_rpc_frame_shared(
dispatcher: &Arc<Mutex<InProcessRpcDispatcher>>,
config: &RpcServerConfig,
frame: RpcWebSocketFrame,
) -> io::Result<Option<RpcWebSocketFrame>> {
match frame {
RpcWebSocketFrame::Text(text) => {
dispatch_websocket_text_frame_shared(dispatcher, config, &text)
}
RpcWebSocketFrame::Binary(payload) => match String::from_utf8(payload) {
Ok(text) => dispatch_websocket_text_frame_shared(dispatcher, config, &text),
Err(error) => Ok(Some(websocket_parse_error_response(error.to_string()))),
},
RpcWebSocketFrame::Ping(payload) => Ok(Some(RpcWebSocketFrame::Pong(payload))),
RpcWebSocketFrame::Pong(_) => Ok(None),
RpcWebSocketFrame::Close => Ok(Some(RpcWebSocketFrame::Close)),
}
}
/// Dispatches a text WebSocket frame while locking the dispatcher only for the
/// actual JSON-RPC execution path.
fn dispatch_websocket_text_frame_shared(
dispatcher: &Arc<Mutex<InProcessRpcDispatcher>>,
config: &RpcServerConfig,
text: &str,
) -> io::Result<Option<RpcWebSocketFrame>> {
let payload = match parse_websocket_jsonrpc_payload(text) {
Ok(payload) => payload,
Err(error) => return Ok(Some(websocket_parse_error_response(error))),
};
let mut dispatcher = dispatcher
.lock()
.map_err(|_| io::Error::other("rpc dispatcher mutex poisoned"))?;
Ok(dispatch_websocket_payload(&mut dispatcher, config, payload))
}
/// Routes a decoded WebSocket frame through the RPC dispatch rules.
#[cfg(test)]
pub(super) fn handle_websocket_rpc_frame(
dispatcher: &mut InProcessRpcDispatcher,
config: &RpcServerConfig,
frame: RpcWebSocketFrame,
) -> Option<RpcWebSocketFrame> {
match frame {
RpcWebSocketFrame::Text(text) => dispatch_websocket_text_frame(dispatcher, config, &text),
RpcWebSocketFrame::Binary(payload) => match String::from_utf8(payload) {
Ok(text) => dispatch_websocket_text_frame(dispatcher, config, &text),
Err(error) => Some(websocket_parse_error_response(error.to_string())),
},
RpcWebSocketFrame::Ping(payload) => Some(RpcWebSocketFrame::Pong(payload)),
RpcWebSocketFrame::Pong(_) => None,
RpcWebSocketFrame::Close => Some(RpcWebSocketFrame::Close),
}
}
#[cfg(test)]
/// Processes a sequence of test-only frames until one requests that the session close.
pub(super) fn process_websocket_session_frames(
dispatcher: &mut InProcessRpcDispatcher,
config: &RpcServerConfig,
frames: impl IntoIterator<Item = RpcWebSocketFrame>,
) -> Vec<RpcWebSocketFrame> {
let mut responses = Vec::new();
for frame in frames {
let should_close = matches!(frame, RpcWebSocketFrame::Close);
if let Some(response) = handle_websocket_rpc_frame(dispatcher, config, frame) {
let response_is_close = matches!(response, RpcWebSocketFrame::Close);
responses.push(response);
if response_is_close {
break;
}
}
if should_close {
break;
}
}
responses
}
@@ -0,0 +1,84 @@
use base64::Engine;
use sha1::{Digest, Sha1};
use super::{
RpcServerConfig,
http_surface::{
HttpRequest, contains_ascii_case_insensitive, header_value, http_response,
http_response_with_headers, normalized_rpc_path,
},
};
/// Computes the `Sec-WebSocket-Accept` value for a client-provided handshake key.
fn websocket_accept_value(key: &str) -> String {
let mut sha1 = Sha1::new();
sha1.update(key.as_bytes());
sha1.update(b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
let digest = sha1.finalize();
base64::engine::general_purpose::STANDARD.encode(digest)
}
/// Builds the HTTP upgrade response for a successful RPC WebSocket handshake.
pub(super) fn websocket_upgrade_response(
config: &RpcServerConfig,
request: &HttpRequest,
) -> Vec<u8> {
if !config.enable_websocket_rpc {
return http_response("404 Not Found", "text/plain", b"websocket-rpc disabled");
}
if normalized_rpc_path(&request.path) != "/jsonrpc" {
return http_response("404 Not Found", "text/plain", b"unknown websocket path");
}
if !request.method.eq_ignore_ascii_case("GET") {
return http_response(
"405 Method Not Allowed",
"text/plain",
b"websocket upgrade requires GET",
);
}
let Some(key) = header_value(&request.headers, "sec-websocket-key") else {
return http_response(
"400 Bad Request",
"text/plain",
b"missing sec-websocket-key",
);
};
if !header_value(&request.headers, "upgrade")
.is_some_and(|value| value.eq_ignore_ascii_case("websocket"))
{
return http_response(
"400 Bad Request",
"text/plain",
b"missing websocket upgrade header",
);
}
if !header_value(&request.headers, "connection")
.is_some_and(|value| contains_ascii_case_insensitive(value, "upgrade"))
{
return http_response(
"400 Bad Request",
"text/plain",
b"missing connection upgrade header",
);
}
if header_value(&request.headers, "sec-websocket-version")
.is_none_or(|value| value.trim() != "13")
{
return http_response_with_headers(
"426 Upgrade Required",
Some("text/plain"),
&[("Sec-WebSocket-Version", "13".to_owned())],
b"unsupported websocket version",
);
}
let mut headers = vec![
("Upgrade", "websocket".to_owned()),
("Connection", "Upgrade".to_owned()),
("Sec-WebSocket-Accept", websocket_accept_value(key)),
];
if let Some(origin) = &config.allow_origin {
headers.push(("Access-Control-Allow-Origin", origin.clone()));
}
http_response_with_headers("101 Switching Protocols", None, &headers, b"")
}
@@ -0,0 +1,108 @@
use std::{
io::{self, Write},
net::TcpStream,
sync::{Arc, Mutex},
};
use crate::{
InProcessRpcDispatcher,
websocket::{RpcWebSocketFrame, WebSocketSessionRegistry},
};
use super::{
RpcServerConfig, WEBSOCKET_IDLE_POLL_INTERVAL,
websocket_dispatch::handle_websocket_rpc_frame_shared,
websocket_wire::{read_websocket_frame, websocket_frame_to_bytes},
};
#[cfg(test)]
/// Flushes any queued outbound frames for a WebSocket session to the client stream.
pub(super) fn flush_websocket_session_queue(
stream: &mut TcpStream,
sessions: &mut WebSocketSessionRegistry,
session_id: &str,
) -> io::Result<usize> {
let mut flushed = 0usize;
while let Some(frame) = sessions.pop_frame(session_id) {
let payload = websocket_frame_to_bytes(&frame);
stream.write_all(&payload)?;
stream.flush()?;
flushed += 1;
}
Ok(flushed)
}
/// Flushes queued outbound frames without holding the shared session mutex across socket IO.
pub(super) fn flush_websocket_session_queue_shared(
stream: &mut TcpStream,
sessions: &Arc<Mutex<WebSocketSessionRegistry>>,
session_id: &str,
) -> io::Result<usize> {
let frames = {
let mut sessions = sessions
.lock()
.map_err(|_| io::Error::other("websocket session mutex poisoned"))?;
sessions.drain_session_frames(session_id)
};
let mut flushed = 0usize;
for frame in frames {
let payload = websocket_frame_to_bytes(&frame);
stream.write_all(&payload)?;
stream.flush()?;
flushed += 1;
}
Ok(flushed)
}
/// Runs the request and notification loop for a successfully upgraded RPC WebSocket session.
pub(super) fn serve_upgraded_websocket_session(
mut stream: TcpStream,
config: &RpcServerConfig,
dispatcher: Arc<Mutex<InProcessRpcDispatcher>>,
sessions: Arc<Mutex<WebSocketSessionRegistry>>,
session_id: String,
) -> io::Result<()> {
stream.set_read_timeout(Some(WEBSOCKET_IDLE_POLL_INTERVAL))?;
if let Ok(mut sessions) = sessions.lock() {
sessions.connect(session_id.clone());
}
let result = loop {
let _ = flush_websocket_session_queue_shared(&mut stream, &sessions, &session_id);
let frame = match read_websocket_frame(&mut stream) {
Ok(frame) => frame,
Err(error)
if matches!(
error.kind(),
io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock
) =>
{
continue;
}
Err(error) => break Err(error),
};
let Some(frame) = frame else {
break Ok(());
};
let response_frame = handle_websocket_rpc_frame_shared(&dispatcher, config, frame)?;
if let Some(frame) = response_frame {
let should_close = matches!(frame, RpcWebSocketFrame::Close);
let payload = websocket_frame_to_bytes(&frame);
stream.write_all(&payload)?;
stream.flush()?;
let _ = flush_websocket_session_queue_shared(&mut stream, &sessions, &session_id);
if should_close {
break Ok(());
}
}
};
let _ = flush_websocket_session_queue_shared(&mut stream, &sessions, &session_id);
if let Ok(mut sessions) = sessions.lock() {
sessions.disconnect(&session_id);
}
result
}
@@ -0,0 +1,16 @@
//! WebSocket RPC surface facade.
//!
//! The HTTP server and tests historically imported the WebSocket helpers from
//! this module. Keep that boundary stable while the concrete responsibilities
//! live in narrower sibling modules.
#[cfg(test)]
pub(super) use super::{
websocket_dispatch::{handle_websocket_rpc_frame, process_websocket_session_frames},
websocket_session::flush_websocket_session_queue,
websocket_wire::{read_websocket_frame, websocket_frame_from_bytes, websocket_frame_to_bytes},
};
pub(super) use super::{
websocket_handshake::websocket_upgrade_response,
websocket_session::serve_upgraded_websocket_session,
};
@@ -0,0 +1,177 @@
use std::{
io::{self, Read},
net::TcpStream,
};
use crate::websocket::RpcWebSocketFrame;
/// Encodes an internal WebSocket frame into wire bytes.
pub(super) fn websocket_frame_to_bytes(frame: &RpcWebSocketFrame) -> Vec<u8> {
let (opcode, payload): (u8, Vec<u8>) = match frame {
RpcWebSocketFrame::Text(text) => (0x1, text.as_bytes().to_vec()),
RpcWebSocketFrame::Binary(payload) => (0x2, payload.clone()),
RpcWebSocketFrame::Ping(payload) => (0x9, payload.clone()),
RpcWebSocketFrame::Pong(payload) => (0xA, payload.clone()),
RpcWebSocketFrame::Close => (0x8, Vec::new()),
};
let mut bytes = Vec::with_capacity(payload.len() + 10);
bytes.push(0x80 | opcode);
let payload_len = payload.len();
if payload_len <= 125 {
bytes.push(payload_len as u8);
} else if u16::try_from(payload_len).is_ok() {
bytes.push(126);
bytes.extend_from_slice(&(payload_len as u16).to_be_bytes());
} else {
bytes.push(127);
bytes.extend_from_slice(&(payload_len as u64).to_be_bytes());
}
bytes.extend_from_slice(&payload);
bytes
}
/// Decodes a complete WebSocket frame from wire bytes.
pub(super) fn websocket_frame_from_bytes(bytes: &[u8]) -> Result<RpcWebSocketFrame, String> {
if bytes.len() < 2 {
return Err("websocket frame too short".to_owned());
}
let fin = bytes[0] & 0x80 != 0;
if !fin {
return Err("fragmented websocket frames are unsupported".to_owned());
}
let opcode = bytes[0] & 0x0F;
let masked = bytes[1] & 0x80 != 0;
let mut payload_len = usize::from(bytes[1] & 0x7F);
let mut offset = 2usize;
if payload_len == 126 {
if bytes.len() < offset + 2 {
return Err("truncated websocket extended length".to_owned());
}
payload_len = usize::from(u16::from_be_bytes([bytes[offset], bytes[offset + 1]]));
offset += 2;
} else if payload_len == 127 {
if bytes.len() < offset + 8 {
return Err("truncated websocket extended length".to_owned());
}
payload_len = u64::from_be_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
bytes[offset + 4],
bytes[offset + 5],
bytes[offset + 6],
bytes[offset + 7],
])
.try_into()
.map_err(|_| "websocket frame too large".to_owned())?;
offset += 8;
}
let mask = if masked {
if bytes.len() < offset + 4 {
return Err("truncated websocket mask".to_owned());
}
let mask = [
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
];
offset += 4;
Some(mask)
} else {
None
};
if bytes.len() < offset + payload_len {
return Err("truncated websocket payload".to_owned());
}
let mut payload = bytes[offset..offset + payload_len].to_vec();
if let Some(mask) = mask {
for (index, byte) in payload.iter_mut().enumerate() {
*byte ^= mask[index % 4];
}
}
match opcode {
0x1 => String::from_utf8(payload)
.map(RpcWebSocketFrame::Text)
.map_err(|error| error.to_string()),
0x2 => Ok(RpcWebSocketFrame::Binary(payload)),
0x8 => Ok(RpcWebSocketFrame::Close),
0x9 => Ok(RpcWebSocketFrame::Ping(payload)),
0xA => Ok(RpcWebSocketFrame::Pong(payload)),
_ => Err(format!("unsupported websocket opcode: {opcode}")),
}
}
/// Reads an exact byte count while treating clean EOF as `false` rather than an error.
fn read_exact_or_eof(stream: &mut TcpStream, buffer: &mut [u8]) -> io::Result<bool> {
let mut offset = 0usize;
while offset < buffer.len() {
let read = stream.read(&mut buffer[offset..])?;
if read == 0 {
if offset == 0 {
return Ok(false);
}
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"truncated websocket frame",
));
}
offset += read;
}
Ok(true)
}
/// Reads one WebSocket frame from the upgraded TCP stream.
pub(super) fn read_websocket_frame(
stream: &mut TcpStream,
) -> io::Result<Option<RpcWebSocketFrame>> {
let mut header = [0_u8; 2];
if !read_exact_or_eof(stream, &mut header)? {
return Ok(None);
}
let mut frame = header.to_vec();
let payload_len_marker = usize::from(header[1] & 0x7F);
if payload_len_marker == 126 {
let mut extended = [0_u8; 2];
read_exact_or_eof(stream, &mut extended)?;
frame.extend_from_slice(&extended);
} else if payload_len_marker == 127 {
let mut extended = [0_u8; 8];
read_exact_or_eof(stream, &mut extended)?;
frame.extend_from_slice(&extended);
}
if header[1] & 0x80 != 0 {
let mut mask = [0_u8; 4];
read_exact_or_eof(stream, &mut mask)?;
frame.extend_from_slice(&mask);
}
let payload_len = if payload_len_marker <= 125 {
payload_len_marker
} else if payload_len_marker == 126 {
usize::from(u16::from_be_bytes([frame[2], frame[3]]))
} else {
u64::from_be_bytes([
frame[2], frame[3], frame[4], frame[5], frame[6], frame[7], frame[8], frame[9],
])
.try_into()
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "websocket frame too large"))?
};
let mut payload = vec![0_u8; payload_len];
if payload_len > 0 {
read_exact_or_eof(stream, &mut payload)?;
frame.extend_from_slice(&payload);
}
websocket_frame_from_bytes(&frame)
.map(Some)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
}
+87
View File
@@ -0,0 +1,87 @@
//! Session and token state used by the RPC transports.
use std::{
collections::BTreeMap,
time::{Duration, SystemTime},
};
use crate::model::RpcAuthContext;
#[derive(Debug, Clone, PartialEq, Eq)]
/// Authentication token issued to an RPC client session.
pub struct RpcAuthToken {
/// Opaque token value presented by the client.
pub value: String,
/// Time at which the token was issued.
pub issued_at: SystemTime,
/// Optional validity window for the token.
pub expires_in: Option<Duration>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// Snapshot of a connected RPC session.
pub struct RpcSessionInfo {
/// Stable session identifier.
pub session_id: String,
/// Remote peer address, if the transport exposes it.
pub peer_addr: Option<String>,
/// Authentication state bound to the session.
pub auth: RpcAuthContext,
/// Time at which the session was created.
pub created_at: SystemTime,
/// Most recent activity timestamp.
pub last_seen_at: SystemTime,
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// Complete RPC session state, including token material.
pub struct RpcSession {
/// Session metadata visible to callers.
pub info: RpcSessionInfo,
/// Current authentication token, if one has been issued.
pub token: Option<RpcAuthToken>,
}
#[derive(Debug, Default)]
/// In-memory store for active RPC sessions.
pub struct RpcSessionStore {
/// Session records keyed by their stable session identifier.
sessions: BTreeMap<String, RpcSession>,
}
impl RpcSessionStore {
/// Inserts or replaces a session keyed by its session identifier.
pub fn insert(&mut self, session: RpcSession) {
self.sessions
.insert(session.info.session_id.clone(), session);
}
#[must_use]
/// Returns a shared reference to a session by identifier.
pub fn get(&self, session_id: &str) -> Option<&RpcSession> {
self.sessions.get(session_id)
}
#[must_use]
/// Returns a mutable reference to a session by identifier.
pub fn get_mut(&mut self, session_id: &str) -> Option<&mut RpcSession> {
self.sessions.get_mut(session_id)
}
#[must_use]
/// Removes and returns a session by identifier.
pub fn remove(&mut self, session_id: &str) -> Option<RpcSession> {
self.sessions.remove(session_id)
}
#[must_use]
/// Returns the number of stored sessions.
pub fn len(&self) -> usize {
self.sessions.len()
}
#[must_use]
/// Returns whether the session store contains no sessions.
pub fn is_empty(&self) -> bool {
self.sessions.is_empty()
}
}
@@ -0,0 +1,46 @@
//! WebSocket notification fan-out and framing helpers.
#![expect(
clippy::redundant_pub_crate,
reason = "the websocket surface keeps transport-specific names explicit for protocol parity"
)]
use std::sync::{Arc, Mutex};
use aria2_rust_pro_core::{EventListener, RuntimeEvent};
/// Runtime-event bridge from core notifications into WebSocket queues.
mod bridge;
/// WebSocket notification event and frame model.
mod notification;
/// WebSocket subscription and connected-session registries.
mod registry;
#[cfg(test)]
/// WebSocket notification and session queue tests.
mod tests;
pub use self::notification::{RpcNotificationEvent, RpcNotificationKind, RpcWebSocketFrame};
pub use self::registry::{
WebSocketNotificationRegistry, WebSocketSessionRegistry, WebSocketSessionState,
WebSocketSubscription,
};
#[derive(Debug, Clone)]
/// Runtime event listener that forwards core download events into WebSocket session queues.
pub(super) struct RuntimeEventWebSocketBridge(
/// Internal bridge implementation kept within the WebSocket facade.
bridge::RuntimeEventWebSocketBridge,
);
impl RuntimeEventWebSocketBridge {
/// Builds a bridge backed by the shared WebSocket session registry.
pub(super) const fn new(sessions: Arc<Mutex<WebSocketSessionRegistry>>) -> Self {
Self(bridge::RuntimeEventWebSocketBridge::new(sessions))
}
}
impl EventListener for RuntimeEventWebSocketBridge {
fn on_event(&mut self, event: &RuntimeEvent) {
self.0.on_event(event);
}
}
@@ -0,0 +1,65 @@
use std::sync::{Arc, Mutex};
use aria2_rust_pro_core::{EventListener, RuntimeEvent, RuntimeEventKind};
use crate::model::RpcMeta;
use super::{
RpcNotificationKind, notification::RpcNotificationEvent, registry::WebSocketSessionRegistry,
};
#[derive(Debug, Clone)]
/// Runtime event listener that forwards core download events into WebSocket session queues.
pub(super) struct RuntimeEventWebSocketBridge {
/// Shared session registry updated whenever a compatible runtime event is observed.
sessions: Arc<Mutex<WebSocketSessionRegistry>>,
}
impl RuntimeEventWebSocketBridge {
/// Builds a bridge backed by the shared WebSocket session registry.
pub(super) const fn new(sessions: Arc<Mutex<WebSocketSessionRegistry>>) -> Self {
Self { sessions }
}
}
impl EventListener for RuntimeEventWebSocketBridge {
fn on_event(&mut self, event: &RuntimeEvent) {
let Some(kind) = runtime_event_to_rpc_notification_kind(event.kind) else {
return;
};
let gid = event.gid.map(|gid| gid.to_string());
let rpc_event = RpcNotificationEvent {
kind,
method: String::new(),
gid,
payload: None,
meta: RpcMeta::default(),
};
if let Ok(mut sessions) = self.sessions.lock() {
sessions.queue_broadcast_frame(rpc_event.to_websocket_frame());
}
}
}
/// Maps core runtime events onto the subset of WebSocket notifications exposed over RPC.
const fn runtime_event_to_rpc_notification_kind(
kind: RuntimeEventKind,
) -> Option<RpcNotificationKind> {
match kind {
RuntimeEventKind::DownloadAdded
| RuntimeEventKind::DownloadResumed
| RuntimeEventKind::OptionChanged
| RuntimeEventKind::SessionSaving
| RuntimeEventKind::SessionSaved
| RuntimeEventKind::ShutdownRequested
| RuntimeEventKind::ForceShutdownRequested
| RuntimeEventKind::SchedulerTick
| RuntimeEventKind::StatisticsUpdated
| RuntimeEventKind::PieceUpdated => None,
RuntimeEventKind::DownloadStarted => Some(RpcNotificationKind::DownloadStarted),
RuntimeEventKind::DownloadPaused => Some(RpcNotificationKind::DownloadPaused),
RuntimeEventKind::DownloadRemoved => Some(RpcNotificationKind::DownloadStopped),
RuntimeEventKind::DownloadCompleted => Some(RpcNotificationKind::DownloadComplete),
RuntimeEventKind::DownloadErrored => Some(RpcNotificationKind::DownloadError),
}
}
@@ -0,0 +1,189 @@
use std::collections::BTreeMap;
use crate::model::{RpcMeta, RpcValue};
/// Maximum queued outbound WebSocket frames retained per connected session.
pub(super) const MAX_PENDING_WEBSOCKET_FRAMES_PER_SESSION: usize = 256;
/// Maximum bridged runtime-event broadcast frames retained before session fan-out.
pub(super) const MAX_PENDING_WEBSOCKET_BROADCAST_FRAMES: usize = 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
/// Notification kinds that can be bridged onto WebSocket sessions.
pub enum RpcNotificationKind {
/// A download transitioned into the active state.
DownloadStarted,
/// A download was paused.
DownloadPaused,
/// A download was stopped or removed.
DownloadStopped,
/// A download completed successfully.
DownloadComplete,
/// A download ended in error.
DownloadError,
/// A download was removed.
DownloadRemoved,
/// A download entered the waiting queue.
DownloadWaiting,
/// A waiting download became active again.
DownloadActive,
/// A `BitTorrent` download completed.
DownloadBtDownloadComplete,
/// Synthetic notification for version polling.
SystemVersion,
/// Synthetic notification for method-list polling.
SystemListMethods,
/// Synthetic notification for notification-list polling.
SystemListNotifications,
}
#[derive(Debug, Clone, PartialEq)]
/// Notification payload bridged to WebSocket clients.
pub struct RpcNotificationEvent {
/// Logical notification kind.
pub kind: RpcNotificationKind,
/// Explicit method name override, if present.
pub method: String,
/// Download gid associated with the notification, if any.
pub gid: Option<String>,
/// Additional notification payload.
pub payload: Option<RpcValue>,
/// Supplemental metadata.
pub meta: RpcMeta,
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// Minimal WebSocket frame shapes used by the RPC server.
pub enum RpcWebSocketFrame {
/// UTF-8 text frame.
Text(String),
/// Binary frame carrying JSON-RPC bytes.
Binary(Vec<u8>),
/// Ping control frame.
Ping(Vec<u8>),
/// Pong control frame.
Pong(Vec<u8>),
/// Close control frame.
Close,
}
impl RpcNotificationKind {
/// Returns the canonical aria2-compatible method name for this notification.
#[must_use]
pub const fn method_name(self) -> &'static str {
match self {
Self::DownloadStarted | Self::DownloadActive => "aria2.onDownloadStart",
Self::DownloadPaused | Self::DownloadWaiting => "aria2.onDownloadPause",
Self::DownloadStopped | Self::DownloadRemoved => "aria2.onDownloadStop",
Self::DownloadComplete => "aria2.onDownloadComplete",
Self::DownloadError => "aria2.onDownloadError",
Self::DownloadBtDownloadComplete => "aria2.onBtDownloadComplete",
Self::SystemVersion => "aria2.getVersion",
Self::SystemListMethods => "system.listMethods",
Self::SystemListNotifications => "system.listNotifications",
}
}
}
impl RpcNotificationEvent {
#[must_use]
/// Returns the effective WebSocket method name for the event.
pub fn websocket_method_name(&self) -> &str {
if self.method.is_empty() {
self.kind.method_name()
} else {
&self.method
}
}
#[must_use]
/// Converts the event into a text WebSocket frame.
pub fn to_websocket_frame(&self) -> RpcWebSocketFrame {
RpcWebSocketFrame::Text(self.to_websocket_json())
}
#[must_use]
/// Renders the event into a JSON-RPC notification string.
pub fn to_websocket_json(&self) -> String {
let method = self.websocket_method_name();
let params = websocket_notification_params(self.gid.as_deref(), self.payload.as_ref());
let params = rpc_value_to_json(&RpcValue::Array(params));
format!(
"{{\"jsonrpc\":\"2.0\",\"method\":\"{}\",\"params\":{}}}",
escape_json(method),
params
)
}
}
/// Builds the JSON-RPC `params` array for a bridged WebSocket notification event.
pub(super) fn websocket_notification_params(
gid: Option<&str>,
payload: Option<&RpcValue>,
) -> Vec<RpcValue> {
if let Some(RpcValue::Array(items)) = payload {
return items.clone();
}
let mut event_spec = BTreeMap::new();
if let Some(gid) = gid {
event_spec.insert("gid".to_owned(), RpcValue::String(gid.to_owned()));
}
if let Some(RpcValue::Object(map)) = payload {
for (key, value) in map {
event_spec.insert(key.clone(), value.clone());
}
}
vec![RpcValue::Object(event_spec)]
}
/// Renders a transport-neutral RPC value into compact JSON text for WebSocket frames.
pub(super) fn rpc_value_to_json(value: &RpcValue) -> String {
match value {
RpcValue::Null => "null".to_owned(),
RpcValue::Bool(value) => value.to_string(),
RpcValue::Number(value) => value.to_string(),
RpcValue::String(value) => format!("\"{}\"", escape_json(value)),
RpcValue::Array(values) => {
let items = values
.iter()
.map(rpc_value_to_json)
.collect::<Vec<_>>()
.join(",");
format!("[{items}]")
}
RpcValue::Object(map) => {
let members = map
.iter()
.map(|(key, value)| {
format!("\"{}\":{}", escape_json(key), rpc_value_to_json(value))
})
.collect::<Vec<_>>()
.join(",");
format!("{{{members}}}")
}
}
}
/// Escapes a string for safe embedding in generated JSON text.
pub(super) fn escape_json(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'"' => escaped.push_str("\\\""),
'\\' => escaped.push_str("\\\\"),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
'\u{08}' => escaped.push_str("\\b"),
'\u{0C}' => escaped.push_str("\\f"),
ch if ch.is_control() => {
use std::fmt::Write as _;
let _ = write!(escaped, "\\u{:04x}", u32::from(ch));
}
ch => escaped.push(ch),
}
}
escaped
}
@@ -0,0 +1,219 @@
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use super::notification::{
MAX_PENDING_WEBSOCKET_BROADCAST_FRAMES, MAX_PENDING_WEBSOCKET_FRAMES_PER_SESSION,
RpcNotificationEvent, RpcNotificationKind, RpcWebSocketFrame,
};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
/// Subscription descriptor for a WebSocket notification consumer.
pub struct WebSocketSubscription {
/// Stable subscription identifier.
pub id: String,
/// Notification kind being subscribed to.
pub kind: RpcNotificationKind,
/// Client-visible topic string.
pub topic: String,
}
#[derive(Debug, Default)]
/// Registry of active WebSocket notification subscriptions.
pub struct WebSocketNotificationRegistry {
/// Subscription descriptors keyed by subscription identifier.
subscriptions: BTreeMap<String, WebSocketSubscription>,
/// Reverse index from notification kind to subscribed identifiers.
by_kind: BTreeMap<RpcNotificationKind, BTreeSet<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// Per-session queued WebSocket state.
pub struct WebSocketSessionState {
/// Stable session identifier.
pub id: String,
/// Frames waiting to be written to the socket for this session.
pending_frames: VecDeque<RpcWebSocketFrame>,
}
#[derive(Debug, Default)]
/// Registry of connected WebSocket sessions.
pub struct WebSocketSessionRegistry {
/// Session entries keyed by their stable session identifier.
sessions: BTreeMap<String, WebSocketSessionState>,
/// Bounded runtime-event broadcast ingress awaiting session fan-out.
pending_broadcast_frames: VecDeque<RpcWebSocketFrame>,
}
impl WebSocketNotificationRegistry {
/// Registers a new subscription.
pub fn subscribe(&mut self, subscription: WebSocketSubscription) {
self.by_kind
.entry(subscription.kind)
.or_default()
.insert(subscription.id.clone());
self.subscriptions
.insert(subscription.id.clone(), subscription);
}
/// Removes a subscription by identifier.
pub fn unsubscribe(&mut self, subscription_id: &str) {
if let Some(subscription) = self.subscriptions.remove(subscription_id)
&& let Some(ids) = self.by_kind.get_mut(&subscription.kind)
{
ids.remove(subscription_id);
}
}
#[must_use]
/// Returns all subscriptions for the provided notification kind.
pub fn subscriptions_for(&self, kind: RpcNotificationKind) -> Vec<&WebSocketSubscription> {
self.by_kind
.get(&kind)
.into_iter()
.flat_map(|ids| ids.iter())
.filter_map(|id| self.subscriptions.get(id))
.collect()
}
#[must_use]
/// Returns the number of registered subscriptions.
pub fn len(&self) -> usize {
self.subscriptions.len()
}
#[must_use]
/// Returns whether the registry contains no subscriptions.
pub fn is_empty(&self) -> bool {
self.subscriptions.is_empty()
}
/// Builds a copy-on-write frame list for a single event fan-out.
#[must_use]
pub fn frames_for_event(
&self,
event: &RpcNotificationEvent,
) -> Vec<(String, RpcWebSocketFrame)> {
let frame = event.to_websocket_frame();
self.subscriptions_for(event.kind)
.into_iter()
.map(|subscription| (subscription.id.clone(), frame.clone()))
.collect()
}
}
impl WebSocketSessionRegistry {
/// Connects or reuses a session entry for the provided identifier.
pub fn connect(&mut self, session_id: impl Into<String>) {
let session_id = session_id.into();
self.sessions
.entry(session_id.clone())
.or_insert_with(|| WebSocketSessionState {
id: session_id,
pending_frames: VecDeque::new(),
});
}
/// Disconnects a session and drops its pending queue.
pub fn disconnect(&mut self, session_id: &str) {
self.sessions.remove(session_id);
}
#[must_use]
/// Returns the number of connected sessions.
pub fn len(&self) -> usize {
self.sessions.len()
}
#[must_use]
/// Returns whether the registry contains no connected sessions.
pub fn is_empty(&self) -> bool {
self.sessions.is_empty()
}
#[must_use]
/// Returns whether the registry contains the provided session id.
pub fn contains(&self, session_id: &str) -> bool {
self.sessions.contains_key(session_id)
}
#[must_use]
/// Returns the connected session identifiers in key order.
pub fn session_ids(&self) -> Vec<String> {
self.sessions.keys().cloned().collect()
}
/// Queues a notification frame for every connected session.
pub fn queue_event_for_all(&mut self, event: &RpcNotificationEvent) {
let frame = event.to_websocket_frame();
for session in self.sessions.values_mut() {
enqueue_session_frame(session, frame.clone());
}
}
/// Queues one bridged broadcast frame for later session fan-out.
pub fn queue_broadcast_frame(&mut self, frame: RpcWebSocketFrame) {
if self.sessions.is_empty() {
return;
}
if self.pending_broadcast_frames.len() >= MAX_PENDING_WEBSOCKET_BROADCAST_FRAMES {
let _ = self.pending_broadcast_frames.pop_front();
}
self.pending_broadcast_frames.push_back(frame);
}
/// Queues a frame for a single session and reports whether it existed.
pub fn queue_frame_for_session(&mut self, session_id: &str, frame: RpcWebSocketFrame) -> bool {
let Some(session) = self.sessions.get_mut(session_id) else {
return false;
};
enqueue_session_frame(session, frame);
true
}
#[must_use]
/// Returns the pending frame count for a session, if it exists.
pub fn pending_count(&self, session_id: &str) -> Option<usize> {
self.sessions
.get(session_id)
.map(|session| session.pending_frames.len())
}
#[must_use]
/// Returns the pending bridged broadcast frame count.
pub fn pending_broadcast_count(&self) -> usize {
self.pending_broadcast_frames.len()
}
/// Fans out all pending bridged broadcast frames into the per-session queues.
pub fn drain_broadcast_frames_into_sessions(&mut self) {
while let Some(frame) = self.pending_broadcast_frames.pop_front() {
for session in self.sessions.values_mut() {
enqueue_session_frame(session, frame.clone());
}
}
}
/// Drains all queued frames for one session after applying pending broadcast fan-out.
pub fn drain_session_frames(&mut self, session_id: &str) -> Vec<RpcWebSocketFrame> {
self.drain_broadcast_frames_into_sessions();
self.sessions
.get_mut(session_id)
.map(|session| session.pending_frames.drain(..).collect())
.unwrap_or_default()
}
/// Pops the oldest pending frame for a session.
pub fn pop_frame(&mut self, session_id: &str) -> Option<RpcWebSocketFrame> {
self.drain_broadcast_frames_into_sessions();
self.sessions
.get_mut(session_id)
.and_then(|session| session.pending_frames.pop_front())
}
}
/// Adds a frame to the session queue while enforcing the bounded backpressure policy.
fn enqueue_session_frame(session: &mut WebSocketSessionState, frame: RpcWebSocketFrame) {
if session.pending_frames.len() >= MAX_PENDING_WEBSOCKET_FRAMES_PER_SESSION {
let _ = session.pending_frames.pop_front();
}
session.pending_frames.push_back(frame);
}
@@ -0,0 +1,397 @@
use std::{
collections::BTreeMap,
sync::{Arc, Mutex},
};
use aria2_rust_pro_core::{
DownloadEngine, DownloadId, EventListener, RuntimeEvent, RuntimeEventKind,
};
use super::notification::{
MAX_PENDING_WEBSOCKET_BROADCAST_FRAMES, MAX_PENDING_WEBSOCKET_FRAMES_PER_SESSION,
};
use super::*;
use crate::{InProcessRpcDispatcher, JsonRpcRequest, RpcValue};
#[test]
/// Verifies that notification kinds keep the upstream aria2 WebSocket method names.
fn notification_kind_uses_upstream_websocket_method_names() {
assert_eq!(
RpcNotificationKind::DownloadStarted.method_name(),
"aria2.onDownloadStart"
);
assert_eq!(
RpcNotificationKind::DownloadBtDownloadComplete.method_name(),
"aria2.onBtDownloadComplete"
);
}
#[test]
/// Verifies that notification events render the upstream `JSON-RPC` notification shape.
fn websocket_event_renders_upstream_jsonrpc_notification_shape() {
let event = RpcNotificationEvent {
kind: RpcNotificationKind::DownloadComplete,
method: String::new(),
gid: Some("a1b2c3".to_owned()),
payload: None,
meta: crate::model::RpcMeta::default(),
};
assert_eq!(
event.to_websocket_json(),
"{\"jsonrpc\":\"2.0\",\"method\":\"aria2.onDownloadComplete\",\"params\":[{\"gid\":\"a1b2c3\"}]}"
);
assert_eq!(
event.to_websocket_frame(),
RpcWebSocketFrame::Text(
"{\"jsonrpc\":\"2.0\",\"method\":\"aria2.onDownloadComplete\",\"params\":[{\"gid\":\"a1b2c3\"}]}".to_owned()
)
);
}
#[test]
/// Verifies that object payloads merge with the gid field in notification output.
fn websocket_event_merges_gid_with_object_payload() {
let event = RpcNotificationEvent {
kind: RpcNotificationKind::DownloadError,
method: String::new(),
gid: Some("deadbeef".to_owned()),
payload: Some(RpcValue::Object(BTreeMap::from([(
"status".to_owned(),
RpcValue::String("error".to_owned()),
)]))),
meta: crate::model::RpcMeta::default(),
};
assert_eq!(
event.to_websocket_json(),
"{\"jsonrpc\":\"2.0\",\"method\":\"aria2.onDownloadError\",\"params\":[{\"gid\":\"deadbeef\",\"status\":\"error\"}]}"
);
}
#[test]
/// Verifies that one notification frame is emitted for each matching subscription.
fn registry_emits_one_frame_per_matching_subscription() {
let mut registry = WebSocketNotificationRegistry::default();
registry.subscribe(WebSocketSubscription {
id: "sub-a".to_owned(),
kind: RpcNotificationKind::DownloadStarted,
topic: "aria2.onDownloadStart".to_owned(),
});
registry.subscribe(WebSocketSubscription {
id: "sub-b".to_owned(),
kind: RpcNotificationKind::DownloadStarted,
topic: "aria2.onDownloadStart".to_owned(),
});
registry.subscribe(WebSocketSubscription {
id: "sub-c".to_owned(),
kind: RpcNotificationKind::DownloadComplete,
topic: "aria2.onDownloadComplete".to_owned(),
});
let event = RpcNotificationEvent {
kind: RpcNotificationKind::DownloadStarted,
method: String::new(),
gid: Some("feedface".to_owned()),
payload: None,
meta: crate::model::RpcMeta::default(),
};
let frames = registry.frames_for_event(&event);
assert_eq!(frames.len(), 2);
assert!(frames.iter().any(|(id, _)| id == "sub-a"));
assert!(frames.iter().any(|(id, _)| id == "sub-b"));
assert!(
frames
.iter()
.all(|(_, frame)| matches!(frame, RpcWebSocketFrame::Text(_)))
);
}
#[test]
/// Verifies that broadcast queueing fans notification frames out to every connected session.
fn session_registry_broadcasts_notification_frames_to_all_sessions() {
let mut registry = WebSocketSessionRegistry::default();
registry.connect("sess-a");
registry.connect("sess-b");
let event = RpcNotificationEvent {
kind: RpcNotificationKind::DownloadComplete,
method: String::new(),
gid: Some("abc123".to_owned()),
payload: None,
meta: crate::model::RpcMeta::default(),
};
registry.queue_event_for_all(&event);
assert_eq!(registry.pending_count("sess-a"), Some(1));
assert_eq!(registry.pending_count("sess-b"), Some(1));
assert_eq!(
registry.pop_frame("sess-a"),
Some(RpcWebSocketFrame::Text(
"{\"jsonrpc\":\"2.0\",\"method\":\"aria2.onDownloadComplete\",\"params\":[{\"gid\":\"abc123\"}]}".to_owned()
))
);
assert_eq!(
registry.pop_frame("sess-b"),
Some(RpcWebSocketFrame::Text(
"{\"jsonrpc\":\"2.0\",\"method\":\"aria2.onDownloadComplete\",\"params\":[{\"gid\":\"abc123\"}]}".to_owned()
))
);
}
#[test]
/// Verifies that bridged broadcast ingress stays bounded and drops the oldest frame first.
fn session_registry_enforces_bounded_broadcast_ingress_backpressure() {
let mut registry = WebSocketSessionRegistry::default();
registry.connect("sess-a");
for index in 0..(MAX_PENDING_WEBSOCKET_BROADCAST_FRAMES + 8) {
registry.queue_broadcast_frame(RpcWebSocketFrame::Ping(vec![
u8::try_from(index % 256).expect("byte should fit"),
]));
}
assert_eq!(
registry.pending_broadcast_count(),
MAX_PENDING_WEBSOCKET_BROADCAST_FRAMES
);
assert_eq!(
registry.pop_frame("sess-a"),
Some(RpcWebSocketFrame::Ping(vec![8])),
"oldest bridged broadcast frames should be dropped first once the ingress limit is reached"
);
}
#[test]
/// Verifies that bridged broadcast ingress fans out only when a session drain occurs.
fn session_registry_drains_broadcast_ingress_into_connected_sessions() {
let mut registry = WebSocketSessionRegistry::default();
registry.connect("sess-a");
registry.connect("sess-b");
registry.queue_broadcast_frame(RpcWebSocketFrame::Ping(vec![4, 2]));
assert_eq!(registry.pending_count("sess-a"), Some(0));
assert_eq!(registry.pending_count("sess-b"), Some(0));
assert_eq!(registry.pending_broadcast_count(), 1);
let sess_a_frames = registry.drain_session_frames("sess-a");
assert_eq!(registry.pending_broadcast_count(), 0);
assert_eq!(sess_a_frames, vec![RpcWebSocketFrame::Ping(vec![4, 2])]);
assert_eq!(
registry.pop_frame("sess-b"),
Some(RpcWebSocketFrame::Ping(vec![4, 2]))
);
}
#[test]
/// Verifies targeted queueing, missing-session rejection, and disconnect behavior.
fn session_registry_supports_targeted_queue_and_disconnect() {
let mut registry = WebSocketSessionRegistry::default();
registry.connect("sess-a");
registry.connect("sess-b");
assert!(registry.queue_frame_for_session("sess-a", RpcWebSocketFrame::Ping(vec![1, 2, 3]),));
assert!(!registry.queue_frame_for_session("sess-missing", RpcWebSocketFrame::Ping(vec![9]),));
assert_eq!(registry.pending_count("sess-a"), Some(1));
assert_eq!(registry.pending_count("sess-b"), Some(0));
assert_eq!(
registry.pop_frame("sess-a"),
Some(RpcWebSocketFrame::Ping(vec![1, 2, 3]))
);
registry.disconnect("sess-b");
assert!(!registry.contains("sess-b"));
assert_eq!(registry.len(), 1);
}
#[test]
/// Verifies that the per-session queue is bounded and drops the oldest frame under pressure.
fn session_registry_enforces_bounded_pending_frame_backpressure() {
let mut registry = WebSocketSessionRegistry::default();
registry.connect("sess-a");
for index in 0..(MAX_PENDING_WEBSOCKET_FRAMES_PER_SESSION + 8) {
assert!(registry.queue_frame_for_session(
"sess-a",
RpcWebSocketFrame::Ping(vec![u8::try_from(index % 256).expect("byte should fit")]),
));
}
assert_eq!(
registry.pending_count("sess-a"),
Some(MAX_PENDING_WEBSOCKET_FRAMES_PER_SESSION)
);
assert_eq!(
registry.pop_frame("sess-a"),
Some(RpcWebSocketFrame::Ping(vec![8])),
"oldest queued frames should be dropped first once the per-session limit is reached"
);
}
#[test]
/// Verifies that the runtime-event bridge queues real completion events into session frames.
fn runtime_event_bridge_queues_real_download_events_into_sessions() {
let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default()));
sessions
.lock()
.expect("sessions lock should succeed")
.connect("sess-a");
let mut bridge = RuntimeEventWebSocketBridge::new(Arc::clone(&sessions));
bridge.on_event(
&RuntimeEvent::new(RuntimeEventKind::DownloadCompleted).with_gid(DownloadId::new(0x2a)),
);
assert_eq!(
sessions
.lock()
.expect("sessions lock should succeed")
.pending_broadcast_count(),
1
);
let frame = sessions
.lock()
.expect("sessions lock should succeed")
.pop_frame("sess-a")
.expect("download completion should queue a notification frame");
match frame {
RpcWebSocketFrame::Text(text) => {
assert!(text.contains("aria2.onDownloadComplete"));
assert!(text.contains(r#""gid":"000000000000002a""#));
}
other => panic!("unexpected bridged notification frame: {other:?}"),
}
}
#[test]
/// Verifies that `addUri` registration alone does not emit a start notification.
fn runtime_event_bridge_does_not_treat_add_uri_as_download_start() {
let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default()));
sessions
.lock()
.expect("sessions lock should succeed")
.connect("sess-a");
let bridge = RuntimeEventWebSocketBridge::new(Arc::clone(&sessions));
let mut dispatcher = InProcessRpcDispatcher::new();
dispatcher.register_runtime_listener(bridge);
let response = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: Some(crate::jsonrpc::JsonRpcId::Number(1)),
method: "aria2.addUri".to_owned(),
params: vec![RpcValue::String("https://example.org/file.iso".to_owned())],
meta: crate::model::RpcMeta::default(),
});
assert!(response.error.is_none());
assert!(
sessions
.lock()
.expect("sessions lock should succeed")
.pop_frame("sess-a")
.is_none(),
"addUri should not emit aria2.onDownloadStart before the download actually starts"
);
}
#[test]
/// Verifies that start notifications only appear after the scheduler activates the download.
fn runtime_event_bridge_emits_start_only_when_scheduler_activates_download() {
let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default()));
sessions
.lock()
.expect("sessions lock should succeed")
.connect("sess-a");
let mut engine = DownloadEngine::new();
engine.register_listener(RuntimeEventWebSocketBridge::new(Arc::clone(&sessions)));
let gid = engine.add_uri("https://example.org/file.iso").gid();
assert!(
sessions
.lock()
.expect("sessions lock should succeed")
.pop_frame("sess-a")
.is_none(),
"registration alone should not emit a start notification"
);
let _ = engine.schedule_once();
let frame = sessions
.lock()
.expect("sessions lock should succeed")
.pop_frame("sess-a")
.expect("scheduler activation should emit start notification");
match frame {
RpcWebSocketFrame::Text(text) => {
assert!(text.contains("aria2.onDownloadStart"));
assert!(text.contains(&format!(r#""gid":"{gid}""#)));
}
other => panic!("unexpected frame: {other:?}"),
}
}
#[test]
/// Verifies that resumed downloads emit start only after they become active again.
fn runtime_event_bridge_does_not_emit_start_until_resumed_download_is_active_again() {
let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default()));
sessions
.lock()
.expect("sessions lock should succeed")
.connect("sess-a");
let mut engine = DownloadEngine::new();
engine.register_listener(RuntimeEventWebSocketBridge::new(Arc::clone(&sessions)));
let gid = engine.add_uri("https://example.org/file.iso").gid();
let _ = engine.schedule_once();
let _ = sessions
.lock()
.expect("sessions lock should succeed")
.pop_frame("sess-a");
engine.pause(gid).expect("pause should succeed");
let pause = sessions
.lock()
.expect("sessions lock should succeed")
.pop_frame("sess-a")
.expect("pause should emit pause notification");
match pause {
RpcWebSocketFrame::Text(text) => {
assert!(text.contains("aria2.onDownloadPause"));
}
other => panic!("unexpected pause frame: {other:?}"),
}
engine
.resume(gid)
.expect("resume should move download back to waiting");
assert!(
sessions
.lock()
.expect("sessions lock should succeed")
.pop_frame("sess-a")
.is_none(),
"resume should not emit start notification before reactivation"
);
let _ = engine.schedule_once();
let frame = sessions
.lock()
.expect("sessions lock should succeed")
.pop_frame("sess-a")
.expect("reactivation should emit start notification");
match frame {
RpcWebSocketFrame::Text(text) => {
assert!(text.contains("aria2.onDownloadStart"));
assert!(text.contains(&format!(r#""gid":"{gid}""#)));
}
other => panic!("unexpected reactivation frame: {other:?}"),
}
}
+28
View File
@@ -0,0 +1,28 @@
//! XML-RPC request and response types plus parser and renderer helpers.
#![expect(
clippy::arithmetic_side_effects,
clippy::indexing_slicing,
reason = "the XML-RPC surface keeps transport terminology explicit for compatibility parity"
)]
/// XML-RPC parser and renderer entrypoints.
mod codec;
/// Conversion helpers between XML-RPC and transport-neutral RPC values.
mod convert;
/// XML-RPC request, response, and value model types.
mod model;
/// Minimal XML token scanner used by the codec parser.
mod scanner;
#[cfg(test)]
/// XML-RPC parser, renderer, and conversion tests.
mod tests;
pub use self::codec::{
xmlrpc_method_call_from_xml, xmlrpc_method_call_to_xml, xmlrpc_method_response_from_xml,
xmlrpc_method_response_to_xml,
};
pub use self::convert::{rpc_value_to_xmlrpc, xmlrpc_value_to_rpc};
pub use self::model::{
XmlRpcFault, XmlRpcMember, XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, XmlRpcValue,
};
@@ -0,0 +1,212 @@
use crate::model::RpcMeta;
use super::{
convert::{xmlrpc_fault_code, xmlrpc_fault_string},
model::XmlRpcFault,
};
use super::{
model::{XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, XmlRpcValue},
scanner::XmlScanner,
};
#[must_use]
/// Renders an XML-RPC method call to a compact XML document.
pub fn xmlrpc_method_call_to_xml(call: &XmlRpcMethodCall) -> String {
let mut out = String::new();
out.push_str("<?xml version=\"1.0\"?>");
out.push_str("<methodCall>");
out.push_str("<methodName>");
out.push_str(&escape_xml_text(&call.method_name));
out.push_str("</methodName>");
out.push_str("<params>");
for param in &call.params {
out.push_str("<param>");
out.push_str(&xmlrpc_value_to_xml(&param.value));
out.push_str("</param>");
}
out.push_str("</params>");
out.push_str("</methodCall>");
out
}
#[must_use]
/// Renders an XML-RPC method response to a compact XML document.
pub fn xmlrpc_method_response_to_xml(response: &XmlRpcMethodResponse) -> String {
let mut out = String::new();
out.push_str("<?xml version=\"1.0\"?>");
out.push_str("<methodResponse>");
if let Some(fault) = &response.fault {
out.push_str("<fault>");
out.push_str("<value><struct>");
out.push_str("<member><name>faultCode</name>");
out.push_str(&xmlrpc_value_to_xml(&XmlRpcValue::Int(fault.code)));
out.push_str("</member>");
out.push_str("<member><name>faultString</name>");
out.push_str(&xmlrpc_value_to_xml(&XmlRpcValue::String(
fault.message.clone(),
)));
out.push_str("</member>");
out.push_str("</struct></value>");
out.push_str("</fault>");
} else if let Some(value) = &response.value {
out.push_str("<params><param>");
out.push_str(&xmlrpc_value_to_xml(value));
out.push_str("</param></params>");
} else {
out.push_str("<params/>");
}
out.push_str("</methodResponse>");
out
}
/// Parses an XML-RPC method call from raw XML text.
///
/// # Errors
///
/// Returns an error when `xml` is not a well-formed XML-RPC method call.
pub fn xmlrpc_method_call_from_xml(xml: &str) -> Result<XmlRpcMethodCall, String> {
let mut p = XmlScanner::new(xml);
p.skip_xml_decl_and_ws();
p.expect_open("methodCall")?;
let method_name = p.read_text_tag("methodName")?;
let mut params = Vec::new();
if p.peek_open("params") {
p.expect_open("params")?;
while p.peek_open("param") {
p.expect_open("param")?;
let value = p.read_value_tag()?;
p.expect_close("param")?;
params.push(XmlRpcParam { value });
}
p.expect_close("params")?;
} else if p.peek_self_closing("params") {
p.expect_self_closing("params")?;
}
p.expect_close("methodCall")?;
p.skip_ws();
if !p.is_eof() {
return Err("trailing XML after methodCall".to_string());
}
Ok(XmlRpcMethodCall {
method_name,
params,
meta: RpcMeta::default(),
})
}
/// Parses an XML-RPC method response from raw XML text.
///
/// # Errors
///
/// Returns an error when `xml` is not a well-formed XML-RPC method response.
pub fn xmlrpc_method_response_from_xml(xml: &str) -> Result<XmlRpcMethodResponse, String> {
let mut p = XmlScanner::new(xml);
p.skip_xml_decl_and_ws();
p.expect_open("methodResponse")?;
let mut value = None;
let mut fault = None;
if p.peek_open("fault") {
p.expect_open("fault")?;
let fault_value = p.read_value_tag()?;
p.expect_close("fault")?;
let XmlRpcValue::Struct(members) = fault_value else {
return Err("fault value must be struct".to_string());
};
let code = members
.iter()
.find(|m| m.name == "faultCode")
.and_then(|m| xmlrpc_fault_code(&m.value))
.ok_or_else(|| "missing faultCode".to_string())?;
let message = members
.iter()
.find(|m| m.name == "faultString")
.and_then(|m| xmlrpc_fault_string(&m.value))
.ok_or_else(|| "missing faultString".to_string())?;
fault = Some(XmlRpcFault {
code,
message,
error: None,
});
} else if p.peek_open("params") {
p.expect_open("params")?;
if p.peek_open("param") {
p.expect_open("param")?;
value = Some(p.read_value_tag()?);
p.expect_close("param")?;
}
p.expect_close("params")?;
} else if p.peek_self_closing("params") {
p.expect_self_closing("params")?;
}
p.expect_close("methodResponse")?;
p.skip_ws();
if !p.is_eof() {
return Err("trailing XML after methodResponse".to_string());
}
Ok(XmlRpcMethodResponse {
value,
fault,
meta: RpcMeta::default(),
})
}
/// Renders a single XML-RPC value into a compact `<value>...</value>` fragment.
fn xmlrpc_value_to_xml(value: &XmlRpcValue) -> String {
match value {
XmlRpcValue::Nil => "<value><nil/></value>".to_string(),
XmlRpcValue::Bool(v) => format!("<value><boolean>{}</boolean></value>", i32::from(*v)),
XmlRpcValue::Int(v) => format!("<value><int>{v}</int></value>"),
XmlRpcValue::String(v) => format!("<value><string>{}</string></value>", escape_xml_text(v)),
XmlRpcValue::Double(v) => format!("<value><double>{v}</double></value>"),
XmlRpcValue::DateTime(v) => {
format!(
"<value><dateTime.iso8601>{}</dateTime.iso8601></value>",
escape_xml_text(v)
)
}
XmlRpcValue::Base64(v) => format!("<value><base64>{}</base64></value>", base64_encode(v)),
XmlRpcValue::Array(values) => {
let mut out = String::from("<value><array><data>");
for item in values {
out.push_str(&xmlrpc_value_to_xml(item));
}
out.push_str("</data></array></value>");
out
}
XmlRpcValue::Struct(members) => {
let mut out = String::from("<value><struct>");
for member in members {
out.push_str("<member><name>");
out.push_str(&escape_xml_text(&member.name));
out.push_str("</name>");
out.push_str(&xmlrpc_value_to_xml(&member.value));
out.push_str("</member>");
}
out.push_str("</struct></value>");
out
}
}
}
/// Escapes text content for safe inclusion inside XML element bodies.
pub(super) fn escape_xml_text(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
_ => out.push(ch),
}
}
out
}
/// Encodes raw bytes using the XML-RPC base64 scalar format.
pub(super) fn base64_encode(bytes: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
@@ -0,0 +1,77 @@
use std::collections::BTreeMap;
use crate::model::RpcValue;
use super::{
codec::base64_encode,
model::{XmlRpcMember, XmlRpcValue},
};
#[must_use]
/// Converts a transport-neutral RPC value into its XML-RPC representation.
pub fn rpc_value_to_xmlrpc(value: RpcValue) -> XmlRpcValue {
match value {
RpcValue::Null => XmlRpcValue::Nil,
RpcValue::Bool(v) => XmlRpcValue::Bool(v),
RpcValue::Number(v) => {
i32::try_from(v).map_or_else(|_| XmlRpcValue::String(v.to_string()), XmlRpcValue::Int)
}
RpcValue::String(v) => XmlRpcValue::String(v),
RpcValue::Array(values) => {
XmlRpcValue::Array(values.into_iter().map(rpc_value_to_xmlrpc).collect())
}
RpcValue::Object(map) => XmlRpcValue::Struct(
map.into_iter()
.map(|(name, value)| XmlRpcMember {
name,
value: rpc_value_to_xmlrpc(value),
})
.collect(),
),
}
}
#[must_use]
/// Converts an XML-RPC value into the transport-neutral RPC representation.
pub fn xmlrpc_value_to_rpc(value: XmlRpcValue) -> RpcValue {
match value {
XmlRpcValue::Nil => RpcValue::Null,
XmlRpcValue::Bool(v) => RpcValue::Bool(v),
XmlRpcValue::Int(v) => RpcValue::Number(i64::from(v)),
XmlRpcValue::String(v) | XmlRpcValue::DateTime(v) => RpcValue::String(v),
XmlRpcValue::Double(v) => RpcValue::String(v.to_string()),
XmlRpcValue::Base64(v) => RpcValue::String(base64_encode(&v)),
XmlRpcValue::Array(values) => {
RpcValue::Array(values.into_iter().map(xmlrpc_value_to_rpc).collect())
}
XmlRpcValue::Struct(members) => {
let mut map = BTreeMap::new();
for member in members {
map.insert(member.name, xmlrpc_value_to_rpc(member.value));
}
RpcValue::Object(map)
}
}
}
/// Extracts an XML-RPC fault code from the normalized fault struct member value.
pub(super) fn xmlrpc_fault_code(value: &XmlRpcValue) -> Option<i32> {
match value {
XmlRpcValue::Int(v) => Some(*v),
XmlRpcValue::String(v) => v.trim().parse::<i32>().ok(),
_ => None,
}
}
/// Extracts an XML-RPC fault string from the normalized fault struct member value.
pub(super) fn xmlrpc_fault_string(value: &XmlRpcValue) -> Option<String> {
match value {
XmlRpcValue::Nil => Some(String::new()),
XmlRpcValue::Bool(v) => Some(if *v { "1" } else { "0" }.to_string()),
XmlRpcValue::Int(v) => Some(v.to_string()),
XmlRpcValue::String(v) | XmlRpcValue::DateTime(v) => Some(v.clone()),
XmlRpcValue::Double(v) => Some(v.to_string()),
XmlRpcValue::Base64(v) => Some(base64_encode(v)),
XmlRpcValue::Array(_) | XmlRpcValue::Struct(_) => None,
}
}
@@ -0,0 +1,85 @@
use crate::model::{RpcError, RpcMeta};
#[derive(Debug, Clone, PartialEq)]
/// XML-RPC value representation used by the compatibility layer.
pub enum XmlRpcValue {
/// 32-bit signed integer.
Int(i32),
/// Boolean scalar.
Bool(bool),
/// UTF-8 string scalar.
String(String),
/// Floating-point scalar.
Double(f64),
/// ISO 8601 timestamp string.
DateTime(String),
/// Base64-encoded binary payload.
Base64(Vec<u8>),
/// Ordered list of nested values.
Array(Vec<Self>),
/// Structured list of named members.
Struct(Vec<XmlRpcMember>),
/// Nil extension value.
Nil,
}
#[derive(Debug, Clone, PartialEq)]
/// Named member within an XML-RPC struct value.
pub struct XmlRpcMember {
/// Member name.
pub name: String,
/// Member value.
pub value: XmlRpcValue,
}
#[derive(Debug, Clone, PartialEq)]
/// Positional XML-RPC parameter wrapper.
pub struct XmlRpcParam {
/// Parameter value.
pub value: XmlRpcValue,
}
#[derive(Debug, Clone, PartialEq)]
/// XML-RPC method call payload.
pub struct XmlRpcMethodCall {
/// Requested method name.
pub method_name: String,
/// Positional parameters.
pub params: Vec<XmlRpcParam>,
/// Supplemental request metadata.
pub meta: RpcMeta,
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// XML-RPC fault payload.
pub struct XmlRpcFault {
/// Fault code returned on the wire.
pub code: i32,
/// Human-readable fault message.
pub message: String,
/// Normalized backing error, if one exists.
pub error: Option<RpcError>,
}
#[derive(Debug, Clone, PartialEq)]
/// XML-RPC method response payload.
pub struct XmlRpcMethodResponse {
/// Successful result value, if any.
pub value: Option<XmlRpcValue>,
/// Fault payload, if the request failed.
pub fault: Option<XmlRpcFault>,
/// Supplemental response metadata.
pub meta: RpcMeta,
}
impl XmlRpcMethodResponse {
#[must_use]
/// Builds a successful XML-RPC response containing the provided value.
pub fn success(value: XmlRpcValue) -> Self {
Self {
value: Some(value),
fault: None,
meta: RpcMeta::default(),
}
}
}
@@ -0,0 +1,435 @@
use super::model::{XmlRpcMember, XmlRpcValue};
/// Small stateful scanner for the permissive XML-RPC parser.
pub(super) struct XmlScanner<'a> {
/// Entire XML document being parsed.
input: &'a str,
/// Current byte position within `input`.
pos: usize,
}
#[derive(Clone, Copy, PartialEq, Eq)]
/// Kind of XML opening tag encountered by the scanner.
enum TagKind {
/// Standard opening tag with a matching close tag later in the stream.
Open,
/// Self-closing tag with no body content.
SelfClosing,
}
impl<'a> XmlScanner<'a> {
/// Creates a scanner positioned at the beginning of the input document.
pub(super) const fn new(input: &'a str) -> Self {
Self { input, pos: 0 }
}
/// Returns whether the scanner has consumed the entire input string.
pub(super) const fn is_eof(&self) -> bool {
self.pos >= self.input.len()
}
/// Returns the remaining unparsed input slice.
fn rest(&self) -> &'a str {
&self.input[self.pos..]
}
/// Skips XML whitespace plus comments and processing instructions.
pub(super) fn skip_ws(&mut self) {
loop {
let start = self.pos;
while let Some(ch) = self.rest().chars().next() {
if ch.is_whitespace() {
self.pos += ch.len_utf8();
} else {
break;
}
}
if self.rest().starts_with("<!--") {
if let Some(end) = self.rest().find("-->") {
self.pos += end + 3;
continue;
}
self.pos = self.input.len();
} else if self.rest().starts_with("<?") {
if let Some(end) = self.rest().find("?>") {
self.pos += end + 2;
continue;
}
self.pos = self.input.len();
}
if self.pos == start {
break;
}
}
}
/// Skips the optional XML declaration and any surrounding whitespace.
pub(super) fn skip_xml_decl_and_ws(&mut self) {
self.skip_ws();
}
/// Returns whether the next non-whitespace token is an opening tag for `tag`.
pub(super) fn peek_open(&mut self, tag: &str) -> bool {
self.skip_ws();
matches!(self.match_open_tag(tag), Some(TagKind::Open))
}
/// Returns whether the next non-whitespace token is a self-closing tag for `tag`.
pub(super) fn peek_self_closing(&mut self, tag: &str) -> bool {
self.skip_ws();
matches!(self.match_open_tag(tag), Some(TagKind::SelfClosing))
}
/// Consumes an opening tag and errors when another token appears instead.
pub(super) fn expect_open(&mut self, tag: &str) -> Result<(), String> {
self.skip_ws();
match self.consume_open_tag(tag) {
Some(TagKind::Open) => Ok(()),
_ => Err(format!("expected opening tag <{tag}>")),
}
}
/// Consumes a closing tag and errors when it is absent.
pub(super) fn expect_close(&mut self, tag: &str) -> Result<(), String> {
self.skip_ws();
if let Some(len) = self.match_close_tag(tag) {
self.pos += len;
Ok(())
} else {
Err(format!("expected closing tag </{tag}>"))
}
}
/// Consumes a self-closing tag and errors when it is absent.
pub(super) fn expect_self_closing(&mut self, tag: &str) -> Result<(), String> {
self.skip_ws();
match self.consume_open_tag(tag) {
Some(TagKind::SelfClosing) => Ok(()),
_ => Err(format!("expected self-closing tag <{tag}/>")),
}
}
/// Reads a text-only tag body, accepting an empty self-closing form.
pub(super) fn read_text_tag(&mut self, tag: &str) -> Result<String, String> {
if self.peek_self_closing(tag) {
self.expect_self_closing(tag)?;
return Ok(String::new());
}
self.expect_open(tag)?;
let text = self.read_text_until_close(tag)?;
self.expect_close(tag)?;
Ok(text)
}
/// Reads a text-only tag body and trims surrounding whitespace.
fn read_trimmed_text_tag(&mut self, tag: &str) -> Result<String, String> {
Ok(self.read_text_tag(tag)?.trim().to_string())
}
/// Reads decoded text until the matching closing tag is encountered.
fn read_text_until_close(&mut self, tag: &str) -> Result<String, String> {
let mut out = String::new();
loop {
if self.match_close_tag(tag).is_some() {
return Ok(out);
}
let rest = self.rest();
if rest.is_empty() {
return Err(format!("missing closing tag </{tag}>"));
}
if rest.starts_with("<![CDATA[") {
let end = rest
.find("]]>")
.ok_or_else(|| "unterminated CDATA section".to_string())?;
out.push_str(&rest[9..end]);
self.pos += end + 3;
continue;
}
if rest.starts_with("<!--") {
let end = rest
.find("-->")
.ok_or_else(|| "unterminated XML comment".to_string())?;
self.pos += end + 3;
continue;
}
if rest.starts_with("<?") {
let end = rest
.find("?>")
.ok_or_else(|| "unterminated XML processing instruction".to_string())?;
self.pos += end + 2;
continue;
}
if rest.starts_with('<') {
return Err(format!("unexpected nested tag inside <{tag}> text"));
}
let next = rest.find('<').unwrap_or(rest.len());
out.push_str(&unescape_xml_text(&rest[..next])?);
self.pos += next;
}
}
/// Returns whether the next non-whitespace token is either opening form for `tag`.
fn next_is_value_tag(&mut self, tag: &str) -> bool {
self.peek_open(tag) || self.peek_self_closing(tag)
}
/// Reads a trimmed integer tag body into an i32 value.
fn read_i32_tag(&mut self, tag: &str) -> Result<i32, String> {
self.read_trimmed_text_tag(tag)?
.parse::<i32>()
.map_err(|error| error.to_string())
}
/// Reads a large integer tag, preserving out-of-range values as strings.
fn read_lossy_i32_or_string_tag(&mut self, tag: &str) -> Result<XmlRpcValue, String> {
let raw = self.read_trimmed_text_tag(tag)?;
raw.parse::<i64>()
.map(|value| {
i32::try_from(value).map_or_else(|_| XmlRpcValue::String(raw), XmlRpcValue::Int)
})
.map_err(|error| error.to_string())
}
/// Reads a complete XML-RPC `<value>` element into the normalized value tree.
pub(super) fn read_value_tag(&mut self) -> Result<XmlRpcValue, String> {
if self.peek_self_closing("value") {
self.expect_self_closing("value")?;
return Ok(XmlRpcValue::String(String::new()));
}
self.expect_open("value")?;
self.skip_ws();
let value = if self.next_is_value_tag("int") {
XmlRpcValue::Int(self.read_i32_tag("int")?)
} else if self.next_is_value_tag("i4") {
XmlRpcValue::Int(self.read_i32_tag("i4")?)
} else if self.next_is_value_tag("i8") {
self.read_lossy_i32_or_string_tag("i8")?
} else if self.next_is_value_tag("biginteger") {
self.read_lossy_i32_or_string_tag("biginteger")?
} else if self.next_is_value_tag("boolean") {
let raw = self.read_trimmed_text_tag("boolean")?;
match raw.as_str() {
"1" | "true" => XmlRpcValue::Bool(true),
"0" | "false" => XmlRpcValue::Bool(false),
_ => return Err("invalid boolean value".to_string()),
}
} else if self.match_open_tag("string").is_some() {
XmlRpcValue::String(self.read_text_tag("string")?)
} else if self.next_is_value_tag("double") {
XmlRpcValue::Double(
self.read_trimmed_text_tag("double")?
.parse::<f64>()
.map_err(|error| error.to_string())?,
)
} else if self.next_is_value_tag("dateTime.iso8601") {
XmlRpcValue::DateTime(self.read_trimmed_text_tag("dateTime.iso8601")?)
} else if self.next_is_value_tag("base64") {
let data = self.read_trimmed_text_tag("base64")?;
XmlRpcValue::Base64(base64_decode(&data)?)
} else if self.peek_open("array") {
self.expect_open("array")?;
let mut items = Vec::new();
if self.peek_self_closing("data") {
self.expect_self_closing("data")?;
} else {
self.expect_open("data")?;
while self.peek_open("value") || self.peek_self_closing("value") {
items.push(self.read_value_tag()?);
}
self.expect_close("data")?;
}
self.expect_close("array")?;
XmlRpcValue::Array(items)
} else if self.peek_self_closing("array") {
self.expect_self_closing("array")?;
XmlRpcValue::Array(Vec::new())
} else if self.peek_open("struct") {
self.expect_open("struct")?;
let mut members = Vec::new();
while self.peek_open("member") {
self.expect_open("member")?;
let name = self.read_text_tag("name")?;
let value = self.read_value_tag()?;
self.expect_close("member")?;
members.push(XmlRpcMember { name, value });
}
self.expect_close("struct")?;
XmlRpcValue::Struct(members)
} else if self.peek_self_closing("struct") {
self.expect_self_closing("struct")?;
XmlRpcValue::Struct(Vec::new())
} else if self.peek_self_closing("nil") {
self.expect_self_closing("nil")?;
XmlRpcValue::Nil
} else if self.peek_open("nil") {
self.expect_open("nil")?;
self.expect_close("nil")?;
XmlRpcValue::Nil
} else if self.rest().starts_with('<') {
return Err("unsupported XML-RPC value type".to_string());
} else {
XmlRpcValue::String(self.read_text_until_close("value")?)
};
self.expect_close("value")?;
Ok(value)
}
/// Peeks at the next opening tag kind without consuming it.
fn match_open_tag(&self, tag: &str) -> Option<TagKind> {
let rest = self.rest();
scan_open_tag(rest, tag).map(|(kind, _)| kind)
}
/// Consumes the next opening tag for `tag` and returns its concrete kind.
fn consume_open_tag(&mut self, tag: &str) -> Option<TagKind> {
let (kind, len) = scan_open_tag(self.rest(), tag)?;
self.pos += len;
Some(kind)
}
/// Returns the byte length of the next matching closing tag, if present.
fn match_close_tag(&self, tag: &str) -> Option<usize> {
scan_close_tag(self.rest(), tag)
}
}
/// Scans a namespaced-or-plain opening tag and returns its kind plus byte length.
fn scan_open_tag(rest: &str, tag: &str) -> Option<(TagKind, usize)> {
if !rest.starts_with('<')
|| rest.starts_with("</")
|| rest.starts_with("<?")
|| rest.starts_with("<!")
{
return None;
}
let mut idx = 1;
while let Some(ch) = rest[idx..].chars().next() {
if ch.is_whitespace() || ch == '/' || ch == '>' {
break;
}
idx += ch.len_utf8();
}
let name = &rest[1..idx];
if name.rsplit(':').next()? != tag {
return None;
}
let mut cursor = idx;
let mut quote = None;
while let Some(ch) = rest[cursor..].chars().next() {
let ch_len = ch.len_utf8();
if let Some(active_quote) = quote {
if ch == active_quote {
quote = None;
}
cursor += ch_len;
continue;
}
match ch {
'"' | '\'' => {
quote = Some(ch);
cursor += ch_len;
}
'>' => return Some((TagKind::Open, cursor + ch_len)),
'/' if rest[cursor..].starts_with("/>") => {
return Some((TagKind::SelfClosing, cursor + 2));
}
_ => cursor += ch_len,
}
}
None
}
/// Scans a namespaced-or-plain closing tag and returns its byte length.
fn scan_close_tag(rest: &str, tag: &str) -> Option<usize> {
if !rest.starts_with("</") {
return None;
}
let mut idx = 2;
while let Some(ch) = rest[idx..].chars().next() {
if ch.is_whitespace() || ch == '>' {
break;
}
idx += ch.len_utf8();
}
let name = &rest[2..idx];
if name.rsplit(':').next()? != tag {
return None;
}
while let Some(ch) = rest[idx..].chars().next() {
if ch.is_whitespace() {
idx += ch.len_utf8();
} else {
break;
}
}
rest[idx..].starts_with('>').then_some(idx + 1)
}
/// Decodes XML entities in a text node into their Unicode scalar values.
fn unescape_xml_text(input: &str) -> Result<String, String> {
let mut out = String::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'&' {
let rem = &input[i..];
if rem.starts_with("&amp;") {
out.push('&');
i += 5;
} else if rem.starts_with("&lt;") {
out.push('<');
i += 4;
} else if rem.starts_with("&gt;") {
out.push('>');
i += 4;
} else if rem.starts_with("&quot;") {
out.push('"');
i += 6;
} else if rem.starts_with("&apos;") {
out.push('\'');
i += 6;
} else if rem.starts_with("&#") {
let (digits_start, radix) = if rem.starts_with("&#x") || rem.starts_with("&#X") {
(3, 16)
} else {
(2, 10)
};
let end = rem
.find(';')
.ok_or_else(|| "invalid numeric XML entity".to_string())?;
let digits = &rem[digits_start..end];
if digits.is_empty() {
return Err("invalid numeric XML entity".to_string());
}
let codepoint = u32::from_str_radix(digits, radix)
.map_err(|_| "invalid numeric XML entity".to_string())?;
let ch = char::from_u32(codepoint)
.ok_or_else(|| "invalid numeric XML entity".to_string())?;
out.push(ch);
i += end + 1;
} else {
return Err("unsupported XML entity".to_string());
}
} else if let Some(ch) = input[i..].chars().next() {
out.push(ch);
i += ch.len_utf8();
} else {
break;
}
}
Ok(out)
}
/// Decodes a base64 value while tolerating insignificant XML whitespace.
fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
use base64::Engine;
let compact = input
.bytes()
.filter(|byte| !byte.is_ascii_whitespace())
.collect::<Vec<_>>();
base64::engine::general_purpose::STANDARD
.decode(compact)
.map_err(|error| error.to_string())
}
@@ -0,0 +1,685 @@
use std::collections::BTreeMap;
use super::*;
use crate::model::{RpcMeta, RpcValue};
#[test]
fn parses_method_call_with_common_value_types() {
let xml = r#"<?xml version="1.0"?>
<methodCall>
<methodName>system.multicall</methodName>
<params>
<param><value><int>7</int></value></param>
<param><value><boolean>1</boolean></value></param>
<param><value><string>aria2</string></value></param>
<param><value><double>3.5</double></value></param>
<param><value><dateTime.iso8601>20260526T10:11:12</dateTime.iso8601></value></param>
<param><value><base64>YXI=</base64></value></param>
<param><value><array><data><value><nil/></value><value><string>x</string></value></data></array></value></param>
<param><value><struct><member><name>a</name><value><i4>-2</i4></value></member></struct></value></param>
</params>
</methodCall>"#;
let call = xmlrpc_method_call_from_xml(xml).expect("parse methodCall");
assert_eq!(call.method_name, "system.multicall");
assert_eq!(
call.params,
vec![
XmlRpcParam {
value: XmlRpcValue::Int(7),
},
XmlRpcParam {
value: XmlRpcValue::Bool(true),
},
XmlRpcParam {
value: XmlRpcValue::String("aria2".to_string()),
},
XmlRpcParam {
value: XmlRpcValue::Double(3.5),
},
XmlRpcParam {
value: XmlRpcValue::DateTime("20260526T10:11:12".to_string()),
},
XmlRpcParam {
value: XmlRpcValue::Base64(b"ar".to_vec()),
},
XmlRpcParam {
value: XmlRpcValue::Array(vec![
XmlRpcValue::Nil,
XmlRpcValue::String("x".to_string()),
]),
},
XmlRpcParam {
value: XmlRpcValue::Struct(vec![XmlRpcMember {
name: "a".to_string(),
value: XmlRpcValue::Int(-2),
}]),
},
]
);
}
#[test]
fn parses_method_response_success_and_fault() {
let ok_xml =
xmlrpc_method_response_to_xml(&XmlRpcMethodResponse::success(XmlRpcValue::Struct(vec![
XmlRpcMember {
name: "status".to_string(),
value: XmlRpcValue::String("done".to_string()),
},
])));
let ok = xmlrpc_method_response_from_xml(&ok_xml).expect("parse success response");
assert_eq!(
ok,
XmlRpcMethodResponse {
value: Some(XmlRpcValue::Struct(vec![XmlRpcMember {
name: "status".to_string(),
value: XmlRpcValue::String("done".to_string()),
}])),
fault: None,
meta: RpcMeta::default(),
}
);
let fault_xml = r#"<?xml version="1.0"?>
<methodResponse>
<fault>
<value>
<struct>
<member><name>faultCode</name><value><int>4</int></value></member>
<member><name>faultString</name><value><string>bad arg</string></value></member>
</struct>
</value>
</fault>
</methodResponse>"#;
let fault = xmlrpc_method_response_from_xml(fault_xml).expect("parse fault response");
assert_eq!(
fault,
XmlRpcMethodResponse {
value: None,
fault: Some(XmlRpcFault {
code: 4,
message: "bad arg".to_string(),
error: None,
}),
meta: RpcMeta::default(),
}
);
}
#[test]
fn roundtrips_rendered_method_call_and_response() {
let call = XmlRpcMethodCall {
method_name: "aria2.addUri".to_string(),
params: vec![
XmlRpcParam {
value: XmlRpcValue::Nil,
},
XmlRpcParam {
value: XmlRpcValue::Array(vec![XmlRpcValue::Bool(false)]),
},
],
meta: RpcMeta::default(),
};
let parsed_call = xmlrpc_method_call_from_xml(&xmlrpc_method_call_to_xml(&call))
.expect("roundtrip methodCall");
assert_eq!(parsed_call, call);
let response = XmlRpcMethodResponse::success(XmlRpcValue::Array(vec![
XmlRpcValue::Int(1),
XmlRpcValue::String("ok".to_string()),
]));
let parsed_response =
xmlrpc_method_response_from_xml(&xmlrpc_method_response_to_xml(&response))
.expect("roundtrip methodResponse");
assert_eq!(parsed_response, response);
}
#[test]
fn converts_nested_rpc_to_xmlrpc() {
let mut inner = BTreeMap::new();
inner.insert("flag".to_string(), RpcValue::Bool(true));
inner.insert(
"list".to_string(),
RpcValue::Array(vec![RpcValue::Number(7), RpcValue::Null]),
);
let mut root = BTreeMap::new();
root.insert("name".to_string(), RpcValue::String("aria".to_string()));
root.insert("meta".to_string(), RpcValue::Object(inner));
let xml = rpc_value_to_xmlrpc(RpcValue::Object(root));
assert_eq!(
xml,
XmlRpcValue::Struct(vec![
XmlRpcMember {
name: "meta".to_string(),
value: XmlRpcValue::Struct(vec![
XmlRpcMember {
name: "flag".to_string(),
value: XmlRpcValue::Bool(true),
},
XmlRpcMember {
name: "list".to_string(),
value: XmlRpcValue::Array(vec![XmlRpcValue::Int(7), XmlRpcValue::Nil]),
},
]),
},
XmlRpcMember {
name: "name".to_string(),
value: XmlRpcValue::String("aria".to_string()),
},
])
);
}
#[test]
fn converts_nested_xmlrpc_back_to_rpc() {
let value = XmlRpcValue::Struct(vec![
XmlRpcMember {
name: "name".to_string(),
value: XmlRpcValue::String("aria2".to_string()),
},
XmlRpcMember {
name: "items".to_string(),
value: XmlRpcValue::Array(vec![
XmlRpcValue::Int(42),
XmlRpcValue::Bool(false),
XmlRpcValue::Nil,
]),
},
XmlRpcMember {
name: "stamp".to_string(),
value: XmlRpcValue::DateTime("2026-01-01T00:00:00Z".to_string()),
},
]);
let rpc = xmlrpc_value_to_rpc(value);
let expected = RpcValue::Object(BTreeMap::from([
(
"items".to_string(),
RpcValue::Array(vec![
RpcValue::Number(42),
RpcValue::Bool(false),
RpcValue::Null,
]),
),
("name".to_string(), RpcValue::String("aria2".to_string())),
(
"stamp".to_string(),
RpcValue::String("2026-01-01T00:00:00Z".to_string()),
),
]));
assert_eq!(rpc, expected);
}
#[test]
fn renders_method_call_xml_with_escaped_text() {
let call = XmlRpcMethodCall {
method_name: "aria2.addUri".to_string(),
params: vec![XmlRpcParam {
value: XmlRpcValue::Array(vec![XmlRpcValue::String(
"https://a.example/?q=1&v=<ok>".to_string(),
)]),
}],
meta: RpcMeta::default(),
};
let xml = xmlrpc_method_call_to_xml(&call);
assert!(xml.contains("<methodName>aria2.addUri</methodName>"));
assert!(xml.contains("&amp;"));
assert!(xml.contains("&lt;ok&gt;"));
}
#[test]
fn renders_fault_method_response_xml() {
let resp = XmlRpcMethodResponse {
value: None,
fault: Some(XmlRpcFault {
code: 3,
message: "bad <arg> & state".to_string(),
error: None,
}),
meta: RpcMeta::default(),
};
let xml = xmlrpc_method_response_to_xml(&resp);
assert!(xml.contains("<fault>"));
assert!(xml.contains("<name>faultCode</name>"));
assert!(xml.contains("<int>3</int>"));
assert!(xml.contains("bad &lt;arg&gt; &amp; state"));
}
#[test]
fn parses_aria2_style_multicall_raw_payload() {
let xml = r#"<?xml version="1.0"?>
<methodCall>
<methodName>system.multicall</methodName>
<params>
<param>
<value>
<array>
<data>
<value>
<struct>
<member><name>methodName</name><value><string>aria2.tellActive</string></value></member>
<member><name>params</name><value><array><data></data></array></value></member>
</struct>
</value>
<value>
<struct>
<member><name>methodName</name><value><string>aria2.tellWaiting</string></value></member>
<member><name>params</name><value><array><data><value><int>0</int></value><value><int>10</int></value></data></array></value></member>
</struct>
</value>
</data>
</array>
</value>
</param>
</params>
</methodCall>"#;
let call = xmlrpc_method_call_from_xml(xml).expect("parse aria2 multicall");
assert_eq!(call.method_name, "system.multicall");
assert_eq!(call.params.len(), 1);
}
#[test]
fn parses_empty_params_self_closing_for_call_and_response() {
let call_xml = r#"<?xml version="1.0"?>
<methodCall>
<methodName>aria2.tellActive</methodName>
<params/>
</methodCall>"#;
let call = xmlrpc_method_call_from_xml(call_xml).expect("parse methodCall with <params/>");
assert_eq!(call.method_name, "aria2.tellActive");
assert!(call.params.is_empty());
let resp_xml = r#"<?xml version="1.0"?>
<methodResponse>
<params/>
</methodResponse>"#;
let resp =
xmlrpc_method_response_from_xml(resp_xml).expect("parse methodResponse with <params/>");
assert!(resp.value.is_none());
assert!(resp.fault.is_none());
}
#[test]
fn parses_comments_processing_instructions_and_spaced_self_closing_tags() {
let call_xml = r#"<?xml version="1.0"?>
<!-- aria2-compatible XML-RPC client -->
<?client probe?>
<methodCall>
<methodName>system.listNotifications</methodName>
<!-- empty params -->
<params />
</methodCall>"#;
let call = xmlrpc_method_call_from_xml(call_xml).expect("parse decorated methodCall");
assert_eq!(call.method_name, "system.listNotifications");
assert!(call.params.is_empty());
let response_xml = r#"<?xml version="1.0"?>
<?client probe?>
<methodResponse>
<!-- empty response -->
<params />
</methodResponse>"#;
let response =
xmlrpc_method_response_from_xml(response_xml).expect("parse decorated methodResponse");
assert!(response.value.is_none());
assert!(response.fault.is_none());
}
#[test]
fn parses_empty_typed_values_and_collections() {
let xml = r#"<?xml version="1.0"?>
<methodCall>
<methodName>aria2.addUri</methodName>
<params>
<param><value /></param>
<param><value><string /></value></param>
<param><value><array><data /></array></value></param>
<param><value><struct /></value></param>
<param><value><nil></nil></value></param>
</params>
</methodCall>"#;
let call = xmlrpc_method_call_from_xml(xml).expect("parse empty typed values");
assert_eq!(
call.params,
vec![
XmlRpcParam {
value: XmlRpcValue::String(String::new()),
},
XmlRpcParam {
value: XmlRpcValue::String(String::new()),
},
XmlRpcParam {
value: XmlRpcValue::Array(Vec::new()),
},
XmlRpcParam {
value: XmlRpcValue::Struct(Vec::new()),
},
XmlRpcParam {
value: XmlRpcValue::Nil,
},
]
);
}
#[test]
fn unescapes_numeric_xml_entities_in_strings_and_member_names() {
let xml = r#"<?xml version="1.0"?>
<methodResponse>
<params>
<param>
<value>
<struct>
<member>
<name>line&#95;break</name>
<value><string>alpha&#10;beta&#x21;</string></value>
</member>
</struct>
</value>
</param>
</params>
</methodResponse>"#;
let response = xmlrpc_method_response_from_xml(xml).expect("parse numeric XML entities");
assert_eq!(
response.value,
Some(XmlRpcValue::Struct(vec![XmlRpcMember {
name: "line_break".to_string(),
value: XmlRpcValue::String("alpha\nbeta!".to_string()),
}]))
);
}
#[test]
fn converts_large_rpc_numbers_to_lossless_xmlrpc_strings() {
let value = rpc_value_to_xmlrpc(RpcValue::Number(i64::from(i32::MAX) + 1));
assert_eq!(value, XmlRpcValue::String("2147483648".to_string()));
}
#[test]
fn parses_fault_with_i4_code_and_escaped_message() {
let xml = r#"<?xml version="1.0"?>
<methodResponse>
<fault>
<value>
<struct>
<member><name>faultCode</name><value><i4>1</i4></value></member>
<member><name>faultString</name><value><string>Method not found: &lt;aria2.nope&gt;</string></value></member>
</struct>
</value>
</fault>
</methodResponse>"#;
let resp = xmlrpc_method_response_from_xml(xml).expect("parse aria2-ish fault");
let fault = resp.fault.expect("fault expected");
assert_eq!(fault.code, 1);
assert_eq!(fault.message, "Method not found: <aria2.nope>");
}
#[test]
fn parses_untyped_value_text_as_string() {
let xml = r#"<?xml version="1.0"?>
<methodResponse>
<params>
<param><value>OK</value></param>
</params>
</methodResponse>"#;
let resp = xmlrpc_method_response_from_xml(xml).expect("parse untyped text value");
assert_eq!(resp.value, Some(XmlRpcValue::String("OK".to_string())));
}
#[test]
fn parses_attribute_decorated_and_prefixed_value_tags() {
let xml = r#"<?xml version="1.0"?>
<methodCall xmlns:ex="http://ws.apache.org/xmlrpc/namespaces/extensions">
<methodName xml:space="preserve">aria2.tellStatus</methodName>
<params class="ignored">
<param>
<value>
<string xml:space="preserve">gid-1</string>
</value>
</param>
<param>
<value>
<ex:nil />
</value>
</param>
<param>
<value>
<base64 encoding="base64"/>
</value>
</param>
<param>
<value>
<array role="empty">
<data id="slot-1"/>
</array>
</value>
</param>
<param>
<value>
<struct role="empty"/>
</value>
</param>
</params>
</methodCall>"#;
let call = xmlrpc_method_call_from_xml(xml).expect("parse decorated XML-RPC call");
assert_eq!(call.method_name, "aria2.tellStatus");
assert_eq!(
call.params,
vec![
XmlRpcParam {
value: XmlRpcValue::String("gid-1".to_string()),
},
XmlRpcParam {
value: XmlRpcValue::Nil,
},
XmlRpcParam {
value: XmlRpcValue::Base64(Vec::new()),
},
XmlRpcParam {
value: XmlRpcValue::Array(Vec::new()),
},
XmlRpcParam {
value: XmlRpcValue::Struct(Vec::new()),
},
]
);
}
#[test]
fn parses_fault_with_i8_code_and_untyped_fault_string() {
let xml = r#"<?xml version="1.0"?>
<methodResponse>
<fault>
<value>
<struct>
<member><name>faultCode</name><value><i8>1</i8></value></member>
<member><name>faultString</name><value>Method not found: aria2.noSuchMethod</value></member>
</struct>
</value>
</fault>
</methodResponse>"#;
let resp = xmlrpc_method_response_from_xml(xml).expect("parse fault with i8 code");
assert_eq!(
resp.fault,
Some(XmlRpcFault {
code: 1,
message: "Method not found: aria2.noSuchMethod".to_string(),
error: None,
})
);
}
#[test]
fn parses_large_i8_losslessly_as_string_value() {
let xml = r#"<?xml version="1.0"?>
<methodResponse>
<params>
<param><value><i8>2147483648</i8></value></param>
</params>
</methodResponse>"#;
let resp = xmlrpc_method_response_from_xml(xml).expect("parse large i8 value");
assert_eq!(
resp.value,
Some(XmlRpcValue::String("2147483648".to_string()))
);
}
#[test]
fn parses_cdata_and_mixed_text_inside_string_and_method_name() {
let xml = r#"<?xml version="1.0"?>
<methodCall>
<methodName><![CDATA[system.listMethods]]></methodName>
<params>
<param>
<value>
<string><![CDATA[alpha<&>]]><!-- gap -->&#10;beta</string>
</value>
</param>
</params>
</methodCall>"#;
let call = xmlrpc_method_call_from_xml(xml).expect("parse CDATA-rich method call");
assert_eq!(call.method_name, "system.listMethods");
assert_eq!(
call.params,
vec![XmlRpcParam {
value: XmlRpcValue::String("alpha<&>\nbeta".to_string()),
}]
);
}
#[test]
fn parses_whitespace_padded_scalar_tags_losslessly() {
let xml = r#"<?xml version="1.0"?>
<methodResponse xmlns:ex="urn:example">
<params>
<param>
<value>
<array>
<data>
<value><ex:int>
7
</ex:int></value>
<value><i4> -2 </i4></value>
<value><i8>
2147483647
</i8></value>
<value><biginteger>
2147483648
</biginteger></value>
<value><double>
3.5
</double></value>
<value><dateTime.iso8601>
20260527T12:34:56
</dateTime.iso8601></value>
</data>
</array>
</value>
</param>
</params>
</methodResponse>"#;
let resp = xmlrpc_method_response_from_xml(xml).expect("parse spaced scalar values");
assert_eq!(
resp.value,
Some(XmlRpcValue::Array(vec![
XmlRpcValue::Int(7),
XmlRpcValue::Int(-2),
XmlRpcValue::Int(2_147_483_647),
XmlRpcValue::String("2147483648".to_string()),
XmlRpcValue::Double(3.5),
XmlRpcValue::DateTime("20260527T12:34:56".to_string()),
]))
);
}
#[test]
fn parses_fault_members_from_whitespace_padded_scalar_forms() {
let xml = r#"<?xml version="1.0"?>
<methodResponse>
<fault>
<value>
<struct>
<member><name>faultCode</name><value>
12
</value></member>
<member><name>faultString</name><value><boolean>
true
</boolean></value></member>
</struct>
</value>
</fault>
</methodResponse>"#;
let resp = xmlrpc_method_response_from_xml(xml).expect("parse padded fault fields");
assert_eq!(
resp.fault,
Some(XmlRpcFault {
code: 12,
message: "1".to_string(),
error: None,
})
);
}
#[test]
fn golden_renders_aria2_add_uri_method_call_shape() {
let call = XmlRpcMethodCall {
method_name: "aria2.addUri".to_string(),
params: vec![
XmlRpcParam {
value: XmlRpcValue::String("token:abc123".to_string()),
},
XmlRpcParam {
value: XmlRpcValue::Array(vec![XmlRpcValue::String(
"https://example.org/file.iso".to_string(),
)]),
},
XmlRpcParam {
value: XmlRpcValue::Struct(vec![
XmlRpcMember {
name: "split".to_string(),
value: XmlRpcValue::String("16".to_string()),
},
XmlRpcMember {
name: "max-connection-per-server".to_string(),
value: XmlRpcValue::String("16".to_string()),
},
]),
},
],
meta: RpcMeta::default(),
};
let xml = xmlrpc_method_call_to_xml(&call);
let expected = "<?xml version=\"1.0\"?><methodCall><methodName>aria2.addUri</methodName><params><param><value><string>token:abc123</string></value></param><param><value><array><data><value><string>https://example.org/file.iso</string></value></data></array></value></param><param><value><struct><member><name>split</name><value><string>16</string></value></member><member><name>max-connection-per-server</name><value><string>16</string></value></member></struct></value></param></params></methodCall>";
assert_eq!(xml, expected);
}
#[test]
fn golden_renders_xmlrpc_fault_struct_shape() {
let response = XmlRpcMethodResponse {
value: None,
fault: Some(XmlRpcFault {
code: 1,
message: "Method not found: aria2.notFound".to_string(),
error: None,
}),
meta: RpcMeta::default(),
};
let xml = xmlrpc_method_response_to_xml(&response);
let expected = "<?xml version=\"1.0\"?><methodResponse><fault><value><struct><member><name>faultCode</name><value><int>1</int></value></member><member><name>faultString</name><value><string>Method not found: aria2.notFound</string></value></member></struct></value></fault></methodResponse>";
assert_eq!(xml, expected);
}