chore: initial sanitized public snapshot

This commit is contained in:
Aria2 Rust Pro Contributors
2026-07-18 14:51:59 +08:00
commit 14dcf8c9bf
321 changed files with 76893 additions and 0 deletions
@@ -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))
}
}