chore: initial sanitized public snapshot
This commit is contained in:
@@ -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;
|
||||
+336
@@ -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"));
|
||||
}
|
||||
+338
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
+926
@@ -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;
|
||||
+488
@@ -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())));
|
||||
}
|
||||
+525
@@ -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}"));
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
pub(super) use super::*;
|
||||
|
||||
mod file_views;
|
||||
mod queue_mutation;
|
||||
mod queue_views;
|
||||
mod session_and_shutdown;
|
||||
mod transfer_runtime;
|
||||
+105
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
+155
@@ -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)
|
||||
);
|
||||
}
|
||||
+291
@@ -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]);
|
||||
}
|
||||
+32
@@ -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())));
|
||||
}
|
||||
+464
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
+324
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user