chore: initial sanitized public snapshot
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "aria2-rust-pro-rpc"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description.workspace = true
|
||||
readme.workspace = true
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "aria2_rust_pro_rpc"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
aria2-rust-pro-compat.workspace = true
|
||||
aria2-rust-pro-core.workspace = true
|
||||
aria2-rust-pro-protocol.workspace = true
|
||||
base64 = "0.22"
|
||||
serde_json = "1"
|
||||
sha1 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
aria2-rust-pro-storage.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,419 @@
|
||||
//! In-process RPC dispatcher backed by the download engine.
|
||||
#![expect(
|
||||
clippy::arithmetic_side_effects,
|
||||
clippy::assigning_clones,
|
||||
clippy::as_conversions,
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::clone_on_copy,
|
||||
clippy::cognitive_complexity,
|
||||
clippy::doc_markdown,
|
||||
clippy::float_arithmetic,
|
||||
clippy::format_collect,
|
||||
clippy::format_push_string,
|
||||
clippy::if_not_else,
|
||||
clippy::indexing_slicing,
|
||||
clippy::integer_division,
|
||||
clippy::into_iter_on_ref,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::manual_clamp,
|
||||
clippy::match_same_arms,
|
||||
clippy::missing_const_for_fn,
|
||||
clippy::missing_errors_doc,
|
||||
clippy::needless_collect,
|
||||
clippy::needless_pass_by_value,
|
||||
clippy::option_if_let_else,
|
||||
clippy::redundant_clone,
|
||||
clippy::redundant_closure_for_method_calls,
|
||||
clippy::too_many_arguments,
|
||||
clippy::too_many_lines,
|
||||
clippy::trivially_copy_pass_by_ref,
|
||||
clippy::unused_self,
|
||||
unused_imports,
|
||||
reason = "the aria2-compatible dispatcher is a monolithic compatibility and test surface where these lints add large volumes of noise without changing validated behavior"
|
||||
)]
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use aria2_rust_pro_core::{
|
||||
BtFileInfo, BtPeerInfo, BtPieceAvailabilityUpdate, BtRuntimeState, BtTrackerInfo, CoreError,
|
||||
DownloadEngine, DownloadId, DownloadStatus, OptionKey, OptionPatch, OptionValue, PieceId,
|
||||
PieceMap, PieceState, QueuePositionMode, RequestGroup, RuntimeConfig, SaveSessionTarget,
|
||||
};
|
||||
use aria2_rust_pro_protocol::{
|
||||
DhtMessageModel, DhtNodeModel, DhtTransport, HeaderKind, HttpResponseModel, MagnetUriModel,
|
||||
ResponseBody, TorrentMetadataModel, TrackerRequestModel, TrackerScrapeModel, TrackerTransport,
|
||||
magnet::MagnetBootstrapModel,
|
||||
parse_torrent_metadata,
|
||||
torrent::{
|
||||
DhtMessageBody, DhtResponseModel, PeerWireBlockRequestModel,
|
||||
PeerWireExtensionHandshakeModel, PeerWireHandshakeModel, PeerWireMessageKind,
|
||||
PeerWireMetadataMessageModel, PeerWireMetadataMessageType, PeerWirePieceBlockModel,
|
||||
TorrentMessageModel,
|
||||
},
|
||||
transport::{
|
||||
PeerWireTransportConnector, PeerWireTransportRequest, PeerWireTransportResponse,
|
||||
TransportEndpoint, TransportScheme,
|
||||
},
|
||||
};
|
||||
use sha1::{Digest, Sha1};
|
||||
|
||||
use crate::{
|
||||
handlers::RpcHandlerContext,
|
||||
jsonrpc::{JsonRpcRequest, JsonRpcResponse},
|
||||
model::{BT_STATUS_FIELDS, RpcAuthContext, RpcError, RpcMeta, RpcValue},
|
||||
router::{RpcDispatchRequest, RpcDispatchResult, RpcRouter},
|
||||
xmlrpc::{XmlRpcMember, XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, XmlRpcValue},
|
||||
};
|
||||
|
||||
/// In-process dispatcher that routes aria2-compatible RPC calls into the core engine.
|
||||
pub struct InProcessRpcDispatcher {
|
||||
/// Core download engine that owns the live download registry and runtime state.
|
||||
engine: DownloadEngine,
|
||||
/// Extension router for non-core RPC methods layered on top of aria2 compatibility.
|
||||
router: RpcRouter,
|
||||
/// Stable session identifier returned by `aria2.getSessionInfo`.
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
/// Lightweight per-download state used by internal callers that do not need a
|
||||
/// full `aria2.tellStatus` payload.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct RpcStatusSummary {
|
||||
/// Current user-visible lifecycle state.
|
||||
pub status: DownloadStatus,
|
||||
/// Expected payload length in bytes.
|
||||
pub total_length: u64,
|
||||
/// Completed payload length in bytes.
|
||||
pub completed_length: u64,
|
||||
/// Active connection count tracked for the download.
|
||||
pub connections: u32,
|
||||
/// Whether the download currently carries BitTorrent runtime state.
|
||||
pub is_bt: bool,
|
||||
}
|
||||
|
||||
/// BitTorrent runtime orchestration helpers used by RPC compatibility methods.
|
||||
mod bt_runtime;
|
||||
/// Shared compatibility-side runtime helpers for BitTorrent and related RPC shaping.
|
||||
mod compat_support;
|
||||
/// Top-level JSON-RPC and XML-RPC request ingress helpers.
|
||||
mod dispatch_surface;
|
||||
/// Shared JSON-RPC and XML-RPC fault shaping helpers.
|
||||
mod faults;
|
||||
/// Shared parser and conversion helpers for dispatcher methods.
|
||||
mod helpers;
|
||||
/// Mutating aria2-compatible RPC method implementations.
|
||||
mod mutations;
|
||||
/// Status/view payload builders for aria2-compatible RPC responses.
|
||||
mod payloads;
|
||||
/// Read-only aria2-compatible RPC method implementations.
|
||||
mod queries;
|
||||
/// Transfer registration and writeback helpers for add* RPC methods.
|
||||
mod transfer_runtime;
|
||||
|
||||
use self::bt_runtime::{
|
||||
BtRuntimeCoordinatorAction, BtRuntimeCoordinatorReport, BtRuntimeCoordinatorSnapshot,
|
||||
BtRuntimeCoordinatorStepReport, BtRuntimeCoordinatorStepStatus,
|
||||
};
|
||||
use self::compat_support::{
|
||||
BT_METADATA_PIECE_LENGTH, PeerWireExchangePlan, apply_bt_select_file_option,
|
||||
bt_metadata_piece_count, bt_metadata_piece_span, bt_peer_is_connectable, bt_peer_metadata_key,
|
||||
bt_piece_count, bt_piece_span_bytes, bt_runtime_total_length, bt_verified_length,
|
||||
build_bt_runtime_state, build_bt_runtime_state_from_magnet, build_dht_announce_peer_request,
|
||||
build_dht_find_node_request, build_dht_get_peers_request, build_dht_ping_request,
|
||||
build_peer_wire_exchange_plan, build_tracker_request, decode_hex_nibble,
|
||||
decode_hex_string_exact, default_bt_dht_nodes, generate_session_id, hex_string,
|
||||
initial_bt_dht_nodes, merge_bt_dht_nodes, merge_bt_peers, merge_bt_trackers,
|
||||
parse_bt_select_file_indexes, parse_dht_compact_nodes, parse_dht_compact_peers,
|
||||
parse_dht_node_spec, parse_peer_wire_exchange_response, peer_wire_bitfield_is_complete,
|
||||
pick_bt_dht_node, promote_bt_dht_node, push_bt_runtime_coordinator_result,
|
||||
resolve_bt_info_hash, rpc_bt_info_hash, rpc_bt_local_node_id, rpc_enabled_features,
|
||||
rpc_share_ratio_text, rpc_share_time_text, skipped_bt_runtime_coordinator_step,
|
||||
try_promote_bt_metadata, verified_length_for_range,
|
||||
};
|
||||
use self::{
|
||||
faults::{rpc_error_value, xmlrpc_error_value, xmlrpc_fault_from_error, xmlrpc_fault_value},
|
||||
helpers::{
|
||||
apply_group_options, decode_metalink_payload, filter_status_payload,
|
||||
first_forbidden_change_global_option_key, first_forbidden_change_option_key,
|
||||
i64_from_usize, is_retry_relevant_status, is_rpc_uri_candidate, metalink_default_options,
|
||||
option_specs_for_global_view, parse_content_range_completed_length,
|
||||
parse_optional_option_object, parse_optional_position, parse_optional_status_keys,
|
||||
parse_optional_uri_array, parse_required_file_index, parse_uri_array_allow_empty,
|
||||
parse_uri_list_param, rpc_uri_file_name, rpc_uri_has_ascii_prefix,
|
||||
rpc_uri_has_ascii_suffix, slice_handles_by_offset, u32_from_usize, u64_from_usize,
|
||||
usize_from_i64, usize_from_u64, xmlrpc_member_value,
|
||||
},
|
||||
};
|
||||
impl std::fmt::Debug for InProcessRpcDispatcher {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("InProcessRpcDispatcher")
|
||||
.field("tracked_download_count", &self.tracked_download_count())
|
||||
.field("session_id", &self.session_id)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InProcessRpcDispatcher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl InProcessRpcDispatcher {
|
||||
#[must_use]
|
||||
/// Creates a dispatcher using the default runtime configuration.
|
||||
pub fn new() -> Self {
|
||||
Self::with_runtime(RuntimeConfig::default())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Creates a dispatcher using an explicit runtime configuration.
|
||||
pub fn with_runtime(runtime: RuntimeConfig) -> Self {
|
||||
Self {
|
||||
engine: DownloadEngine::with_runtime(runtime),
|
||||
router: RpcRouter::new(),
|
||||
session_id: generate_session_id(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns the number of tracked downloads currently known to the engine.
|
||||
pub fn tracked_download_count(&self) -> usize {
|
||||
self.engine.task_count()
|
||||
}
|
||||
|
||||
/// Returns a lightweight summary for one tracked download without shaping a
|
||||
/// full RPC payload.
|
||||
pub fn status_summary_for_gid(&self, gid: &str) -> Result<RpcStatusSummary, RpcError> {
|
||||
let gid = parse_gid_text(gid)?;
|
||||
let group = self
|
||||
.engine
|
||||
.registry()
|
||||
.get(gid)
|
||||
.ok_or_else(|| missing_download_error(gid))?;
|
||||
Ok(RpcStatusSummary {
|
||||
status: *group.status(),
|
||||
total_length: group.total_length(),
|
||||
completed_length: group.completed_length(),
|
||||
connections: group.num_connections(),
|
||||
is_bt: group.bt().is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Registers a runtime listener with the underlying download engine.
|
||||
pub fn register_runtime_listener(
|
||||
&mut self,
|
||||
listener: impl aria2_rust_pro_core::EventListener + 'static,
|
||||
) {
|
||||
self.engine.register_listener(listener);
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps core state-transition failures into aria2-compatible RPC errors.
|
||||
fn state_transition_rpc_error(
|
||||
method: &'static str,
|
||||
gid: DownloadId,
|
||||
error: &CoreError,
|
||||
) -> RpcError {
|
||||
match method {
|
||||
"aria2.pause" | "aria2.forcePause" => {
|
||||
RpcError::unsupported(&format!("GID#{gid} cannot be paused now"))
|
||||
}
|
||||
"aria2.unpause" => RpcError::unsupported(&format!("GID#{gid} cannot be unpaused now")),
|
||||
"aria2.remove" | "aria2.forceRemove" => match error {
|
||||
CoreError::UnknownDownloadId(_) => {
|
||||
RpcError::unsupported(&format!("Active Download not found for GID#{gid}"))
|
||||
}
|
||||
_ => RpcError::unsupported(&format!("GID#{gid} cannot be removed now")),
|
||||
},
|
||||
_ => RpcError::unsupported(&error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an RPC GID string into the engine download identifier.
|
||||
fn parse_gid_text(gid: &str) -> Result<DownloadId, RpcError> {
|
||||
DownloadId::parse_hex(gid).ok_or_else(|| RpcError::unsupported(&format!("Invalid GID {gid}")))
|
||||
}
|
||||
|
||||
/// Builds the upstream-style missing-download error for a GID.
|
||||
fn missing_download_error(gid: DownloadId) -> RpcError {
|
||||
RpcError::unsupported(&format!("No such download for GID#{gid}"))
|
||||
}
|
||||
|
||||
/// Infers the effective transfer length from an HTTP response snapshot.
|
||||
fn http_response_length(response: &HttpResponseModel) -> Option<u64> {
|
||||
response
|
||||
.content_range
|
||||
.as_ref()
|
||||
.and_then(|range| range.total_size)
|
||||
.or_else(|| {
|
||||
response
|
||||
.headers
|
||||
.headers
|
||||
.iter()
|
||||
.find(|header| {
|
||||
header.kind == HeaderKind::Response
|
||||
&& header.name.eq_ignore_ascii_case("content-length")
|
||||
})
|
||||
.and_then(|header| header.value.parse::<u64>().ok())
|
||||
})
|
||||
.or_else(|| match &response.body {
|
||||
ResponseBody::Inline(bytes) => Some(bytes.len() as u64),
|
||||
ResponseBody::Streamed {
|
||||
expected_len,
|
||||
observed_len,
|
||||
..
|
||||
} => observed_len.or(*expected_len),
|
||||
ResponseBody::Empty => Some(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Infers the bytes transferred during the latest HTTP response chunk.
|
||||
fn http_response_delta_length(response: &HttpResponseModel) -> Option<u64> {
|
||||
match &response.body {
|
||||
ResponseBody::Inline(bytes) => Some(bytes.len() as u64),
|
||||
ResponseBody::Streamed { observed_len, .. } => *observed_len,
|
||||
ResponseBody::Empty => Some(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Infers the cumulative completed length represented by an HTTP response.
|
||||
fn http_response_completed_length(response: &HttpResponseModel) -> Option<u64> {
|
||||
let observed_len = match &response.body {
|
||||
ResponseBody::Inline(bytes) => Some(bytes.len() as u64),
|
||||
ResponseBody::Streamed { observed_len, .. } => *observed_len,
|
||||
ResponseBody::Empty => Some(0),
|
||||
};
|
||||
response
|
||||
.content_range
|
||||
.as_ref()
|
||||
.map(|range| {
|
||||
observed_len
|
||||
.map(|len| range.start.saturating_add(len))
|
||||
.unwrap_or_else(|| range.end_inclusive.saturating_add(1))
|
||||
})
|
||||
.or_else(|| {
|
||||
response
|
||||
.headers
|
||||
.headers
|
||||
.iter()
|
||||
.find(|header| {
|
||||
header.kind == HeaderKind::Response
|
||||
&& header.name.eq_ignore_ascii_case("content-range")
|
||||
})
|
||||
.and_then(|header| parse_content_range_completed_length(&header.value))
|
||||
})
|
||||
.or(observed_len)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod response_length_tests {
|
||||
use super::{http_response_completed_length, http_response_delta_length};
|
||||
use aria2_rust_pro_protocol::{
|
||||
ChecksumSpec, ContentRangeSpec, HeaderKind, HttpHeader, HttpResponseHeaders,
|
||||
HttpResponseModel, HttpVersion, RangeUnit, ResponseBody,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn streamed_lengths_prefer_observed_bytes_over_declared_content_length() {
|
||||
let response = HttpResponseModel {
|
||||
status: 200,
|
||||
reason: "OK".to_owned(),
|
||||
version: HttpVersion::Http11,
|
||||
headers: HttpResponseHeaders {
|
||||
headers: vec![HttpHeader {
|
||||
name: "Content-Length".to_owned(),
|
||||
value: "10".to_owned(),
|
||||
kind: HeaderKind::Response,
|
||||
}],
|
||||
},
|
||||
body: ResponseBody::Streamed {
|
||||
expected_len: Some(10),
|
||||
observed_len: Some(0),
|
||||
observed_digest: None,
|
||||
temp_path: None,
|
||||
},
|
||||
content_range: None,
|
||||
partial_content: false,
|
||||
checksum: Some(ChecksumSpec {
|
||||
algorithm: "sha-256".to_owned(),
|
||||
expected_hex: String::new(),
|
||||
actual_hex: None,
|
||||
}),
|
||||
redirected_from: None,
|
||||
};
|
||||
|
||||
assert_eq!(http_response_delta_length(&response), Some(0));
|
||||
assert_eq!(http_response_completed_length(&response), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_partial_completion_uses_observed_span_not_declared_range_tail() {
|
||||
let response = HttpResponseModel {
|
||||
status: 206,
|
||||
reason: "Partial Content".to_owned(),
|
||||
version: HttpVersion::Http11,
|
||||
headers: HttpResponseHeaders {
|
||||
headers: Vec::new(),
|
||||
},
|
||||
body: ResponseBody::Streamed {
|
||||
expected_len: Some(10),
|
||||
observed_len: Some(5),
|
||||
observed_digest: None,
|
||||
temp_path: None,
|
||||
},
|
||||
content_range: Some(ContentRangeSpec {
|
||||
unit: RangeUnit::Bytes,
|
||||
start: 0,
|
||||
end_inclusive: 9,
|
||||
total_size: Some(10),
|
||||
unsatisfied: false,
|
||||
}),
|
||||
partial_content: true,
|
||||
checksum: None,
|
||||
redirected_from: None,
|
||||
};
|
||||
|
||||
assert_eq!(http_response_delta_length(&response), Some(5));
|
||||
assert_eq!(http_response_completed_length(&response), Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_lengths_without_observed_bytes_do_not_claim_progress() {
|
||||
let response = HttpResponseModel {
|
||||
status: 200,
|
||||
reason: "OK".to_owned(),
|
||||
version: HttpVersion::Http11,
|
||||
headers: HttpResponseHeaders {
|
||||
headers: vec![HttpHeader {
|
||||
name: "Content-Length".to_owned(),
|
||||
value: "4096".to_owned(),
|
||||
kind: HeaderKind::Response,
|
||||
}],
|
||||
},
|
||||
body: ResponseBody::Streamed {
|
||||
expected_len: Some(4096),
|
||||
observed_len: None,
|
||||
observed_digest: None,
|
||||
temp_path: None,
|
||||
},
|
||||
content_range: None,
|
||||
partial_content: false,
|
||||
checksum: None,
|
||||
redirected_from: None,
|
||||
};
|
||||
|
||||
assert_eq!(http_response_delta_length(&response), None);
|
||||
assert_eq!(http_response_completed_length(&response), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Regression coverage for JSON-RPC, XML-RPC, and queue mutation dispatcher behavior.
|
||||
mod tests;
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Shared `BitTorrent` runtime coordination helpers for dispatcher entrypoints.
|
||||
|
||||
pub(super) use self::reporting::{
|
||||
BtRuntimeCoordinatorAction, BtRuntimeCoordinatorReport, BtRuntimeCoordinatorSnapshot,
|
||||
BtRuntimeCoordinatorStepReport, BtRuntimeCoordinatorStepStatus,
|
||||
};
|
||||
use super::{
|
||||
BtPeerInfo, BtPieceAvailabilityUpdate, DhtMessageBody, DhtMessageModel, DhtNodeModel,
|
||||
DhtResponseModel, DhtTransport, Digest, DownloadStatus, InProcessRpcDispatcher,
|
||||
PeerWireMetadataMessageType, PeerWireTransportConnector, PeerWireTransportResponse, PieceId,
|
||||
PieceState, RpcError, TrackerScrapeModel, TrackerTransport, bt_metadata_piece_span,
|
||||
bt_peer_is_connectable, bt_peer_metadata_key, bt_piece_count, bt_piece_span_bytes,
|
||||
bt_runtime_total_length, bt_verified_length, build_dht_announce_peer_request,
|
||||
build_dht_find_node_request, build_dht_get_peers_request, build_dht_ping_request,
|
||||
build_tracker_request, hex_string, merge_bt_dht_nodes, missing_download_error,
|
||||
parse_dht_compact_nodes, parse_dht_compact_peers, parse_dht_node_spec, parse_gid_text,
|
||||
peer_wire_bitfield_is_complete, promote_bt_dht_node, push_bt_runtime_coordinator_result,
|
||||
rpc_bt_info_hash, skipped_bt_runtime_coordinator_step, try_promote_bt_metadata, u32_from_usize,
|
||||
u64_from_usize,
|
||||
};
|
||||
|
||||
/// DHT runtime coordinator operations.
|
||||
mod dht;
|
||||
/// Peer-wire runtime coordinator operations.
|
||||
mod peer_wire;
|
||||
/// Coordinator report types shared with dispatcher tests and RPC payload shaping.
|
||||
mod reporting;
|
||||
/// Runtime snapshot and coordinator orchestration methods.
|
||||
mod runtime_state;
|
||||
/// Tracker runtime coordinator operations.
|
||||
mod tracker;
|
||||
@@ -0,0 +1,327 @@
|
||||
use super::{
|
||||
DhtMessageBody, DhtMessageModel, DhtNodeModel, DhtResponseModel, DhtTransport,
|
||||
InProcessRpcDispatcher, RpcError, build_dht_announce_peer_request, build_dht_find_node_request,
|
||||
build_dht_get_peers_request, build_dht_ping_request, merge_bt_dht_nodes,
|
||||
missing_download_error, parse_dht_compact_nodes, parse_dht_compact_peers, parse_gid_text,
|
||||
promote_bt_dht_node, u32_from_usize,
|
||||
};
|
||||
|
||||
impl InProcessRpcDispatcher {
|
||||
/// Applies a DHT get-peers response to a tracked download.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `gid` is invalid or the DHT payload cannot be applied.
|
||||
pub fn apply_dht_get_peers_result(
|
||||
&mut self,
|
||||
gid: &str,
|
||||
node: &DhtNodeModel,
|
||||
response: &DhtMessageModel,
|
||||
) -> Result<(), RpcError> {
|
||||
let gid = parse_gid_text(gid)?;
|
||||
|
||||
let get_peers = match &response.body {
|
||||
DhtMessageBody::Response(DhtResponseModel::GetPeers(model)) => model,
|
||||
DhtMessageBody::Error(error) => {
|
||||
return Err(RpcError::unsupported(&format!(
|
||||
"dht get_peers returned error {}: {}",
|
||||
error.code, error.message
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(RpcError::unsupported(
|
||||
"dht get_peers requires a get_peers response",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let peers = parse_dht_compact_peers(&get_peers.values)
|
||||
.map_err(|error| RpcError::unsupported(&format!("invalid dht peer values: {error}")))?;
|
||||
let discovered_nodes = parse_dht_compact_nodes(get_peers.nodes.as_deref())
|
||||
.map_err(|error| RpcError::unsupported(&format!("invalid dht nodes: {error}")))?;
|
||||
|
||||
if !peers.is_empty() {
|
||||
self.engine
|
||||
.apply_bt_peer_snapshot(gid, peers)
|
||||
.map_err(|error| RpcError::unsupported(&error.to_string()))?;
|
||||
}
|
||||
{
|
||||
let group = self
|
||||
.engine
|
||||
.handle_mut(gid)
|
||||
.ok_or_else(|| missing_download_error(gid))?;
|
||||
group.set_dht_token(get_peers.token.clone());
|
||||
}
|
||||
|
||||
let peer_count = self
|
||||
.engine
|
||||
.registry()
|
||||
.get(gid)
|
||||
.and_then(|group| group.bt())
|
||||
.map(|bt| u32_from_usize(bt.peers.len()))
|
||||
.unwrap_or(0);
|
||||
let group = self
|
||||
.engine
|
||||
.handle_mut(gid)
|
||||
.ok_or_else(|| missing_download_error(gid))?;
|
||||
group.set_num_connections(peer_count);
|
||||
let bt = group
|
||||
.bt_mut()
|
||||
.ok_or_else(|| RpcError::unsupported("dht apply requires bt runtime state"))?;
|
||||
merge_bt_dht_nodes(
|
||||
&mut bt.dht_nodes,
|
||||
std::iter::once(format!("{}:{}", node.address, node.port)).chain(discovered_nodes),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies a DHT ping response to a tracked download.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `gid` is invalid or the DHT payload cannot be applied.
|
||||
pub fn apply_dht_ping_result(
|
||||
&mut self,
|
||||
gid: &str,
|
||||
node: &DhtNodeModel,
|
||||
response: &DhtMessageModel,
|
||||
) -> Result<(), RpcError> {
|
||||
let gid = parse_gid_text(gid)?;
|
||||
|
||||
let ping = match &response.body {
|
||||
DhtMessageBody::Response(DhtResponseModel::Ping(model)) => model,
|
||||
DhtMessageBody::Error(error) => {
|
||||
return Err(RpcError::unsupported(&format!(
|
||||
"dht ping returned error {}: {}",
|
||||
error.code, error.message
|
||||
)));
|
||||
}
|
||||
_ => return Err(RpcError::unsupported("dht ping requires a ping response")),
|
||||
};
|
||||
|
||||
if ping.node_id.len() != 20 {
|
||||
return Err(RpcError::unsupported(
|
||||
"dht ping response node id must be 20 bytes",
|
||||
));
|
||||
}
|
||||
|
||||
let group = self
|
||||
.engine
|
||||
.handle_mut(gid)
|
||||
.ok_or_else(|| missing_download_error(gid))?;
|
||||
let bt = group
|
||||
.bt_mut()
|
||||
.ok_or_else(|| RpcError::unsupported("dht ping requires bt runtime state"))?;
|
||||
promote_bt_dht_node(&mut bt.dht_nodes, node);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies a DHT find-node response to a tracked download.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `gid` is invalid or the DHT payload cannot be applied.
|
||||
pub fn apply_dht_find_node_result(
|
||||
&mut self,
|
||||
gid: &str,
|
||||
node: &DhtNodeModel,
|
||||
response: &DhtMessageModel,
|
||||
) -> Result<(), RpcError> {
|
||||
let gid = parse_gid_text(gid)?;
|
||||
|
||||
let find_node = match &response.body {
|
||||
DhtMessageBody::Response(DhtResponseModel::FindNode(model)) => model,
|
||||
DhtMessageBody::Error(error) => {
|
||||
return Err(RpcError::unsupported(&format!(
|
||||
"dht find_node returned error {}: {}",
|
||||
error.code, error.message
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(RpcError::unsupported(
|
||||
"dht find_node requires a find_node response",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if find_node.node_id.len() != 20 {
|
||||
return Err(RpcError::unsupported(
|
||||
"dht find_node response node id must be 20 bytes",
|
||||
));
|
||||
}
|
||||
|
||||
let discovered_nodes = find_node
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|discovered| {
|
||||
format!(
|
||||
"{}.{}.{}.{}:{}",
|
||||
discovered.address[0],
|
||||
discovered.address[1],
|
||||
discovered.address[2],
|
||||
discovered.address[3],
|
||||
discovered.port
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let group = self
|
||||
.engine
|
||||
.handle_mut(gid)
|
||||
.ok_or_else(|| missing_download_error(gid))?;
|
||||
let bt = group
|
||||
.bt_mut()
|
||||
.ok_or_else(|| RpcError::unsupported("dht find_node requires bt runtime state"))?;
|
||||
promote_bt_dht_node(&mut bt.dht_nodes, node);
|
||||
merge_bt_dht_nodes(&mut bt.dht_nodes, discovered_nodes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies a DHT announce-peer response to a tracked download.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `gid` is invalid or the DHT payload cannot be applied.
|
||||
pub fn apply_dht_announce_peer_result(
|
||||
&mut self,
|
||||
gid: &str,
|
||||
node: &DhtNodeModel,
|
||||
response: &DhtMessageModel,
|
||||
) -> Result<(), RpcError> {
|
||||
let gid = parse_gid_text(gid)?;
|
||||
|
||||
let announce_peer = match &response.body {
|
||||
DhtMessageBody::Response(DhtResponseModel::Ping(model)) => model,
|
||||
DhtMessageBody::Error(error) => {
|
||||
return Err(RpcError::unsupported(&format!(
|
||||
"dht announce_peer returned error {}: {}",
|
||||
error.code, error.message
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(RpcError::unsupported(
|
||||
"dht announce_peer requires a ping-like response",
|
||||
));
|
||||
}
|
||||
};
|
||||
if announce_peer.node_id.len() != 20 {
|
||||
return Err(RpcError::unsupported(
|
||||
"dht announce_peer response node id must be 20 bytes",
|
||||
));
|
||||
}
|
||||
|
||||
let group = self
|
||||
.engine
|
||||
.handle_mut(gid)
|
||||
.ok_or_else(|| missing_download_error(gid))?;
|
||||
let bt = group
|
||||
.bt_mut()
|
||||
.ok_or_else(|| RpcError::unsupported("dht announce_peer requires bt runtime state"))?;
|
||||
promote_bt_dht_node(&mut bt.dht_nodes, node);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Executes a DHT ping using the provided transport.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `gid` is invalid, the DHT request cannot be built, or transport execution fails.
|
||||
pub fn execute_dht_ping<T: DhtTransport + ?Sized>(
|
||||
&mut self,
|
||||
gid: &str,
|
||||
transport: &T,
|
||||
) -> Result<(), RpcError> {
|
||||
let download_id = parse_gid_text(gid)?;
|
||||
let (node, request) = {
|
||||
let group = self
|
||||
.engine
|
||||
.registry()
|
||||
.get(download_id)
|
||||
.ok_or_else(|| missing_download_error(download_id))?;
|
||||
build_dht_ping_request(group)?
|
||||
};
|
||||
|
||||
let response = transport
|
||||
.send_message(&node, &request)
|
||||
.map_err(|error| RpcError::unsupported(&format!("dht ping failed: {error}")))?;
|
||||
self.apply_dht_ping_result(gid, &node, &response)
|
||||
}
|
||||
|
||||
/// Executes a DHT find-node query using the provided transport.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `gid` is invalid, the DHT request cannot be built, or transport execution fails.
|
||||
pub fn execute_dht_find_node<T: DhtTransport + ?Sized>(
|
||||
&mut self,
|
||||
gid: &str,
|
||||
transport: &T,
|
||||
) -> Result<(), RpcError> {
|
||||
let download_id = parse_gid_text(gid)?;
|
||||
let (node, request) = {
|
||||
let group = self
|
||||
.engine
|
||||
.registry()
|
||||
.get(download_id)
|
||||
.ok_or_else(|| missing_download_error(download_id))?;
|
||||
build_dht_find_node_request(group)?
|
||||
};
|
||||
|
||||
let response = transport
|
||||
.send_message(&node, &request)
|
||||
.map_err(|error| RpcError::unsupported(&format!("dht find_node failed: {error}")))?;
|
||||
self.apply_dht_find_node_result(gid, &node, &response)
|
||||
}
|
||||
|
||||
/// Executes a DHT announce-peer query using the provided transport.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `gid` is invalid, the DHT request cannot be built, or transport execution fails.
|
||||
pub fn execute_dht_announce_peer<T: DhtTransport + ?Sized>(
|
||||
&mut self,
|
||||
gid: &str,
|
||||
transport: &T,
|
||||
) -> Result<(), RpcError> {
|
||||
let download_id = parse_gid_text(gid)?;
|
||||
let (node, request) = {
|
||||
let group = self
|
||||
.engine
|
||||
.registry()
|
||||
.get(download_id)
|
||||
.ok_or_else(|| missing_download_error(download_id))?;
|
||||
build_dht_announce_peer_request(group)?
|
||||
};
|
||||
|
||||
let response = transport.send_message(&node, &request).map_err(|error| {
|
||||
RpcError::unsupported(&format!("dht announce_peer failed: {error}"))
|
||||
})?;
|
||||
self.apply_dht_announce_peer_result(gid, &node, &response)
|
||||
}
|
||||
|
||||
/// Executes a DHT get-peers query using the provided transport.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `gid` is invalid, the DHT request cannot be built, or transport execution fails.
|
||||
pub fn execute_dht_get_peers<T: DhtTransport + ?Sized>(
|
||||
&mut self,
|
||||
gid: &str,
|
||||
transport: &T,
|
||||
) -> Result<(), RpcError> {
|
||||
let download_id = parse_gid_text(gid)?;
|
||||
let (node, request) = {
|
||||
let group = self
|
||||
.engine
|
||||
.registry()
|
||||
.get(download_id)
|
||||
.ok_or_else(|| missing_download_error(download_id))?;
|
||||
build_dht_get_peers_request(group)?
|
||||
};
|
||||
|
||||
let response = transport
|
||||
.send_message(&node, &request)
|
||||
.map_err(|error| RpcError::unsupported(&format!("dht get_peers failed: {error}")))?;
|
||||
self.apply_dht_get_peers_result(gid, &node, &response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
use super::{
|
||||
BtPieceAvailabilityUpdate, DownloadStatus, InProcessRpcDispatcher, PeerWireMetadataMessageType,
|
||||
PeerWireTransportConnector, PeerWireTransportResponse, PieceId, PieceState, RpcError,
|
||||
bt_metadata_piece_span, bt_peer_metadata_key, bt_piece_count, bt_piece_span_bytes,
|
||||
bt_runtime_total_length, bt_verified_length, missing_download_error, parse_gid_text,
|
||||
peer_wire_bitfield_is_complete, try_promote_bt_metadata, u32_from_usize, u64_from_usize,
|
||||
};
|
||||
use crate::dispatcher::compat_support::{
|
||||
PeerWireExchangePlan, build_peer_wire_exchange_plan, parse_peer_wire_exchange_response,
|
||||
};
|
||||
|
||||
impl InProcessRpcDispatcher {
|
||||
/// Executes a peer-wire exchange using the provided transport connector.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `gid` is invalid, the exchange plan cannot be built, or transport execution fails.
|
||||
pub fn execute_peer_wire_exchange<T: PeerWireTransportConnector + ?Sized>(
|
||||
&mut self,
|
||||
gid: &str,
|
||||
transport: &T,
|
||||
) -> Result<(), RpcError> {
|
||||
let download_id = parse_gid_text(gid)?;
|
||||
let plan = {
|
||||
let group = self
|
||||
.engine
|
||||
.registry()
|
||||
.get(download_id)
|
||||
.ok_or_else(|| missing_download_error(download_id))?;
|
||||
build_peer_wire_exchange_plan(group)?
|
||||
};
|
||||
let response = transport
|
||||
.connect_peer_wire(&plan.request)
|
||||
.map_err(|error| {
|
||||
RpcError::unsupported(&format!("peer-wire exchange failed: {error}"))
|
||||
})?;
|
||||
self.apply_peer_wire_exchange_result(gid, &plan, &response)
|
||||
}
|
||||
|
||||
/// Applies a peer-wire exchange result to BitTorrent runtime state and piece progress.
|
||||
fn apply_peer_wire_exchange_result(
|
||||
&mut self,
|
||||
gid: &str,
|
||||
plan: &PeerWireExchangePlan,
|
||||
response: &PeerWireTransportResponse,
|
||||
) -> Result<(), RpcError> {
|
||||
let gid = parse_gid_text(gid)?;
|
||||
let parsed = parse_peer_wire_exchange_response(
|
||||
response,
|
||||
&plan.info_hash,
|
||||
plan.metadata_extension_id,
|
||||
)?;
|
||||
let should_complete;
|
||||
{
|
||||
let group = self
|
||||
.engine
|
||||
.handle_mut(gid)
|
||||
.ok_or_else(|| missing_download_error(gid))?;
|
||||
if group.bt().is_none() {
|
||||
return Err(RpcError::unsupported(
|
||||
"peer-wire apply requires bt runtime state",
|
||||
));
|
||||
}
|
||||
let mut runtime_metadata_only = group.bt().is_some_and(|bt| bt.metadata_only);
|
||||
{
|
||||
let bt = group.bt_mut().ok_or_else(|| {
|
||||
RpcError::unsupported("peer-wire apply requires bt runtime state")
|
||||
})?;
|
||||
let peer = bt.peers.get_mut(plan.peer_index).ok_or_else(|| {
|
||||
RpcError::unsupported(
|
||||
"peer-wire apply target peer disappeared from runtime state",
|
||||
)
|
||||
})?;
|
||||
let peer_key = bt_peer_metadata_key(peer);
|
||||
|
||||
if let Some(peer_id) = parsed.peer_id.clone() {
|
||||
peer.peer_id = Some(peer_id);
|
||||
}
|
||||
if let Some(choked) = parsed.peer_choked {
|
||||
peer.choked = choked;
|
||||
}
|
||||
if let Some(interested) = parsed.peer_interested {
|
||||
peer.interested = interested;
|
||||
}
|
||||
if let Some(extension_handshake) = &parsed.extension_handshake {
|
||||
if let Some(client_name) = &extension_handshake.client_name {
|
||||
peer.client_name = Some(client_name.clone());
|
||||
}
|
||||
if let Some(extension_message_id) = extension_handshake.ut_metadata_id() {
|
||||
bt.metadata_extension_ids
|
||||
.insert(peer_key.clone(), extension_message_id);
|
||||
}
|
||||
if let Some(metadata_size) = extension_handshake.metadata_size {
|
||||
bt.metadata_size = Some(metadata_size);
|
||||
}
|
||||
}
|
||||
|
||||
let mut known_metadata_size = bt.metadata_size;
|
||||
for message in &parsed.metadata_messages {
|
||||
match message.message_type {
|
||||
PeerWireMetadataMessageType::Request
|
||||
| PeerWireMetadataMessageType::Reject => {}
|
||||
PeerWireMetadataMessageType::Data => {
|
||||
let total_size = message.total_size.ok_or_else(|| {
|
||||
RpcError::unsupported(
|
||||
"ut_metadata data payload must include total_size",
|
||||
)
|
||||
})?;
|
||||
if let Some(existing_size) = known_metadata_size {
|
||||
if existing_size != total_size {
|
||||
return Err(RpcError::unsupported(&format!(
|
||||
"conflicting magnet metadata size: expected {existing_size}, got {total_size}",
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
bt.metadata_size = Some(total_size);
|
||||
known_metadata_size = Some(total_size);
|
||||
}
|
||||
let expected_len = bt_metadata_piece_span(total_size, message.piece);
|
||||
if expected_len == 0 {
|
||||
return Err(RpcError::unsupported(&format!(
|
||||
"ut_metadata piece {} exceeded metadata size {total_size}",
|
||||
message.piece
|
||||
)));
|
||||
}
|
||||
if message.payload.len() > expected_len {
|
||||
return Err(RpcError::unsupported(&format!(
|
||||
"ut_metadata piece {} payload too large: expected at most {expected_len} bytes, got {}",
|
||||
message.piece,
|
||||
message.payload.len()
|
||||
)));
|
||||
}
|
||||
bt.metadata_piece_payloads
|
||||
.insert(message.piece, message.payload.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if runtime_metadata_only {
|
||||
let _ = try_promote_bt_metadata(group)?;
|
||||
runtime_metadata_only = group.bt().is_some_and(|bt| bt.metadata_only);
|
||||
}
|
||||
if let Some(request) = &plan.block_request
|
||||
&& !runtime_metadata_only
|
||||
{
|
||||
let requested_piece = PieceId(request.piece_index);
|
||||
if group.piece_state(requested_piece) != Some(PieceState::Verified) {
|
||||
group.set_piece_state(requested_piece, PieceState::Downloading);
|
||||
}
|
||||
}
|
||||
|
||||
let mut downloaded_delta = 0_u64;
|
||||
if !runtime_metadata_only {
|
||||
let piece_length = group.piece_length().max(1);
|
||||
let total_length = bt_runtime_total_length(group);
|
||||
for piece in &parsed.pieces {
|
||||
let piece_id = PieceId(piece.piece_index);
|
||||
if piece.block.is_empty() {
|
||||
continue;
|
||||
}
|
||||
downloaded_delta =
|
||||
downloaded_delta.saturating_add(u64_from_usize(piece.block.len()));
|
||||
let expected_len =
|
||||
bt_piece_span_bytes(piece_id, piece_length, total_length).max(1);
|
||||
if piece.block_offset == 0 && u64_from_usize(piece.block.len()) >= expected_len
|
||||
{
|
||||
group.set_piece_state(piece_id, PieceState::Verified);
|
||||
} else if group.piece_state(piece_id) != Some(PieceState::Verified) {
|
||||
group.set_piece_state(piece_id, PieceState::Downloading);
|
||||
}
|
||||
}
|
||||
let num_pieces = bt_piece_count(total_length, piece_length);
|
||||
let completed_length = bt_verified_length(group, piece_length, total_length);
|
||||
if completed_length > group.completed_length() {
|
||||
group.set_completed_length(completed_length);
|
||||
}
|
||||
for piece in &parsed.available_pieces {
|
||||
if *piece >= u32_from_usize(num_pieces) {
|
||||
continue;
|
||||
}
|
||||
group.apply_bt_piece_availability_update(BtPieceAvailabilityUpdate {
|
||||
piece_id: PieceId(*piece),
|
||||
peers_with_piece: 1,
|
||||
});
|
||||
}
|
||||
should_complete = total_length > 0 && completed_length >= total_length;
|
||||
} else {
|
||||
should_complete = false;
|
||||
}
|
||||
group.set_download_speed(downloaded_delta);
|
||||
if !matches!(
|
||||
group.status(),
|
||||
DownloadStatus::Complete | DownloadStatus::Removed
|
||||
) {
|
||||
group.set_status(DownloadStatus::Active);
|
||||
}
|
||||
|
||||
let peer_runtime_piece_length = group.piece_length().max(1);
|
||||
let peer_runtime_total_length = bt_runtime_total_length(group);
|
||||
let peer_runtime_piece_count =
|
||||
bt_piece_count(peer_runtime_total_length, peer_runtime_piece_length);
|
||||
let peer_stats;
|
||||
{
|
||||
let bt = group.bt_mut().ok_or_else(|| {
|
||||
RpcError::unsupported("peer-wire apply requires bt runtime state")
|
||||
})?;
|
||||
let peer = bt.peers.get_mut(plan.peer_index).ok_or_else(|| {
|
||||
RpcError::unsupported(
|
||||
"peer-wire apply target peer disappeared from runtime state",
|
||||
)
|
||||
})?;
|
||||
if let Some(peer_id) = parsed.peer_id.clone() {
|
||||
peer.peer_id = Some(peer_id);
|
||||
}
|
||||
if let Some(choked) = parsed.peer_choked {
|
||||
peer.choked = choked;
|
||||
}
|
||||
if let Some(interested) = parsed.peer_interested {
|
||||
peer.interested = interested;
|
||||
}
|
||||
peer.download_speed = downloaded_delta;
|
||||
peer.upload_speed = u64_from_usize(plan.request.payload.len());
|
||||
if let Some(bitfield) = &parsed.bitfield_pieces {
|
||||
peer.seeder =
|
||||
peer_wire_bitfield_is_complete(bitfield, peer_runtime_piece_count);
|
||||
} else if parsed.available_pieces.len() >= peer_runtime_piece_count
|
||||
&& peer_runtime_piece_count > 0
|
||||
{
|
||||
peer.seeder = (0..u32_from_usize(peer_runtime_piece_count))
|
||||
.all(|piece| parsed.available_pieces.contains(&piece));
|
||||
}
|
||||
let updated_peer = peer.clone();
|
||||
peer_stats = group.apply_bt_peer_update(updated_peer);
|
||||
}
|
||||
group.set_num_connections(u32_from_usize(peer_stats.peer_count));
|
||||
}
|
||||
if should_complete {
|
||||
self.engine
|
||||
.complete(gid)
|
||||
.map_err(|_| missing_download_error(gid))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use super::DownloadStatus;
|
||||
|
||||
/// One coordinator-visible BitTorrent action that the dispatcher can execute in a loop.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum BtRuntimeCoordinatorAction {
|
||||
/// Advance share/seeding timers using the supplied wall clock.
|
||||
AdvanceClock,
|
||||
/// Refresh peers and tracker metadata from the primary tracker.
|
||||
TrackerAnnounce,
|
||||
/// Query DHT for peers against the current info hash.
|
||||
DhtGetPeers,
|
||||
/// Expand the DHT node frontier when peers are still unavailable.
|
||||
DhtFindNode,
|
||||
/// Announce the local presence back into DHT once a token is cached.
|
||||
DhtAnnouncePeer,
|
||||
/// Perform one peer-wire request/response exchange against the best current peer.
|
||||
PeerWireExchange,
|
||||
}
|
||||
|
||||
/// Outcome classification for one coordinator-visible BitTorrent action.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum BtRuntimeCoordinatorStepStatus {
|
||||
/// The dispatcher executed the action successfully.
|
||||
Executed,
|
||||
/// The dispatcher intentionally skipped the action because a prerequisite was absent.
|
||||
Skipped,
|
||||
/// The dispatcher attempted the action and it failed.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Detailed result for one BitTorrent coordinator action.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct BtRuntimeCoordinatorStepReport {
|
||||
/// Action that was evaluated.
|
||||
pub action: BtRuntimeCoordinatorAction,
|
||||
/// Final status for the action in this loop iteration.
|
||||
pub status: BtRuntimeCoordinatorStepStatus,
|
||||
/// Optional human-readable detail for skips and failures.
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// Snapshot of dispatcher-visible BitTorrent runtime state for loop orchestration.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
#[expect(
|
||||
clippy::struct_excessive_bools,
|
||||
reason = "aria2-compatible BT status snapshots intentionally surface several independent boolean facets"
|
||||
)]
|
||||
pub struct BtRuntimeCoordinatorSnapshot {
|
||||
/// Download GID in aria2 hex form.
|
||||
pub gid: String,
|
||||
/// Current aria2/core download state.
|
||||
pub status: DownloadStatus,
|
||||
/// Whether the BT runtime currently considers the local side seeding.
|
||||
pub seeding: bool,
|
||||
/// Aggregate number of bytes marked complete in the request group.
|
||||
pub completed_length: u64,
|
||||
/// Aggregate total length known to the runtime.
|
||||
pub total_length: u64,
|
||||
/// Live connection count currently exposed through aria2 status surfaces.
|
||||
pub connections: u32,
|
||||
/// Number of configured tracker entries in BT runtime state.
|
||||
pub tracker_count: usize,
|
||||
/// Total DHT node entries currently cached, including malformed ones.
|
||||
pub dht_node_count: usize,
|
||||
/// Number of DHT node entries that can actually be converted into transport targets.
|
||||
pub addressable_dht_node_count: usize,
|
||||
/// Total peer rows currently cached in BT runtime state.
|
||||
pub peer_count: usize,
|
||||
/// Number of peers that are currently usable for peer-wire transport.
|
||||
pub connectable_peer_count: usize,
|
||||
/// Whether a DHT announce token is already cached from a prior get_peers response.
|
||||
pub has_dht_token: bool,
|
||||
/// Whether the BT runtime is still metadata-only.
|
||||
pub metadata_only: bool,
|
||||
/// Whether this looks like a magnet-backed partial path that still lacks metadata exchange support.
|
||||
pub metadata_exchange_pending: bool,
|
||||
/// Number of locally requestable pieces remaining under the current piece state.
|
||||
pub requestable_piece_count: usize,
|
||||
/// Dispatcher-level action suggestions derived from the current snapshot.
|
||||
pub recommended_actions: Vec<BtRuntimeCoordinatorAction>,
|
||||
}
|
||||
|
||||
/// Full report for one dispatcher-driven BitTorrent loop iteration.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct BtRuntimeCoordinatorReport {
|
||||
/// Snapshot captured before any coordinator actions were attempted.
|
||||
pub initial_snapshot: BtRuntimeCoordinatorSnapshot,
|
||||
/// Snapshot captured after the last attempted coordinator action.
|
||||
pub final_snapshot: BtRuntimeCoordinatorSnapshot,
|
||||
/// Ordered per-action results for this iteration.
|
||||
pub steps: Vec<BtRuntimeCoordinatorStepReport>,
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
use super::{
|
||||
BtRuntimeCoordinatorAction, BtRuntimeCoordinatorReport, BtRuntimeCoordinatorSnapshot,
|
||||
DhtTransport, Digest, InProcessRpcDispatcher, PeerWireTransportConnector, RpcError,
|
||||
TrackerTransport, bt_peer_is_connectable, bt_runtime_total_length, missing_download_error,
|
||||
parse_dht_node_spec, parse_gid_text, push_bt_runtime_coordinator_result,
|
||||
skipped_bt_runtime_coordinator_step,
|
||||
};
|
||||
|
||||
impl InProcessRpcDispatcher {
|
||||
/// Applies a BitTorrent runtime tick update to a tracked download.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `gid` is invalid or the engine rejects the runtime update.
|
||||
pub fn apply_bt_runtime_tick(
|
||||
&mut self,
|
||||
gid: &str,
|
||||
downloaded_delta: u64,
|
||||
uploaded_delta: u64,
|
||||
download_speed: u64,
|
||||
upload_speed: u64,
|
||||
share_time_delta_secs: u64,
|
||||
seeding_time_delta_secs: u64,
|
||||
seeding: bool,
|
||||
num_connections: Option<u32>,
|
||||
) -> Result<(), RpcError> {
|
||||
let gid = parse_gid_text(gid)?;
|
||||
self.engine
|
||||
.apply_bt_runtime_tick(
|
||||
gid,
|
||||
downloaded_delta,
|
||||
uploaded_delta,
|
||||
download_speed,
|
||||
upload_speed,
|
||||
share_time_delta_secs,
|
||||
seeding_time_delta_secs,
|
||||
seeding,
|
||||
num_connections,
|
||||
)
|
||||
.map_err(|error| RpcError::unsupported(&error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Advances the BitTorrent runtime clock for a tracked download.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `gid` is invalid or the engine rejects the clock update.
|
||||
pub fn tick_bt_runtime_clock(
|
||||
&mut self,
|
||||
gid: &str,
|
||||
now_unix_secs: u64,
|
||||
seeding: bool,
|
||||
) -> Result<(), RpcError> {
|
||||
let gid = parse_gid_text(gid)?;
|
||||
self.engine
|
||||
.tick_bt_runtime_clock(gid, now_unix_secs, seeding)
|
||||
.map_err(|error| RpcError::unsupported(&error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets whether a BitTorrent download is currently seeding.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `gid` is invalid or the engine rejects the seeding update.
|
||||
pub fn set_bt_seeding_state(
|
||||
&mut self,
|
||||
gid: &str,
|
||||
seeding: bool,
|
||||
at_unix_secs: Option<u64>,
|
||||
) -> Result<(), RpcError> {
|
||||
let gid = parse_gid_text(gid)?;
|
||||
self.engine
|
||||
.set_bt_seeding_state(gid, seeding, at_unix_secs)
|
||||
.map_err(|error| RpcError::unsupported(&error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Captures a coordinator-friendly BitTorrent runtime snapshot for one download.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `gid` is invalid, missing, or does not currently own BT runtime state.
|
||||
pub fn bt_runtime_coordinator_snapshot(
|
||||
&self,
|
||||
gid: &str,
|
||||
) -> Result<BtRuntimeCoordinatorSnapshot, RpcError> {
|
||||
let gid = parse_gid_text(gid)?;
|
||||
let group = self
|
||||
.engine
|
||||
.registry()
|
||||
.get(gid)
|
||||
.ok_or_else(|| missing_download_error(gid))?;
|
||||
let bt = group
|
||||
.bt()
|
||||
.ok_or_else(|| RpcError::unsupported("bt coordinator requires bt runtime state"))?;
|
||||
|
||||
let has_dht_token = group.dht_token().is_some();
|
||||
let addressable_dht_node_count = bt
|
||||
.dht_nodes
|
||||
.iter()
|
||||
.filter(|raw| parse_dht_node_spec(raw).is_ok())
|
||||
.count();
|
||||
let connectable_peer_count = bt
|
||||
.peers
|
||||
.iter()
|
||||
.filter(|peer| bt_peer_is_connectable(peer))
|
||||
.count();
|
||||
let (pending, queued, _, _, missing, _) = group.piece_state_counts();
|
||||
let requestable_piece_count = pending + queued + missing;
|
||||
let metadata_exchange_pending = bt.metadata_only && bt.magnet_uri.is_some();
|
||||
|
||||
let mut recommended_actions = Vec::new();
|
||||
if !bt.trackers.is_empty() {
|
||||
recommended_actions.push(BtRuntimeCoordinatorAction::TrackerAnnounce);
|
||||
}
|
||||
if addressable_dht_node_count > 0 {
|
||||
recommended_actions.push(BtRuntimeCoordinatorAction::DhtGetPeers);
|
||||
if connectable_peer_count == 0 {
|
||||
recommended_actions.push(BtRuntimeCoordinatorAction::DhtFindNode);
|
||||
}
|
||||
if has_dht_token {
|
||||
recommended_actions.push(BtRuntimeCoordinatorAction::DhtAnnouncePeer);
|
||||
}
|
||||
}
|
||||
if connectable_peer_count > 0 {
|
||||
recommended_actions.push(BtRuntimeCoordinatorAction::PeerWireExchange);
|
||||
}
|
||||
|
||||
Ok(BtRuntimeCoordinatorSnapshot {
|
||||
gid: format!("{:016x}", gid.as_u64()),
|
||||
status: *group.status(),
|
||||
seeding: group.bt_is_seeding(),
|
||||
completed_length: group.completed_length(),
|
||||
total_length: bt_runtime_total_length(group),
|
||||
connections: group.num_connections(),
|
||||
tracker_count: bt.trackers.len(),
|
||||
dht_node_count: bt.dht_nodes.len(),
|
||||
addressable_dht_node_count,
|
||||
peer_count: bt.peers.len(),
|
||||
connectable_peer_count,
|
||||
has_dht_token,
|
||||
metadata_only: bt.metadata_only,
|
||||
metadata_exchange_pending,
|
||||
requestable_piece_count,
|
||||
recommended_actions,
|
||||
})
|
||||
}
|
||||
|
||||
/// Drives one coordinator-friendly BitTorrent loop iteration using any available transports.
|
||||
///
|
||||
/// This helper intentionally stays transport-neutral: it consumes already-built tracker, DHT,
|
||||
/// and peer-wire transports when provided, and it reports the remaining metadata-only magnet gap
|
||||
/// instead of pretending to implement BEP9/ut_metadata locally.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `gid` is invalid, missing, or does not currently own BT runtime state.
|
||||
pub fn drive_bt_runtime_once(
|
||||
&mut self,
|
||||
gid: &str,
|
||||
tracker_transport: Option<&dyn TrackerTransport>,
|
||||
dht_transport: Option<&dyn DhtTransport>,
|
||||
peer_wire_transport: Option<&dyn PeerWireTransportConnector>,
|
||||
now_unix_secs: Option<u64>,
|
||||
) -> Result<BtRuntimeCoordinatorReport, RpcError> {
|
||||
let initial_snapshot = self.bt_runtime_coordinator_snapshot(gid)?;
|
||||
let mut steps = Vec::new();
|
||||
|
||||
if let Some(now_unix_secs) = now_unix_secs {
|
||||
push_bt_runtime_coordinator_result(
|
||||
&mut steps,
|
||||
BtRuntimeCoordinatorAction::AdvanceClock,
|
||||
self.tick_bt_runtime_clock(gid, now_unix_secs, initial_snapshot.seeding),
|
||||
);
|
||||
}
|
||||
|
||||
let mut snapshot = self.bt_runtime_coordinator_snapshot(gid)?;
|
||||
if snapshot.tracker_count > 0 {
|
||||
if let Some(transport) = tracker_transport {
|
||||
push_bt_runtime_coordinator_result(
|
||||
&mut steps,
|
||||
BtRuntimeCoordinatorAction::TrackerAnnounce,
|
||||
self.execute_tracker_announce(gid, transport),
|
||||
);
|
||||
snapshot = self.bt_runtime_coordinator_snapshot(gid)?;
|
||||
} else {
|
||||
steps.push(skipped_bt_runtime_coordinator_step(
|
||||
BtRuntimeCoordinatorAction::TrackerAnnounce,
|
||||
"tracker transport unavailable",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if snapshot.addressable_dht_node_count > 0 {
|
||||
if let Some(transport) = dht_transport {
|
||||
push_bt_runtime_coordinator_result(
|
||||
&mut steps,
|
||||
BtRuntimeCoordinatorAction::DhtGetPeers,
|
||||
self.execute_dht_get_peers(gid, transport),
|
||||
);
|
||||
snapshot = self.bt_runtime_coordinator_snapshot(gid)?;
|
||||
} else {
|
||||
steps.push(skipped_bt_runtime_coordinator_step(
|
||||
BtRuntimeCoordinatorAction::DhtGetPeers,
|
||||
"dht transport unavailable",
|
||||
));
|
||||
}
|
||||
} else if snapshot.dht_node_count > 0 {
|
||||
steps.push(skipped_bt_runtime_coordinator_step(
|
||||
BtRuntimeCoordinatorAction::DhtGetPeers,
|
||||
"no usable dht nodes in runtime state",
|
||||
));
|
||||
}
|
||||
|
||||
if snapshot.addressable_dht_node_count > 0 && snapshot.connectable_peer_count == 0 {
|
||||
if let Some(transport) = dht_transport {
|
||||
push_bt_runtime_coordinator_result(
|
||||
&mut steps,
|
||||
BtRuntimeCoordinatorAction::DhtFindNode,
|
||||
self.execute_dht_find_node(gid, transport),
|
||||
);
|
||||
snapshot = self.bt_runtime_coordinator_snapshot(gid)?;
|
||||
} else {
|
||||
steps.push(skipped_bt_runtime_coordinator_step(
|
||||
BtRuntimeCoordinatorAction::DhtFindNode,
|
||||
"dht transport unavailable",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if snapshot.addressable_dht_node_count > 0 && snapshot.has_dht_token {
|
||||
if let Some(transport) = dht_transport {
|
||||
push_bt_runtime_coordinator_result(
|
||||
&mut steps,
|
||||
BtRuntimeCoordinatorAction::DhtAnnouncePeer,
|
||||
self.execute_dht_announce_peer(gid, transport),
|
||||
);
|
||||
snapshot = self.bt_runtime_coordinator_snapshot(gid)?;
|
||||
} else {
|
||||
steps.push(skipped_bt_runtime_coordinator_step(
|
||||
BtRuntimeCoordinatorAction::DhtAnnouncePeer,
|
||||
"dht transport unavailable",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if snapshot.connectable_peer_count > 0 {
|
||||
if let Some(transport) = peer_wire_transport {
|
||||
push_bt_runtime_coordinator_result(
|
||||
&mut steps,
|
||||
BtRuntimeCoordinatorAction::PeerWireExchange,
|
||||
self.execute_peer_wire_exchange(gid, transport),
|
||||
);
|
||||
} else {
|
||||
steps.push(skipped_bt_runtime_coordinator_step(
|
||||
BtRuntimeCoordinatorAction::PeerWireExchange,
|
||||
"peer-wire transport unavailable",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(BtRuntimeCoordinatorReport {
|
||||
initial_snapshot,
|
||||
final_snapshot: self.bt_runtime_coordinator_snapshot(gid)?,
|
||||
steps,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
use super::{
|
||||
BtPeerInfo, InProcessRpcDispatcher, RpcError, TrackerScrapeModel, TrackerTransport,
|
||||
build_tracker_request, hex_string, missing_download_error, parse_gid_text, rpc_bt_info_hash,
|
||||
};
|
||||
|
||||
impl InProcessRpcDispatcher {
|
||||
/// Applies a tracker announce response to a tracked download.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `gid` is invalid or the tracker payload cannot be applied.
|
||||
pub fn apply_tracker_announce_result(
|
||||
&mut self,
|
||||
gid: &str,
|
||||
response: &aria2_rust_pro_protocol::tracker::TrackerResponseModel,
|
||||
) -> Result<(), RpcError> {
|
||||
let gid = parse_gid_text(gid)?;
|
||||
let (tracker_url, peers) = {
|
||||
let group = self
|
||||
.engine
|
||||
.registry()
|
||||
.get(gid)
|
||||
.ok_or_else(|| missing_download_error(gid))?;
|
||||
let tracker_url = group
|
||||
.bt()
|
||||
.and_then(|bt| bt.trackers.first().map(|tracker| tracker.url.clone()))
|
||||
.ok_or_else(|| {
|
||||
RpcError::unsupported("tracker announce apply requires at least one tracker")
|
||||
})?;
|
||||
let peers = response
|
||||
.peers
|
||||
.peers
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|peer| BtPeerInfo {
|
||||
peer_id: peer.peer_id.map(|id| hex_string(&id).to_ascii_lowercase()),
|
||||
ip: peer.ip,
|
||||
port: peer.port,
|
||||
client_name: peer.client_name,
|
||||
interested: peer.interested,
|
||||
choked: peer.choked,
|
||||
download_speed: 0,
|
||||
upload_speed: 0,
|
||||
seeder: false,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(tracker_url, peers)
|
||||
};
|
||||
self.engine
|
||||
.apply_bt_peer_snapshot(gid, peers)
|
||||
.map_err(|error| RpcError::unsupported(&error.to_string()))?;
|
||||
self.engine
|
||||
.apply_bt_tracker_snapshot(
|
||||
gid,
|
||||
&tracker_url,
|
||||
response.peers.tracker_id.clone(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.map_err(|error| RpcError::unsupported(&error.to_string()))?;
|
||||
if let Some(scrape) = &response.scrape {
|
||||
self.apply_tracker_scrape_result(&gid.to_string(), Some(&tracker_url), scrape)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies a tracker scrape response to a tracked download.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `gid` is invalid or the scrape payload cannot be applied.
|
||||
pub fn apply_tracker_scrape_result(
|
||||
&mut self,
|
||||
gid: &str,
|
||||
tracker_url: Option<&str>,
|
||||
scrape: &TrackerScrapeModel,
|
||||
) -> Result<(), RpcError> {
|
||||
let gid = parse_gid_text(gid)?;
|
||||
let (resolved_tracker_url, resolved_complete, resolved_incomplete) = {
|
||||
let group = self
|
||||
.engine
|
||||
.registry()
|
||||
.get(gid)
|
||||
.ok_or_else(|| missing_download_error(gid))?;
|
||||
let bt = group
|
||||
.bt()
|
||||
.ok_or_else(|| RpcError::unsupported("tracker scrape requires bt runtime state"))?;
|
||||
let tracker_url = tracker_url
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| bt.trackers.first().map(|tracker| tracker.url.clone()))
|
||||
.ok_or_else(|| {
|
||||
RpcError::unsupported("tracker scrape apply requires at least one tracker")
|
||||
})?;
|
||||
let info_hash = if !bt.info_hash.is_empty() {
|
||||
bt.info_hash.to_ascii_lowercase()
|
||||
} else {
|
||||
rpc_bt_info_hash(group.uri())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
};
|
||||
let file_match = scrape
|
||||
.files
|
||||
.iter()
|
||||
.find(|file| file.info_hash.eq_ignore_ascii_case(&info_hash));
|
||||
let complete = file_match
|
||||
.and_then(|file| file.complete)
|
||||
.or(scrape.complete);
|
||||
let incomplete = file_match
|
||||
.and_then(|file| file.incomplete)
|
||||
.or(scrape.incomplete);
|
||||
(tracker_url, complete, incomplete)
|
||||
};
|
||||
|
||||
self.engine
|
||||
.apply_bt_tracker_snapshot(
|
||||
gid,
|
||||
&resolved_tracker_url,
|
||||
None,
|
||||
resolved_complete,
|
||||
resolved_incomplete,
|
||||
)
|
||||
.map_err(|error| RpcError::unsupported(&error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Executes a tracker scrape using the provided transport.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `gid` is invalid, the tracker request cannot be built, or transport execution fails.
|
||||
pub fn execute_tracker_scrape<T: TrackerTransport + ?Sized>(
|
||||
&mut self,
|
||||
gid: &str,
|
||||
transport: &T,
|
||||
) -> Result<(), RpcError> {
|
||||
let download_id = parse_gid_text(gid)?;
|
||||
let request = {
|
||||
let group = self
|
||||
.engine
|
||||
.registry()
|
||||
.get(download_id)
|
||||
.ok_or_else(|| missing_download_error(download_id))?;
|
||||
build_tracker_request(group)?
|
||||
};
|
||||
let scrape = transport
|
||||
.scrape(&request.announce_url)
|
||||
.map_err(|error| RpcError::unsupported(&format!("tracker scrape failed: {error}")))?;
|
||||
self.apply_tracker_scrape_result(gid, Some(&request.announce_url), &scrape)
|
||||
}
|
||||
|
||||
/// Executes a tracker announce using the provided transport.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `gid` is invalid, the tracker request cannot be built, or transport execution fails.
|
||||
pub fn execute_tracker_announce<T: TrackerTransport + ?Sized>(
|
||||
&mut self,
|
||||
gid: &str,
|
||||
transport: &T,
|
||||
) -> Result<(), RpcError> {
|
||||
let download_id = parse_gid_text(gid)?;
|
||||
let request = {
|
||||
let group = self
|
||||
.engine
|
||||
.registry()
|
||||
.get(download_id)
|
||||
.ok_or_else(|| missing_download_error(download_id))?;
|
||||
build_tracker_request(group)?
|
||||
};
|
||||
let mut response = transport
|
||||
.announce(&request)
|
||||
.map_err(|error| RpcError::unsupported(&format!("tracker announce failed: {error}")))?;
|
||||
if response.scrape.is_none()
|
||||
&& let Ok(scrape) = transport.scrape(&request.announce_url)
|
||||
{
|
||||
response.scrape = Some(scrape);
|
||||
}
|
||||
self.apply_tracker_announce_result(gid, &response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Shared compatibility helpers that bridge dispatcher state into aria2-style payloads.
|
||||
|
||||
pub(super) use self::{
|
||||
bt_runtime::*, dht::*, peer_wire::*, rpc_surface::*, selection::*, tracker::*,
|
||||
};
|
||||
use super::{
|
||||
AtomicU64, BTreeMap, BTreeSet, BtFileInfo, BtPeerInfo, BtRuntimeCoordinatorAction,
|
||||
BtRuntimeCoordinatorStepReport, BtRuntimeCoordinatorStepStatus, BtRuntimeState, BtTrackerInfo,
|
||||
DhtMessageModel, DhtNodeModel, Digest, DownloadId, MagnetBootstrapModel, MagnetUriModel,
|
||||
Ordering, PeerWireBlockRequestModel, PeerWireExtensionHandshakeModel, PeerWireHandshakeModel,
|
||||
PeerWireMessageKind, PeerWireMetadataMessageModel, PeerWirePieceBlockModel,
|
||||
PeerWireTransportRequest, PeerWireTransportResponse, PieceId, PieceMap, PieceState,
|
||||
RequestGroup, RpcError, Sha1, SystemTime, TorrentMessageModel, TorrentMetadataModel,
|
||||
TrackerRequestModel, TransportEndpoint, TransportScheme, UNIX_EPOCH, parse_torrent_metadata,
|
||||
};
|
||||
|
||||
/// BitTorrent runtime state construction and accounting helpers.
|
||||
mod bt_runtime;
|
||||
/// DHT request construction and compact payload parsing helpers.
|
||||
mod dht;
|
||||
/// Peer-wire request, response, and accounting helpers.
|
||||
mod peer_wire;
|
||||
/// RPC-facing compatibility value formatting helpers.
|
||||
mod rpc_surface;
|
||||
/// BitTorrent file selection option helpers.
|
||||
mod selection;
|
||||
/// Tracker announce request construction helpers.
|
||||
mod tracker;
|
||||
@@ -0,0 +1,362 @@
|
||||
use super::{
|
||||
BTreeMap, BtFileInfo, BtPeerInfo, BtRuntimeState, BtTrackerInfo, DhtNodeModel, Digest,
|
||||
MagnetBootstrapModel, MagnetUriModel, PieceId, PieceMap, PieceState, RequestGroup, RpcError,
|
||||
TorrentMetadataModel, hex_string, merge_bt_dht_nodes, parse_torrent_metadata,
|
||||
};
|
||||
|
||||
/// Extracts an uppercase BitTorrent info hash from a magnet URI.
|
||||
pub(in crate::dispatcher) fn rpc_bt_info_hash(uri: &str) -> Option<String> {
|
||||
let lower = uri.to_ascii_lowercase();
|
||||
let marker = "xt=urn:btih:";
|
||||
let start = lower.find(marker)?;
|
||||
let raw = &uri[start + marker.len()..];
|
||||
let token = raw.split('&').next().unwrap_or(raw);
|
||||
let normalized: String = token
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_alphanumeric())
|
||||
.map(|ch| ch.to_ascii_uppercase())
|
||||
.collect();
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts parsed torrent metadata into the core BitTorrent runtime model.
|
||||
pub(in crate::dispatcher) fn build_bt_runtime_state(
|
||||
metadata: &TorrentMetadataModel,
|
||||
) -> BtRuntimeState {
|
||||
let trackers: Vec<BtTrackerInfo> = metadata
|
||||
.trackers
|
||||
.iter()
|
||||
.map(|tracker| BtTrackerInfo {
|
||||
url: tracker.url.clone(),
|
||||
tier: tracker.tier,
|
||||
id: tracker.id.clone(),
|
||||
seeders: tracker.seeders,
|
||||
leechers: tracker.leechers,
|
||||
})
|
||||
.collect();
|
||||
let files = metadata
|
||||
.info
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| BtFileInfo {
|
||||
path: file.path.clone(),
|
||||
length: file.length,
|
||||
piece_offset: file.piece_offset,
|
||||
selected: file.selected,
|
||||
})
|
||||
.collect();
|
||||
let peers = metadata
|
||||
.peers
|
||||
.iter()
|
||||
.map(|peer| BtPeerInfo {
|
||||
peer_id: peer.peer_id.map(|peer_id| hex_string(&peer_id)),
|
||||
ip: peer.ip.clone(),
|
||||
port: peer.port,
|
||||
client_name: peer.client_name.clone(),
|
||||
interested: peer.interested,
|
||||
choked: peer.choked,
|
||||
download_speed: 0,
|
||||
upload_speed: 0,
|
||||
seeder: false,
|
||||
})
|
||||
.collect();
|
||||
let info_hash = metadata
|
||||
.info
|
||||
.hash
|
||||
.as_ref()
|
||||
.map(|hash| hash.info_hash_hex.to_ascii_uppercase())
|
||||
.unwrap_or_default();
|
||||
let magnet_uri = (!info_hash.is_empty()).then(|| {
|
||||
MagnetUriModel {
|
||||
info_hash: info_hash.clone(),
|
||||
display_name: Some(metadata.info.name.clone()),
|
||||
trackers: trackers.iter().map(|tracker| tracker.url.clone()).collect(),
|
||||
web_seeds: Vec::new(),
|
||||
exact_topic: None,
|
||||
}
|
||||
.to_uri()
|
||||
});
|
||||
|
||||
BtRuntimeState {
|
||||
info_hash,
|
||||
name: Some(metadata.info.name.clone()),
|
||||
magnet_uri,
|
||||
metadata_only: false,
|
||||
metadata_size: None,
|
||||
metadata_extension_ids: BTreeMap::new(),
|
||||
metadata_piece_payloads: BTreeMap::new(),
|
||||
creation_date: metadata.creation_date.clone(),
|
||||
comment: metadata.comment.clone(),
|
||||
dht_nodes: initial_bt_dht_nodes(&metadata.dht_nodes),
|
||||
files,
|
||||
trackers,
|
||||
peers,
|
||||
}
|
||||
}
|
||||
|
||||
/// Seeds BitTorrent runtime state from a magnet URI before metadata arrives.
|
||||
pub(in crate::dispatcher) fn build_bt_runtime_state_from_magnet(
|
||||
uri: &str,
|
||||
magnet: &MagnetBootstrapModel,
|
||||
) -> BtRuntimeState {
|
||||
let magnet_uri = Some(uri.to_owned());
|
||||
let trackers = magnet
|
||||
.trackers
|
||||
.iter()
|
||||
.map(|tracker| BtTrackerInfo {
|
||||
url: tracker.url.clone(),
|
||||
tier: tracker.tier,
|
||||
id: tracker.id.clone(),
|
||||
seeders: tracker.seeders,
|
||||
leechers: tracker.leechers,
|
||||
})
|
||||
.collect();
|
||||
let peer_hints = magnet
|
||||
.peer_hints
|
||||
.iter()
|
||||
.map(|peer| BtPeerInfo {
|
||||
peer_id: peer
|
||||
.peer_id
|
||||
.map(|peer_id: [u8; 20]| hex_string(&peer_id[..])),
|
||||
ip: peer.ip.clone(),
|
||||
port: peer.port,
|
||||
client_name: peer.client_name.clone(),
|
||||
interested: peer.interested,
|
||||
choked: peer.choked,
|
||||
download_speed: 0,
|
||||
upload_speed: 0,
|
||||
seeder: false,
|
||||
})
|
||||
.collect();
|
||||
let hinted_dht_nodes = magnet
|
||||
.peer_hint_nodes
|
||||
.iter()
|
||||
.map(DhtNodeModel::to_spec)
|
||||
.collect::<Vec<_>>();
|
||||
BtRuntimeState {
|
||||
info_hash: magnet.info_hash_hex.to_ascii_uppercase(),
|
||||
name: magnet.uri.display_name.clone(),
|
||||
magnet_uri,
|
||||
metadata_only: true,
|
||||
metadata_size: None,
|
||||
metadata_extension_ids: BTreeMap::new(),
|
||||
metadata_piece_payloads: BTreeMap::new(),
|
||||
creation_date: None,
|
||||
comment: None,
|
||||
dht_nodes: initial_bt_dht_nodes(&hinted_dht_nodes),
|
||||
files: Vec::new(),
|
||||
trackers,
|
||||
peers: peer_hints,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the built-in fallback DHT router list.
|
||||
pub(in crate::dispatcher) fn default_bt_dht_nodes() -> Vec<String> {
|
||||
vec![
|
||||
"router.bittorrent.com:6881".to_owned(),
|
||||
"dht.transmissionbt.com:6881".to_owned(),
|
||||
"router.utorrent.com:6881".to_owned(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Chooses explicit DHT nodes when present and otherwise falls back to router defaults.
|
||||
pub(in crate::dispatcher) fn initial_bt_dht_nodes(explicit_nodes: &[String]) -> Vec<String> {
|
||||
if explicit_nodes.is_empty() {
|
||||
default_bt_dht_nodes()
|
||||
} else {
|
||||
explicit_nodes.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the BEP 9 metadata piece size used by aria2-compatible peers.
|
||||
pub(in crate::dispatcher) const BT_METADATA_PIECE_LENGTH: u64 = 16 * 1024;
|
||||
|
||||
/// Returns the number of metadata pieces needed for one BEP 9 payload size.
|
||||
pub(in crate::dispatcher) fn bt_metadata_piece_count(metadata_size: u32) -> u32 {
|
||||
if metadata_size == 0 {
|
||||
return 0;
|
||||
}
|
||||
metadata_size.div_ceil(BT_METADATA_PIECE_LENGTH as u32)
|
||||
}
|
||||
|
||||
/// Returns the byte length of one metadata piece within the BEP 9 payload.
|
||||
pub(in crate::dispatcher) fn bt_metadata_piece_span(metadata_size: u32, piece_index: u32) -> usize {
|
||||
let piece_start = u64::from(piece_index).saturating_mul(BT_METADATA_PIECE_LENGTH);
|
||||
let metadata_size = u64::from(metadata_size);
|
||||
if piece_start >= metadata_size {
|
||||
return 0;
|
||||
}
|
||||
let remaining = metadata_size.saturating_sub(piece_start);
|
||||
usize::try_from(remaining.min(BT_METADATA_PIECE_LENGTH)).unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
/// Returns a stable runtime key for one BT peer endpoint.
|
||||
pub(in crate::dispatcher) fn bt_peer_metadata_key(peer: &BtPeerInfo) -> String {
|
||||
format!("{}:{}", peer.ip, peer.port)
|
||||
}
|
||||
|
||||
/// Merges BT tracker snapshots by URL while preserving any existing runtime rows.
|
||||
pub(in crate::dispatcher) fn merge_bt_trackers(
|
||||
existing: &mut Vec<BtTrackerInfo>,
|
||||
incoming: Vec<BtTrackerInfo>,
|
||||
) {
|
||||
for tracker in incoming {
|
||||
if existing.iter().any(|current| current.url == tracker.url) {
|
||||
continue;
|
||||
}
|
||||
existing.push(tracker);
|
||||
}
|
||||
}
|
||||
|
||||
/// Merges BT peer snapshots while preserving existing runtime rows.
|
||||
pub(in crate::dispatcher) fn merge_bt_peers(
|
||||
existing: &mut Vec<BtPeerInfo>,
|
||||
incoming: Vec<BtPeerInfo>,
|
||||
) {
|
||||
for peer in incoming {
|
||||
if let Some(current) = existing.iter_mut().find(|candidate| {
|
||||
candidate.peer_id.as_deref() == peer.peer_id.as_deref()
|
||||
|| (candidate.ip == peer.ip && candidate.port == peer.port)
|
||||
}) {
|
||||
*current = peer;
|
||||
} else {
|
||||
existing.push(peer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Promotes a metadata-only magnet runtime into a full torrent-backed BT session when ready.
|
||||
pub(in crate::dispatcher) fn try_promote_bt_metadata(
|
||||
group: &mut RequestGroup,
|
||||
) -> Result<bool, RpcError> {
|
||||
let Some(bt) = group.bt().cloned() else {
|
||||
return Err(RpcError::unsupported(
|
||||
"metadata promotion requires bt runtime state",
|
||||
));
|
||||
};
|
||||
if !bt.metadata_only {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(metadata_size) = bt.metadata_size.filter(|size| *size > 0) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let piece_count = bt_metadata_piece_count(metadata_size);
|
||||
if piece_count == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut metadata_bytes =
|
||||
Vec::with_capacity(usize::try_from(metadata_size).unwrap_or(usize::MAX));
|
||||
for piece in 0..piece_count {
|
||||
let Some(payload) = bt.metadata_piece_payloads.get(&piece) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let expected_len = bt_metadata_piece_span(metadata_size, piece);
|
||||
if payload.len() < expected_len {
|
||||
return Ok(false);
|
||||
}
|
||||
metadata_bytes.extend_from_slice(&payload[..expected_len]);
|
||||
}
|
||||
metadata_bytes.truncate(usize::try_from(metadata_size).unwrap_or(usize::MAX));
|
||||
|
||||
let metadata = parse_torrent_metadata(&metadata_bytes).map_err(|error| {
|
||||
RpcError::unsupported(&format!("invalid magnet metadata payload: {error}"))
|
||||
})?;
|
||||
let parsed_info_hash = metadata
|
||||
.info
|
||||
.hash
|
||||
.as_ref()
|
||||
.map(|hash| hash.info_hash_hex.to_ascii_uppercase())
|
||||
.unwrap_or_default();
|
||||
if parsed_info_hash.is_empty() {
|
||||
return Err(RpcError::unsupported(
|
||||
"promoted torrent metadata did not expose an info hash",
|
||||
));
|
||||
}
|
||||
if !bt.info_hash.is_empty() && !parsed_info_hash.eq_ignore_ascii_case(&bt.info_hash) {
|
||||
return Err(RpcError::unsupported(&format!(
|
||||
"magnet metadata info hash mismatch: expected {}, got {parsed_info_hash}",
|
||||
bt.info_hash
|
||||
)));
|
||||
}
|
||||
|
||||
let mut promoted = build_bt_runtime_state(&metadata);
|
||||
promoted.magnet_uri = bt.magnet_uri.clone();
|
||||
promoted.metadata_size = Some(metadata_size);
|
||||
promoted.metadata_extension_ids = bt.metadata_extension_ids.clone();
|
||||
promoted.metadata_piece_payloads = bt.metadata_piece_payloads.clone();
|
||||
promoted.dht_nodes = bt.dht_nodes.clone();
|
||||
merge_bt_dht_nodes(&mut promoted.dht_nodes, metadata.dht_nodes.iter().cloned());
|
||||
merge_bt_trackers(&mut promoted.trackers, bt.trackers.clone());
|
||||
let metadata_peers = std::mem::take(&mut promoted.peers);
|
||||
promoted.peers = bt.peers.clone();
|
||||
merge_bt_peers(&mut promoted.peers, metadata_peers);
|
||||
|
||||
group.set_bt(promoted);
|
||||
group.set_total_length(metadata.total_length());
|
||||
group.set_piece_length(metadata.info.piece_length.max(1));
|
||||
group.set_completed_length(0);
|
||||
group.clear_piece_availability();
|
||||
group.clear_segment_assignments();
|
||||
*group.piece_map_mut() = PieceMap::new();
|
||||
for piece in &metadata.pieces {
|
||||
group.set_piece_state(PieceId(piece.index), PieceState::Pending);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Computes the effective total length visible to BitTorrent runtime reporting.
|
||||
pub(in crate::dispatcher) fn bt_runtime_total_length(group: &RequestGroup) -> u64 {
|
||||
group.total_length().max(
|
||||
group
|
||||
.bt()
|
||||
.map(BtRuntimeState::selected_or_all_total_length)
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Computes the number of pieces required to cover a torrent payload.
|
||||
pub(in crate::dispatcher) fn bt_piece_count(total_length: u64, piece_length: u64) -> usize {
|
||||
if total_length == 0 {
|
||||
0
|
||||
} else {
|
||||
total_length.div_ceil(piece_length.max(1)) as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the byte span represented by a single BitTorrent piece.
|
||||
pub(in crate::dispatcher) fn bt_piece_span_bytes(
|
||||
piece: PieceId,
|
||||
piece_length: u64,
|
||||
total_length: u64,
|
||||
) -> u64 {
|
||||
let piece_length = piece_length.max(1);
|
||||
if total_length == 0 {
|
||||
return piece_length;
|
||||
}
|
||||
let start = u64::from(piece.0).saturating_mul(piece_length);
|
||||
total_length.saturating_sub(start).min(piece_length)
|
||||
}
|
||||
|
||||
/// Sums the verified length implied by the request group's piece map.
|
||||
pub(in crate::dispatcher) fn bt_verified_length(
|
||||
group: &RequestGroup,
|
||||
piece_length: u64,
|
||||
total_length: u64,
|
||||
) -> u64 {
|
||||
group
|
||||
.piece_map()
|
||||
.iter()
|
||||
.filter(|(_, state)| **state == PieceState::Verified)
|
||||
.map(|(piece, _)| bt_piece_span_bytes(*piece, piece_length, total_length))
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Returns whether a BT peer row is usable for an outbound peer-wire exchange.
|
||||
pub(in crate::dispatcher) fn bt_peer_is_connectable(peer: &BtPeerInfo) -> bool {
|
||||
!peer.ip.trim().is_empty() && peer.port != 0
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
use super::{
|
||||
BtPeerInfo, BtRuntimeState, DhtMessageModel, DhtNodeModel, Digest, DownloadId, RequestGroup,
|
||||
RpcError, rpc_bt_info_hash,
|
||||
};
|
||||
|
||||
/// Builds the outbound DHT `get_peers` message and selected target node.
|
||||
pub(in crate::dispatcher) fn build_dht_get_peers_request(
|
||||
group: &RequestGroup,
|
||||
) -> Result<(DhtNodeModel, DhtMessageModel), RpcError> {
|
||||
let bt = group
|
||||
.bt()
|
||||
.ok_or_else(|| RpcError::unsupported("dht get_peers requires bt runtime state"))?;
|
||||
let node = pick_bt_dht_node(bt.dht_nodes())?;
|
||||
let info_hash = resolve_bt_info_hash(group, bt)?;
|
||||
Ok((
|
||||
node,
|
||||
DhtMessageModel::get_peers_query(
|
||||
b"gp".to_vec(),
|
||||
rpc_bt_local_node_id(group.gid()),
|
||||
info_hash,
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// Builds the outbound DHT `ping` message and selected target node.
|
||||
pub(in crate::dispatcher) fn build_dht_ping_request(
|
||||
group: &RequestGroup,
|
||||
) -> Result<(DhtNodeModel, DhtMessageModel), RpcError> {
|
||||
let bt = group
|
||||
.bt()
|
||||
.ok_or_else(|| RpcError::unsupported("dht ping requires bt runtime state"))?;
|
||||
let node = pick_bt_dht_node(bt.dht_nodes())?;
|
||||
Ok((
|
||||
node,
|
||||
DhtMessageModel::ping_query(b"pi".to_vec(), rpc_bt_local_node_id(group.gid())),
|
||||
))
|
||||
}
|
||||
|
||||
/// Builds the outbound DHT `find_node` message and selected target node.
|
||||
pub(in crate::dispatcher) fn build_dht_find_node_request(
|
||||
group: &RequestGroup,
|
||||
) -> Result<(DhtNodeModel, DhtMessageModel), RpcError> {
|
||||
let bt = group
|
||||
.bt()
|
||||
.ok_or_else(|| RpcError::unsupported("dht find_node requires bt runtime state"))?;
|
||||
let node = pick_bt_dht_node(bt.dht_nodes())?;
|
||||
let target = resolve_bt_info_hash(group, bt)?;
|
||||
Ok((
|
||||
node,
|
||||
DhtMessageModel::find_node_query(b"fn".to_vec(), rpc_bt_local_node_id(group.gid()), target),
|
||||
))
|
||||
}
|
||||
|
||||
/// Builds the outbound DHT `announce_peer` message and selected target node.
|
||||
pub(in crate::dispatcher) fn build_dht_announce_peer_request(
|
||||
group: &RequestGroup,
|
||||
) -> Result<(DhtNodeModel, DhtMessageModel), RpcError> {
|
||||
let bt = group
|
||||
.bt()
|
||||
.ok_or_else(|| RpcError::unsupported("dht announce_peer requires bt runtime state"))?;
|
||||
let node = pick_bt_dht_node(bt.dht_nodes())?;
|
||||
let token = group
|
||||
.dht_token()
|
||||
.map(|token| token.to_vec())
|
||||
.ok_or_else(|| {
|
||||
RpcError::unsupported("dht announce_peer requires token from prior get_peers")
|
||||
})?;
|
||||
let info_hash = resolve_bt_info_hash(group, bt)?;
|
||||
Ok((
|
||||
node,
|
||||
DhtMessageModel::announce_peer_query(
|
||||
b"ap".to_vec(),
|
||||
rpc_bt_local_node_id(group.gid()),
|
||||
info_hash,
|
||||
6881,
|
||||
token,
|
||||
false,
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// Chooses the first parseable DHT node entry from runtime state.
|
||||
pub(in crate::dispatcher) fn pick_bt_dht_node(nodes: &[String]) -> Result<DhtNodeModel, RpcError> {
|
||||
if nodes.is_empty() {
|
||||
return Err(RpcError::unsupported(
|
||||
"dht get_peers requires at least one dht node",
|
||||
));
|
||||
}
|
||||
let mut last_error = None;
|
||||
for node in nodes {
|
||||
match parse_dht_node_spec(node) {
|
||||
Ok(parsed) => return Ok(parsed),
|
||||
Err(error) => last_error = Some(error.message),
|
||||
}
|
||||
}
|
||||
Err(RpcError::unsupported(&format!(
|
||||
"dht get_peers found no valid dht nodes in runtime state{}",
|
||||
last_error
|
||||
.map(|message| format!(": {message}"))
|
||||
.unwrap_or_default()
|
||||
)))
|
||||
}
|
||||
|
||||
/// Resolves the BitTorrent info hash bytes required by DHT and peer-wire requests.
|
||||
pub(in crate::dispatcher) fn resolve_bt_info_hash(
|
||||
group: &RequestGroup,
|
||||
bt: &BtRuntimeState,
|
||||
) -> Result<Vec<u8>, RpcError> {
|
||||
let info_hash = if !bt.info_hash.is_empty() {
|
||||
bt.info_hash.clone()
|
||||
} else {
|
||||
rpc_bt_info_hash(group.uri()).unwrap_or_default()
|
||||
};
|
||||
decode_hex_string_exact(&info_hash, 20, "dht get_peers info hash")
|
||||
.map_err(|error| RpcError::unsupported(&error))
|
||||
}
|
||||
|
||||
/// Parses a `host:port` DHT node spec into a transport model.
|
||||
pub(in crate::dispatcher) fn parse_dht_node_spec(raw: &str) -> Result<DhtNodeModel, RpcError> {
|
||||
let (address, port_raw) = raw
|
||||
.rsplit_once(':')
|
||||
.ok_or_else(|| RpcError::unsupported("dht node entry must use host:port format"))?;
|
||||
let port = port_raw
|
||||
.parse::<u16>()
|
||||
.map_err(|_| RpcError::unsupported("dht node port must be a valid u16"))?;
|
||||
Ok(DhtNodeModel {
|
||||
node_id: String::new(),
|
||||
address: address.to_owned(),
|
||||
port,
|
||||
})
|
||||
}
|
||||
|
||||
/// Derives a deterministic local DHT node ID from a download GID.
|
||||
pub(in crate::dispatcher) fn rpc_bt_local_node_id(gid: DownloadId) -> Vec<u8> {
|
||||
let gid_hex = format!("{:040x}", gid.as_u64());
|
||||
decode_hex_string_exact(&gid_hex, 20, "local dht node id").unwrap_or_else(|_| vec![0_u8; 20])
|
||||
}
|
||||
|
||||
/// Decodes a fixed-width hexadecimal string into raw bytes.
|
||||
pub(in crate::dispatcher) fn decode_hex_string_exact(
|
||||
raw: &str,
|
||||
expected_len: usize,
|
||||
label: &str,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
if raw.len() != expected_len * 2 {
|
||||
return Err(format!(
|
||||
"{label} must be {} hex characters",
|
||||
expected_len * 2
|
||||
));
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(expected_len);
|
||||
for pair in raw.as_bytes().chunks_exact(2) {
|
||||
let hi = decode_hex_nibble(pair[0])
|
||||
.ok_or_else(|| format!("{label} contains non-hex characters"))?;
|
||||
let lo = decode_hex_nibble(pair[1])
|
||||
.ok_or_else(|| format!("{label} contains non-hex characters"))?;
|
||||
bytes.push((hi << 4) | lo);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Decodes a single ASCII hex nibble.
|
||||
pub(in crate::dispatcher) fn decode_hex_nibble(byte: u8) -> Option<u8> {
|
||||
match byte {
|
||||
b'0'..=b'9' => Some(byte - b'0'),
|
||||
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||
b'A'..=b'F' => Some(byte - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses compact peer payloads returned by DHT `get_peers`.
|
||||
pub(in crate::dispatcher) fn parse_dht_compact_peers(
|
||||
values: &[Vec<u8>],
|
||||
) -> Result<Vec<BtPeerInfo>, String> {
|
||||
let mut peers = Vec::new();
|
||||
for value in values {
|
||||
if value.len() % 6 != 0 {
|
||||
return Err("compact peer list length must be a multiple of 6".to_owned());
|
||||
}
|
||||
for chunk in value.chunks_exact(6) {
|
||||
peers.push(BtPeerInfo {
|
||||
peer_id: None,
|
||||
ip: format!("{}.{}.{}.{}", chunk[0], chunk[1], chunk[2], chunk[3]),
|
||||
port: u16::from_be_bytes([chunk[4], chunk[5]]),
|
||||
client_name: None,
|
||||
interested: false,
|
||||
choked: false,
|
||||
download_speed: 0,
|
||||
upload_speed: 0,
|
||||
seeder: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(peers)
|
||||
}
|
||||
|
||||
/// Parses compact DHT node payload bytes into `host:port` strings.
|
||||
pub(in crate::dispatcher) fn parse_dht_compact_nodes(
|
||||
raw: Option<&[u8]>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let Some(raw) = raw else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
if raw.len() % 26 != 0 {
|
||||
return Err("compact dht node list length must be a multiple of 26".to_owned());
|
||||
}
|
||||
let mut nodes = Vec::new();
|
||||
for chunk in raw.chunks_exact(26) {
|
||||
let ip = format!("{}.{}.{}.{}", chunk[20], chunk[21], chunk[22], chunk[23]);
|
||||
let port = u16::from_be_bytes([chunk[24], chunk[25]]);
|
||||
nodes.push(format!("{ip}:{port}"));
|
||||
}
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
/// Appends newly discovered DHT nodes while preserving existing order.
|
||||
pub(in crate::dispatcher) fn merge_bt_dht_nodes<I>(existing: &mut Vec<String>, discovered: I)
|
||||
where
|
||||
I: IntoIterator<Item = String>,
|
||||
{
|
||||
for node in discovered {
|
||||
if !existing.iter().any(|current| current == &node) {
|
||||
existing.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves a successfully used DHT node to the front of the runtime node list.
|
||||
pub(in crate::dispatcher) fn promote_bt_dht_node(existing: &mut Vec<String>, node: &DhtNodeModel) {
|
||||
let entry = format!("{}:{}", node.address, node.port);
|
||||
if let Some(index) = existing.iter().position(|current| current == &entry) {
|
||||
if index > 0 {
|
||||
let value = existing.remove(index);
|
||||
existing.insert(0, value);
|
||||
}
|
||||
} else {
|
||||
existing.insert(0, entry);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
use super::{
|
||||
BTreeMap, BTreeSet, BtPeerInfo, Digest, PeerWireBlockRequestModel,
|
||||
PeerWireExtensionHandshakeModel, PeerWireHandshakeModel, PeerWireMessageKind,
|
||||
PeerWireMetadataMessageModel, PeerWirePieceBlockModel, PeerWireTransportRequest,
|
||||
PeerWireTransportResponse, PieceState, RequestGroup, RpcError, TorrentMessageModel,
|
||||
TransportEndpoint, TransportScheme, bt_metadata_piece_count, bt_peer_metadata_key,
|
||||
bt_piece_span_bytes, bt_runtime_total_length, hex_string, resolve_bt_info_hash,
|
||||
rpc_bt_local_node_id,
|
||||
};
|
||||
|
||||
/// Identifies the peer selected for a peer-wire compatibility exchange.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(in crate::dispatcher) struct PeerWirePeerTarget {
|
||||
/// Original peer index inside the runtime peer list.
|
||||
pub(in crate::dispatcher) index: usize,
|
||||
/// Peer runtime snapshot used to build the outbound request.
|
||||
pub(in crate::dispatcher) peer: BtPeerInfo,
|
||||
}
|
||||
|
||||
/// Holds the outbound peer-wire request and bookkeeping for a compatibility probe.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(in crate::dispatcher) struct PeerWireExchangePlan {
|
||||
/// Peer list index that should receive the parsed response data.
|
||||
pub(in crate::dispatcher) peer_index: usize,
|
||||
/// Expected info hash validated against the peer handshake.
|
||||
pub(in crate::dispatcher) info_hash: [u8; 20],
|
||||
/// Transport payload sent to the peer.
|
||||
pub(in crate::dispatcher) request: PeerWireTransportRequest,
|
||||
/// Optional block request emitted after the handshake.
|
||||
pub(in crate::dispatcher) block_request: Option<PeerWireBlockRequestModel>,
|
||||
/// Optional known `ut_metadata` extension id for the selected peer.
|
||||
pub(in crate::dispatcher) metadata_extension_id: Option<u8>,
|
||||
}
|
||||
|
||||
/// Captures peer-wire handshake and frame state recovered from a peer response.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(in crate::dispatcher) struct PeerWireExchangeResponseModel {
|
||||
/// Remote peer ID emitted by the handshake when present.
|
||||
pub(in crate::dispatcher) peer_id: Option<String>,
|
||||
/// Parsed remote extended handshake, when observed.
|
||||
pub(in crate::dispatcher) extension_handshake: Option<PeerWireExtensionHandshakeModel>,
|
||||
/// Latest observed choke state.
|
||||
pub(in crate::dispatcher) peer_choked: Option<bool>,
|
||||
/// Latest observed interest state.
|
||||
pub(in crate::dispatcher) peer_interested: Option<bool>,
|
||||
/// Piece set explicitly advertised by a bitfield frame.
|
||||
pub(in crate::dispatcher) bitfield_pieces: Option<BTreeSet<u32>>,
|
||||
/// Aggregate set of pieces implied by bitfield, have, and piece frames.
|
||||
pub(in crate::dispatcher) available_pieces: BTreeSet<u32>,
|
||||
/// Piece payload frames recovered from the response.
|
||||
pub(in crate::dispatcher) pieces: Vec<PeerWirePieceBlockModel>,
|
||||
/// Metadata payloads recovered from BEP 9 messages.
|
||||
pub(in crate::dispatcher) metadata_messages: Vec<PeerWireMetadataMessageModel>,
|
||||
}
|
||||
|
||||
/// Builds the outbound peer-wire request for the current BitTorrent runtime state.
|
||||
pub(in crate::dispatcher) fn build_peer_wire_exchange_plan(
|
||||
group: &RequestGroup,
|
||||
) -> Result<PeerWireExchangePlan, RpcError> {
|
||||
let bt = group
|
||||
.bt()
|
||||
.ok_or_else(|| RpcError::unsupported("peer-wire exchange requires bt runtime state"))?;
|
||||
let target = pick_bt_peer_target(&bt.peers)?;
|
||||
let info_hash_vec = resolve_bt_info_hash(group, bt)?;
|
||||
let info_hash: [u8; 20] = info_hash_vec
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| RpcError::unsupported("peer-wire exchange info hash must be 20 bytes"))?;
|
||||
let peer_id_vec = rpc_bt_local_node_id(group.gid());
|
||||
let peer_id: [u8; 20] = peer_id_vec
|
||||
.clone()
|
||||
.try_into()
|
||||
.map_err(|_| RpcError::unsupported("peer-wire exchange peer id must be 20 bytes"))?;
|
||||
|
||||
let peer_key = bt_peer_metadata_key(&target.peer);
|
||||
let metadata_extension_id = bt.metadata_extension_ids.get(&peer_key).copied();
|
||||
let metadata_request_piece = if bt.metadata_only {
|
||||
match (metadata_extension_id, bt.metadata_size) {
|
||||
(Some(_), Some(metadata_size)) if metadata_size > 0 => {
|
||||
let piece_count = bt_metadata_piece_count(metadata_size);
|
||||
(0..piece_count).find(|piece| !bt.metadata_piece_payloads.contains_key(piece))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut handshake = PeerWireHandshakeModel::new(info_hash, peer_id);
|
||||
handshake.reserved[5] |= 0x10;
|
||||
let mut payload = handshake.serialize();
|
||||
|
||||
let mut block_request = None;
|
||||
if bt.metadata_only {
|
||||
let extension_handshake = PeerWireExtensionHandshakeModel {
|
||||
extensions: BTreeMap::from([("ut_metadata".to_owned(), 1_u8)]),
|
||||
client_name: Some("aria2-rust-pro".to_owned()),
|
||||
metadata_size: None,
|
||||
request_queue: Some(16),
|
||||
};
|
||||
payload.extend_from_slice(
|
||||
&TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Extension(
|
||||
extension_handshake.to_peer_wire_message(),
|
||||
))
|
||||
.serialize_peer_wire_frame()
|
||||
.map_err(|error| {
|
||||
RpcError::unsupported(&format!(
|
||||
"peer-wire extension handshake serialization failed: {error}"
|
||||
))
|
||||
})?,
|
||||
);
|
||||
if let (Some(extension_message_id), Some(piece)) =
|
||||
(metadata_extension_id, metadata_request_piece)
|
||||
{
|
||||
payload.extend_from_slice(
|
||||
&TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Extension(
|
||||
PeerWireMetadataMessageModel::request(piece)
|
||||
.to_peer_wire_message(extension_message_id),
|
||||
))
|
||||
.serialize_peer_wire_frame()
|
||||
.map_err(|error| {
|
||||
RpcError::unsupported(&format!(
|
||||
"peer-wire metadata request serialization failed: {error}"
|
||||
))
|
||||
})?,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let piece_length = group.piece_length().max(1);
|
||||
let total_length = bt_runtime_total_length(group);
|
||||
let requestable = group.bt_requestable_piece_ids(false, 8);
|
||||
let availability = group.piece_availability();
|
||||
let selected_piece = requestable
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|piece| availability.get(piece).copied().unwrap_or(0) > 0)
|
||||
.or_else(|| requestable.first().copied());
|
||||
block_request = selected_piece.map(|piece| PeerWireBlockRequestModel {
|
||||
piece_index: piece.0,
|
||||
block_offset: 0,
|
||||
block_length: bt_piece_span_bytes(piece, piece_length, total_length)
|
||||
.min(16_u64 * 1024)
|
||||
.max(1) as u32,
|
||||
});
|
||||
if block_request.is_some() {
|
||||
payload.extend_from_slice(
|
||||
&TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Interested)
|
||||
.serialize_peer_wire_frame()
|
||||
.map_err(|error| {
|
||||
RpcError::unsupported(&format!(
|
||||
"peer-wire interested frame serialization failed: {error}"
|
||||
))
|
||||
})?,
|
||||
);
|
||||
}
|
||||
if let Some(block_request) = &block_request {
|
||||
payload.extend_from_slice(
|
||||
&TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Request(
|
||||
block_request.clone(),
|
||||
))
|
||||
.serialize_peer_wire_frame()
|
||||
.map_err(|error| {
|
||||
RpcError::unsupported(&format!(
|
||||
"peer-wire request frame serialization failed: {error}"
|
||||
))
|
||||
})?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PeerWireExchangePlan {
|
||||
peer_index: target.index,
|
||||
info_hash,
|
||||
request: PeerWireTransportRequest {
|
||||
endpoint: TransportEndpoint {
|
||||
scheme: TransportScheme::BitTorrent,
|
||||
address: format!("{}:{}", target.peer.ip, target.peer.port),
|
||||
},
|
||||
info_hash: info_hash_vec,
|
||||
peer_id: peer_id_vec,
|
||||
payload,
|
||||
},
|
||||
block_request,
|
||||
metadata_extension_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Chooses the best available peer target for a peer-wire exchange.
|
||||
pub(super) fn pick_bt_peer_target(peers: &[BtPeerInfo]) -> Result<PeerWirePeerTarget, RpcError> {
|
||||
if peers.is_empty() {
|
||||
return Err(RpcError::unsupported(
|
||||
"peer-wire exchange requires at least one bt peer",
|
||||
));
|
||||
}
|
||||
let mut last_error = None;
|
||||
let mut fallback = None;
|
||||
for (index, peer) in peers.iter().enumerate() {
|
||||
if peer.ip.trim().is_empty() {
|
||||
last_error = Some("peer ip must not be empty".to_owned());
|
||||
continue;
|
||||
}
|
||||
if peer.port == 0 {
|
||||
last_error = Some("peer port must be non-zero".to_owned());
|
||||
continue;
|
||||
}
|
||||
let target = PeerWirePeerTarget {
|
||||
index,
|
||||
peer: peer.clone(),
|
||||
};
|
||||
if !peer.choked {
|
||||
return Ok(target);
|
||||
}
|
||||
if fallback.is_none() {
|
||||
fallback = Some(target);
|
||||
}
|
||||
}
|
||||
if let Some(target) = fallback {
|
||||
return Ok(target);
|
||||
}
|
||||
Err(RpcError::unsupported(&format!(
|
||||
"peer-wire exchange found no valid bt peers in runtime state{}",
|
||||
last_error
|
||||
.map(|message| format!(": {message}"))
|
||||
.unwrap_or_default()
|
||||
)))
|
||||
}
|
||||
|
||||
/// Parses a peer-wire transport payload into normalized runtime update data.
|
||||
pub(in crate::dispatcher) fn parse_peer_wire_exchange_response(
|
||||
response: &PeerWireTransportResponse,
|
||||
expected_info_hash: &[u8; 20],
|
||||
known_metadata_extension_id: Option<u8>,
|
||||
) -> Result<PeerWireExchangeResponseModel, RpcError> {
|
||||
let mut parsed = PeerWireExchangeResponseModel::default();
|
||||
let mut cursor = 0;
|
||||
let mut negotiated_metadata_extension_id = known_metadata_extension_id;
|
||||
if response.payload.first().copied() == Some(19) {
|
||||
let (handshake, consumed) = PeerWireHandshakeModel::parse_prefix(&response.payload)
|
||||
.map_err(|error| {
|
||||
RpcError::unsupported(&format!("invalid peer-wire handshake: {error}"))
|
||||
})?;
|
||||
if handshake.info_hash != *expected_info_hash {
|
||||
return Err(RpcError::unsupported(
|
||||
"peer-wire handshake info hash did not match download runtime state",
|
||||
));
|
||||
}
|
||||
parsed.peer_id = Some(hex_string(&handshake.peer_id).to_ascii_lowercase());
|
||||
cursor = consumed;
|
||||
}
|
||||
|
||||
while cursor < response.payload.len() {
|
||||
let (frame, consumed) = TorrentMessageModel::parse_peer_wire_frame(
|
||||
&response.payload[cursor..],
|
||||
)
|
||||
.map_err(|error| RpcError::unsupported(&format!("invalid peer-wire frame: {error}")))?;
|
||||
cursor = cursor.saturating_add(consumed);
|
||||
match frame.peer_wire_kind().map_err(|error| {
|
||||
RpcError::unsupported(&format!("invalid peer-wire message: {error}"))
|
||||
})? {
|
||||
PeerWireMessageKind::Choke => parsed.peer_choked = Some(true),
|
||||
PeerWireMessageKind::Unchoke => parsed.peer_choked = Some(false),
|
||||
PeerWireMessageKind::Interested => parsed.peer_interested = Some(true),
|
||||
PeerWireMessageKind::NotInterested => parsed.peer_interested = Some(false),
|
||||
PeerWireMessageKind::Have(piece) => {
|
||||
parsed.available_pieces.insert(piece);
|
||||
}
|
||||
PeerWireMessageKind::Bitfield(bitfield) => {
|
||||
let available = bitfield
|
||||
.to_piece_flags(bitfield.piece_capacity())
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(piece, has_piece)| has_piece.then_some(piece as u32))
|
||||
.collect::<BTreeSet<_>>();
|
||||
parsed.available_pieces.extend(available.iter().copied());
|
||||
parsed.bitfield_pieces = Some(available);
|
||||
}
|
||||
PeerWireMessageKind::Piece(piece) => {
|
||||
parsed.available_pieces.insert(piece.piece_index);
|
||||
parsed.pieces.push(piece);
|
||||
}
|
||||
PeerWireMessageKind::Extension(message) => {
|
||||
if message.extension_message_id == 0 {
|
||||
let handshake = PeerWireExtensionHandshakeModel::from_peer_wire_message(
|
||||
&message,
|
||||
)
|
||||
.map_err(|error| {
|
||||
RpcError::unsupported(&format!(
|
||||
"invalid peer-wire extended handshake: {error}"
|
||||
))
|
||||
})?;
|
||||
negotiated_metadata_extension_id = handshake
|
||||
.ut_metadata_id()
|
||||
.or(negotiated_metadata_extension_id);
|
||||
parsed.extension_handshake = Some(handshake);
|
||||
} else if negotiated_metadata_extension_id
|
||||
.is_some_and(|extension_id| extension_id == message.extension_message_id)
|
||||
{
|
||||
let metadata_message = PeerWireMetadataMessageModel::from_peer_wire_message(
|
||||
&message,
|
||||
negotiated_metadata_extension_id.expect("checked is_some above"),
|
||||
)
|
||||
.map_err(|error| {
|
||||
RpcError::unsupported(&format!(
|
||||
"invalid peer-wire ut_metadata payload: {error}"
|
||||
))
|
||||
})?;
|
||||
parsed.metadata_messages.push(metadata_message);
|
||||
}
|
||||
}
|
||||
PeerWireMessageKind::KeepAlive
|
||||
| PeerWireMessageKind::Request(_)
|
||||
| PeerWireMessageKind::Cancel(_)
|
||||
| PeerWireMessageKind::Port(_)
|
||||
| PeerWireMessageKind::Unknown(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// Computes how many verified bytes overlap a requested byte range.
|
||||
pub(in crate::dispatcher) fn verified_length_for_range(
|
||||
group: &RequestGroup,
|
||||
range_start: u64,
|
||||
range_length: u64,
|
||||
piece_length: u64,
|
||||
total_length: u64,
|
||||
) -> u64 {
|
||||
if range_length == 0 || piece_length == 0 || total_length == 0 {
|
||||
return 0;
|
||||
}
|
||||
let range_end = range_start.saturating_add(range_length).min(total_length);
|
||||
if range_end <= range_start {
|
||||
return 0;
|
||||
}
|
||||
group
|
||||
.piece_map()
|
||||
.iter()
|
||||
.filter(|(_, state)| **state == PieceState::Verified)
|
||||
.map(|(piece, _)| {
|
||||
let piece_start = u64::from(piece.0).saturating_mul(piece_length);
|
||||
let piece_end = piece_start
|
||||
.saturating_add(bt_piece_span_bytes(*piece, piece_length, total_length))
|
||||
.min(total_length);
|
||||
let overlap_start = piece_start.max(range_start);
|
||||
let overlap_end = piece_end.min(range_end);
|
||||
overlap_end.saturating_sub(overlap_start)
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Returns whether a peer-wire bitfield covers every expected piece index.
|
||||
pub(in crate::dispatcher) fn peer_wire_bitfield_is_complete(
|
||||
pieces: &BTreeSet<u32>,
|
||||
expected_piece_count: usize,
|
||||
) -> bool {
|
||||
expected_piece_count > 0
|
||||
&& pieces.len() >= expected_piece_count
|
||||
&& (0..expected_piece_count as u32).all(|piece| pieces.contains(&piece))
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use super::{
|
||||
AtomicU64, BtRuntimeCoordinatorAction, BtRuntimeCoordinatorStepReport,
|
||||
BtRuntimeCoordinatorStepStatus, Digest, Ordering, RpcError, Sha1, SystemTime, UNIX_EPOCH,
|
||||
};
|
||||
|
||||
/// Converts a successful or failed coordinator action result into a stable report row.
|
||||
pub(in crate::dispatcher) fn push_bt_runtime_coordinator_result(
|
||||
steps: &mut Vec<BtRuntimeCoordinatorStepReport>,
|
||||
action: BtRuntimeCoordinatorAction,
|
||||
result: Result<(), RpcError>,
|
||||
) {
|
||||
match result {
|
||||
Ok(()) => steps.push(BtRuntimeCoordinatorStepReport {
|
||||
action,
|
||||
status: BtRuntimeCoordinatorStepStatus::Executed,
|
||||
detail: None,
|
||||
}),
|
||||
Err(error) => steps.push(BtRuntimeCoordinatorStepReport {
|
||||
action,
|
||||
status: BtRuntimeCoordinatorStepStatus::Failed,
|
||||
detail: Some(error.message),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a skipped coordinator action report row with a concise reason.
|
||||
pub(in crate::dispatcher) fn skipped_bt_runtime_coordinator_step(
|
||||
action: BtRuntimeCoordinatorAction,
|
||||
detail: &str,
|
||||
) -> BtRuntimeCoordinatorStepReport {
|
||||
BtRuntimeCoordinatorStepReport {
|
||||
action,
|
||||
status: BtRuntimeCoordinatorStepStatus::Skipped,
|
||||
detail: Some(detail.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Monotonic nonce mixed into generated session IDs.
|
||||
static NEXT_SESSION_ID_NONCE: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// Generates a stable hex session identifier for RPC clients.
|
||||
pub(in crate::dispatcher) fn generate_session_id() -> String {
|
||||
let now_nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system time should be after unix epoch")
|
||||
.as_nanos();
|
||||
let nonce = NEXT_SESSION_ID_NONCE.fetch_add(1, Ordering::Relaxed);
|
||||
let mut sha1 = Sha1::new();
|
||||
sha1.update(now_nanos.to_le_bytes());
|
||||
sha1.update(std::process::id().to_le_bytes());
|
||||
sha1.update(nonce.to_le_bytes());
|
||||
let digest = sha1.finalize();
|
||||
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Returns the upstream-style enabled feature list reported by `getVersion`.
|
||||
pub(in crate::dispatcher) fn rpc_enabled_features() -> &'static [&'static str] {
|
||||
&[
|
||||
"Async DNS",
|
||||
"BitTorrent",
|
||||
"GZip",
|
||||
"HTTPS",
|
||||
"Message Digest",
|
||||
"Metalink",
|
||||
"XML-RPC",
|
||||
"SFTP",
|
||||
]
|
||||
}
|
||||
|
||||
/// Encodes bytes as an uppercase hexadecimal string.
|
||||
pub(in crate::dispatcher) fn hex_string(bytes: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
out.push_str(&format!("{byte:02X}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Formats the share ratio text expected by aria2 RPC payloads.
|
||||
pub(in crate::dispatcher) fn rpc_share_ratio_text(share_ratio_milli: Option<u64>) -> String {
|
||||
share_ratio_milli
|
||||
.map(|milli| format!("{:.3}", milli as f64 / 1000.0))
|
||||
.unwrap_or_else(|| "0.000".to_owned())
|
||||
}
|
||||
|
||||
/// Formats the share time text expected by aria2 RPC payloads.
|
||||
pub(in crate::dispatcher) fn rpc_share_time_text(
|
||||
snapshot: &aria2_rust_pro_core::ProgressSnapshot,
|
||||
) -> String {
|
||||
snapshot.share_time_secs.unwrap_or(0).to_string()
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use super::{BTreeSet, Digest, RequestGroup};
|
||||
|
||||
/// Applies a `select-file` option value to BitTorrent file selection state.
|
||||
pub(in crate::dispatcher) fn apply_bt_select_file_option(
|
||||
group: &mut RequestGroup,
|
||||
select_file: &str,
|
||||
) -> Result<(), String> {
|
||||
let Some(mut bt_state) = group.bt().cloned() else {
|
||||
return Ok(());
|
||||
};
|
||||
if bt_state.files.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let selected_indexes = parse_bt_select_file_indexes(select_file, bt_state.files.len())?;
|
||||
for (index, file) in bt_state.files.iter_mut().enumerate() {
|
||||
file.selected = selected_indexes.contains(&(index + 1));
|
||||
}
|
||||
group.set_bt(bt_state);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parses aria2-style `select-file` syntax into a set of selected file indexes.
|
||||
pub(in crate::dispatcher) fn parse_bt_select_file_indexes(
|
||||
select_file: &str,
|
||||
file_count: usize,
|
||||
) -> Result<BTreeSet<usize>, String> {
|
||||
let mut selected = BTreeSet::new();
|
||||
let trimmed = select_file.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("empty value".to_owned());
|
||||
}
|
||||
for token in trimmed
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|token| !token.is_empty())
|
||||
{
|
||||
if let Some((start_raw, end_raw)) = token.split_once('-') {
|
||||
let start = start_raw
|
||||
.trim()
|
||||
.parse::<usize>()
|
||||
.map_err(|_| format!("invalid start index `{start_raw}`"))?;
|
||||
let end = end_raw
|
||||
.trim()
|
||||
.parse::<usize>()
|
||||
.map_err(|_| format!("invalid end index `{end_raw}`"))?;
|
||||
if start == 0 || end == 0 {
|
||||
return Err("indexes are 1-based".to_owned());
|
||||
}
|
||||
let (lo, hi) = if start <= end {
|
||||
(start, end)
|
||||
} else {
|
||||
(end, start)
|
||||
};
|
||||
if hi > file_count {
|
||||
return Err(format!("index {hi} out of range 1..={file_count}"));
|
||||
}
|
||||
for index in lo..=hi {
|
||||
selected.insert(index);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let index = token
|
||||
.parse::<usize>()
|
||||
.map_err(|_| format!("invalid index `{token}`"))?;
|
||||
if index == 0 {
|
||||
return Err("indexes are 1-based".to_owned());
|
||||
}
|
||||
if index > file_count {
|
||||
return Err(format!("index {index} out of range 1..={file_count}"));
|
||||
}
|
||||
selected.insert(index);
|
||||
}
|
||||
Ok(selected)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use super::{RequestGroup, RpcError, TrackerRequestModel, rpc_bt_info_hash};
|
||||
|
||||
/// Builds a tracker announce request from the current BitTorrent runtime snapshot.
|
||||
pub(in crate::dispatcher) fn build_tracker_request(
|
||||
group: &RequestGroup,
|
||||
) -> Result<TrackerRequestModel, RpcError> {
|
||||
let bt = group
|
||||
.bt()
|
||||
.ok_or_else(|| RpcError::unsupported("tracker announce requires bt runtime state"))?;
|
||||
let announce_url = bt
|
||||
.trackers
|
||||
.first()
|
||||
.map(|tracker| tracker.url.clone())
|
||||
.ok_or_else(|| RpcError::unsupported("tracker announce requires at least one tracker"))?;
|
||||
let info_hash = if !bt.info_hash.is_empty() {
|
||||
bt.info_hash.clone()
|
||||
} else {
|
||||
rpc_bt_info_hash(group.uri()).unwrap_or_default()
|
||||
};
|
||||
if info_hash.len() != 40 || !info_hash.chars().all(|ch| ch.is_ascii_hexdigit()) {
|
||||
return Err(RpcError::unsupported(
|
||||
"tracker announce requires a 40-character hex info hash",
|
||||
));
|
||||
}
|
||||
let selected_total = group
|
||||
.bt_selected_total_length()
|
||||
.unwrap_or_else(|| group.total_length());
|
||||
Ok(TrackerRequestModel {
|
||||
announce_url,
|
||||
info_hash,
|
||||
peer_id: format!("{:040x}", group.gid().as_u64()),
|
||||
port: 6881,
|
||||
uploaded: group.upload_length(),
|
||||
downloaded: group.completed_length(),
|
||||
left: selected_total.saturating_sub(group.completed_length()),
|
||||
event: Some("started".to_owned()),
|
||||
compact: true,
|
||||
numwant: Some(50),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
use crate::{
|
||||
handlers::RpcHandlerContext,
|
||||
jsonrpc::{JsonRpcRequest, JsonRpcResponse},
|
||||
model::{RpcAuthContext, RpcError, RpcMeta, RpcValue},
|
||||
router::{RpcDispatchRequest, RpcDispatchResult},
|
||||
xmlrpc::{XmlRpcMember, XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, XmlRpcValue},
|
||||
};
|
||||
|
||||
use super::{
|
||||
InProcessRpcDispatcher,
|
||||
faults::{rpc_error_value, xmlrpc_error_value, xmlrpc_fault_from_error, xmlrpc_fault_value},
|
||||
helpers::xmlrpc_member_value,
|
||||
};
|
||||
|
||||
impl InProcessRpcDispatcher {
|
||||
#[must_use]
|
||||
/// Dispatches a JSON-RPC request through the in-process engine.
|
||||
pub fn dispatch_json(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
match request.method.as_str() {
|
||||
"aria2.addUri" => self.handle_add_uri(request),
|
||||
"aria2.addTorrent" => self.handle_add_torrent(request),
|
||||
"aria2.addMetalink" => self.handle_add_metalink(request),
|
||||
"aria2.tellStatus" => self.handle_tell_status(request),
|
||||
"aria2.tellWaiting" => self.handle_tell_waiting(request),
|
||||
"aria2.tellStopped" => self.handle_tell_stopped(request),
|
||||
"aria2.pause" => self.handle_state_transition(
|
||||
request,
|
||||
"aria2.pause",
|
||||
aria2_rust_pro_core::DownloadEngine::pause,
|
||||
),
|
||||
"aria2.forcePause" => self.handle_state_transition(
|
||||
request,
|
||||
"aria2.forcePause",
|
||||
aria2_rust_pro_core::DownloadEngine::pause,
|
||||
),
|
||||
"aria2.unpause" => self.handle_state_transition(
|
||||
request,
|
||||
"aria2.unpause",
|
||||
aria2_rust_pro_core::DownloadEngine::resume,
|
||||
),
|
||||
"aria2.remove" => self.handle_state_transition(
|
||||
request,
|
||||
"aria2.remove",
|
||||
aria2_rust_pro_core::DownloadEngine::remove,
|
||||
),
|
||||
"aria2.forceRemove" => self.handle_state_transition(
|
||||
request,
|
||||
"aria2.forceRemove",
|
||||
aria2_rust_pro_core::DownloadEngine::remove,
|
||||
),
|
||||
"aria2.pauseAll" => self.handle_pause_all(request),
|
||||
"aria2.forcePauseAll" => self.handle_pause_all(request),
|
||||
"aria2.unpauseAll" => self.handle_unpause_all(request),
|
||||
"aria2.tellActive" => self.handle_tell_active(request),
|
||||
"aria2.getGlobalStat" | "aria2.tellGlobalStat" => self.handle_tell_global_stat(request),
|
||||
"aria2.getGlobalOption" => self.handle_get_global_option(request),
|
||||
"aria2.changeGlobalOption" => self.handle_change_global_option(request),
|
||||
"aria2.getOption" => self.handle_get_option(request),
|
||||
"aria2.changeOption" => self.handle_change_option(request),
|
||||
"aria2.getUris" => self.handle_get_uris(request),
|
||||
"aria2.getFiles" => self.handle_get_files(request),
|
||||
"aria2.getPeers" => self.handle_get_peers(request),
|
||||
"aria2.getServers" => self.handle_get_servers(request),
|
||||
"aria2.changePosition" => self.handle_change_position(request),
|
||||
"aria2.changeUri" => self.handle_change_uri(request),
|
||||
"aria2.purgeDownloadResult" => self.handle_purge_download_result(request),
|
||||
"aria2.removeDownloadResult" => self.handle_remove_download_result(request),
|
||||
"aria2.getSessionInfo" => self.handle_get_session_info(request),
|
||||
"aria2.saveSession" => self.handle_save_session(request),
|
||||
"aria2.shutdown" => self.handle_shutdown(request),
|
||||
"aria2.forceShutdown" => self.handle_force_shutdown(request),
|
||||
"system.multicall" | "aria2.multicall" => self.handle_multicall(request),
|
||||
"aria2.getVersion" => JsonRpcResponse::success(request.id, self.rpc_version_payload()),
|
||||
_ => {
|
||||
let ctx = RpcHandlerContext {
|
||||
auth: RpcAuthContext::default(),
|
||||
meta: request.meta.clone(),
|
||||
};
|
||||
match self
|
||||
.router
|
||||
.dispatch(RpcDispatchRequest::Json(request.clone()), ctx)
|
||||
{
|
||||
RpcDispatchResult::Json(response) => response,
|
||||
RpcDispatchResult::Xml(_) | RpcDispatchResult::Empty => {
|
||||
JsonRpcResponse::success(request.id, RpcValue::Null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Dispatches an XML-RPC request through the in-process engine.
|
||||
pub fn dispatch_xml(&mut self, request: XmlRpcMethodCall) -> XmlRpcMethodResponse {
|
||||
if request.method_name == "aria2.getVersion" {
|
||||
return XmlRpcMethodResponse {
|
||||
value: Some(crate::xmlrpc::rpc_value_to_xmlrpc(
|
||||
self.rpc_version_payload(),
|
||||
)),
|
||||
fault: None,
|
||||
meta: RpcMeta::default(),
|
||||
};
|
||||
}
|
||||
if request.method_name == "aria2.getSessionInfo" {
|
||||
return XmlRpcMethodResponse {
|
||||
value: Some(XmlRpcValue::Struct(vec![XmlRpcMember {
|
||||
name: "sessionId".to_owned(),
|
||||
value: XmlRpcValue::String(self.session_id.clone()),
|
||||
}])),
|
||||
fault: None,
|
||||
meta: RpcMeta::default(),
|
||||
};
|
||||
}
|
||||
if request.method_name == "system.multicall" {
|
||||
return self.handle_xml_multicall(request);
|
||||
}
|
||||
let json_request = JsonRpcRequest {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id: None,
|
||||
method: request.method_name,
|
||||
params: request
|
||||
.params
|
||||
.into_iter()
|
||||
.map(|param| crate::xmlrpc::xmlrpc_value_to_rpc(param.value))
|
||||
.collect(),
|
||||
meta: request.meta,
|
||||
};
|
||||
let response = self.dispatch_json(json_request);
|
||||
XmlRpcMethodResponse {
|
||||
value: response.result.map(crate::xmlrpc::rpc_value_to_xmlrpc),
|
||||
fault: response.error.map(xmlrpc_fault_from_error),
|
||||
meta: RpcMeta::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatches XML-RPC multicall entries and wraps each response in upstream-compatible arrays.
|
||||
fn handle_xml_multicall(&mut self, request: XmlRpcMethodCall) -> XmlRpcMethodResponse {
|
||||
let Some(first) = request.params.first() else {
|
||||
return XmlRpcMethodResponse {
|
||||
value: None,
|
||||
fault: Some(xmlrpc_fault_from_error(RpcError::invalid_params(
|
||||
"system.multicall requires method specs",
|
||||
))),
|
||||
meta: RpcMeta::default(),
|
||||
};
|
||||
};
|
||||
let XmlRpcValue::Array(method_specs) = &first.value else {
|
||||
return XmlRpcMethodResponse {
|
||||
value: None,
|
||||
fault: Some(xmlrpc_fault_from_error(RpcError::invalid_params(
|
||||
"system.multicall expected array of method specs",
|
||||
))),
|
||||
meta: RpcMeta::default(),
|
||||
};
|
||||
};
|
||||
|
||||
let mut results = Vec::with_capacity(method_specs.len());
|
||||
for method_spec in method_specs {
|
||||
let XmlRpcValue::Struct(spec) = method_spec else {
|
||||
results.push(xmlrpc_error_value(RpcError::invalid_params(
|
||||
"system.multicall expected struct.",
|
||||
)));
|
||||
continue;
|
||||
};
|
||||
let Some(XmlRpcValue::String(method_name)) = xmlrpc_member_value(spec, "methodName")
|
||||
else {
|
||||
results.push(xmlrpc_error_value(RpcError::invalid_params(
|
||||
"Missing methodName.",
|
||||
)));
|
||||
continue;
|
||||
};
|
||||
if method_name == "system.multicall" {
|
||||
results.push(xmlrpc_error_value(RpcError::invalid_params(
|
||||
"Recursive system.multicall forbidden.",
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
let params = match xmlrpc_member_value(spec, "params") {
|
||||
Some(XmlRpcValue::Array(params)) => params
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|value| XmlRpcParam { value })
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let response = self.dispatch_xml(XmlRpcMethodCall {
|
||||
method_name: method_name.clone(),
|
||||
params,
|
||||
meta: request.meta.clone(),
|
||||
});
|
||||
if let Some(fault) = response.fault {
|
||||
results.push(xmlrpc_fault_value(fault));
|
||||
} else {
|
||||
results.push(XmlRpcValue::Array(vec![
|
||||
response.value.unwrap_or(XmlRpcValue::Nil),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
XmlRpcMethodResponse {
|
||||
value: Some(XmlRpcValue::Array(results)),
|
||||
fault: None,
|
||||
meta: RpcMeta::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles JSON-RPC multicall requests while preserving per-call result ordering.
|
||||
fn handle_multicall(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
let Some(first) = request.params.first() else {
|
||||
return JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::invalid_params("system.multicall requires method specs"),
|
||||
);
|
||||
};
|
||||
let RpcValue::Array(method_specs) = first else {
|
||||
return JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::invalid_params("system.multicall expected array of method specs"),
|
||||
);
|
||||
};
|
||||
|
||||
let mut results = Vec::with_capacity(method_specs.len());
|
||||
for method_spec in method_specs {
|
||||
let RpcValue::Object(spec) = method_spec else {
|
||||
results.push(rpc_error_value(RpcError::invalid_params(
|
||||
"system.multicall expected struct.",
|
||||
)));
|
||||
continue;
|
||||
};
|
||||
let Some(RpcValue::String(method_name)) = spec.get("methodName") else {
|
||||
results.push(rpc_error_value(RpcError::invalid_params(
|
||||
"Missing methodName.",
|
||||
)));
|
||||
continue;
|
||||
};
|
||||
if method_name == "system.multicall" || method_name == "aria2.multicall" {
|
||||
results.push(rpc_error_value(RpcError::invalid_params(
|
||||
"Recursive system.multicall forbidden.",
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
let params = match spec.get("params") {
|
||||
Some(RpcValue::Array(params)) => params.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let response = self.dispatch_json(JsonRpcRequest {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id: None,
|
||||
method: method_name.clone(),
|
||||
params,
|
||||
meta: request.meta.clone(),
|
||||
});
|
||||
if let Some(error) = response.error {
|
||||
results.push(rpc_error_value(error));
|
||||
} else {
|
||||
results.push(RpcValue::Array(vec![
|
||||
response.result.unwrap_or(RpcValue::Null),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
JsonRpcResponse::success(request.id, RpcValue::Array(results))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{
|
||||
model::{RpcError, RpcValue},
|
||||
xmlrpc::{XmlRpcMember, XmlRpcValue},
|
||||
};
|
||||
|
||||
/// Converts an RPC error into the JSON-RPC error-object shape used by multicall.
|
||||
pub(super) fn rpc_error_value(error: RpcError) -> RpcValue {
|
||||
RpcValue::Object(BTreeMap::from([
|
||||
(
|
||||
"code".to_owned(),
|
||||
RpcValue::Number(i64::from(error.code as i32)),
|
||||
),
|
||||
("message".to_owned(), RpcValue::String(error.message)),
|
||||
]))
|
||||
}
|
||||
|
||||
/// Converts an RPC error into the XML-RPC fault-value shape used by multicall.
|
||||
pub(super) fn xmlrpc_error_value(error: RpcError) -> XmlRpcValue {
|
||||
xmlrpc_fault_value(xmlrpc_fault_from_error(error))
|
||||
}
|
||||
|
||||
/// Serializes an XML-RPC fault struct into a value payload.
|
||||
pub(super) fn xmlrpc_fault_value(fault: crate::xmlrpc::XmlRpcFault) -> XmlRpcValue {
|
||||
XmlRpcValue::Struct(vec![
|
||||
XmlRpcMember {
|
||||
name: "faultCode".to_owned(),
|
||||
value: XmlRpcValue::Int(fault.code),
|
||||
},
|
||||
XmlRpcMember {
|
||||
name: "faultString".to_owned(),
|
||||
value: XmlRpcValue::String(fault.message),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
/// Wraps an RPC error in the XML-RPC fault envelope expected by aria2 clients.
|
||||
pub(super) fn xmlrpc_fault_from_error(error: RpcError) -> crate::xmlrpc::XmlRpcFault {
|
||||
let message = error.message.clone();
|
||||
crate::xmlrpc::XmlRpcFault {
|
||||
code: 1,
|
||||
message,
|
||||
error: Some(error),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use aria2_rust_pro_compat::{global_option_specs, per_download_option_specs};
|
||||
use aria2_rust_pro_core::{DownloadHandle, RequestGroup};
|
||||
use base64::Engine;
|
||||
|
||||
use crate::{
|
||||
model::RpcValue,
|
||||
xmlrpc::{XmlRpcMember, XmlRpcValue},
|
||||
};
|
||||
|
||||
/// Builds the unified option surface exposed by `getGlobalOption`.
|
||||
pub(super) fn option_specs_for_global_view() -> Vec<&'static aria2_rust_pro_compat::OptionSpec> {
|
||||
let mut specs = global_option_specs();
|
||||
let mut seen = specs.iter().map(|spec| spec.name).collect::<BTreeSet<_>>();
|
||||
for spec in per_download_option_specs() {
|
||||
if seen.insert(spec.name) {
|
||||
specs.push(spec);
|
||||
}
|
||||
}
|
||||
specs
|
||||
}
|
||||
|
||||
/// Lossily converts a `usize` into an `i64` for RPC payload rendering.
|
||||
pub(super) fn i64_from_usize(value: usize) -> i64 {
|
||||
i64::try_from(value).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
/// Lossily converts a `usize` into a `u32` for engine-facing counters.
|
||||
pub(super) fn u32_from_usize(value: usize) -> u32 {
|
||||
u32::try_from(value).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
/// Lossily converts a `usize` into a `u64` for RPC payload rendering.
|
||||
pub(super) fn u64_from_usize(value: usize) -> u64 {
|
||||
u64::try_from(value).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// Converts a non-negative JSON-RPC integer into a platform `usize`.
|
||||
pub(super) fn usize_from_i64(value: i64) -> Option<usize> {
|
||||
usize::try_from(value).ok()
|
||||
}
|
||||
|
||||
/// Lossily converts a `u64` into a `usize` for local indexing.
|
||||
pub(super) fn usize_from_u64(value: u64) -> usize {
|
||||
usize::try_from(value).unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
/// Returns the first per-download option key that aria2 forbids through `changeOption`.
|
||||
pub(super) fn first_forbidden_change_option_key(map: &BTreeMap<String, RpcValue>) -> Option<&str> {
|
||||
const FORBIDDEN: &[&str] = &[
|
||||
"dry-run",
|
||||
"metalink-base-uri",
|
||||
"parameterized-uri",
|
||||
"pause",
|
||||
"piece-length",
|
||||
"rpc-save-upload-metadata",
|
||||
];
|
||||
FORBIDDEN
|
||||
.iter()
|
||||
.find_map(|name| map.contains_key(*name).then_some(*name))
|
||||
}
|
||||
|
||||
/// Returns the first global option key that aria2 forbids through `changeGlobalOption`.
|
||||
pub(super) fn first_forbidden_change_global_option_key(
|
||||
map: &BTreeMap<String, RpcValue>,
|
||||
) -> Option<&str> {
|
||||
const FORBIDDEN: &[&str] = &["checksum", "index-out", "out", "pause", "select-file"];
|
||||
FORBIDDEN
|
||||
.iter()
|
||||
.find_map(|name| map.contains_key(*name).then_some(*name))
|
||||
}
|
||||
|
||||
/// Parses a required RPC URI parameter into a normalized URI list.
|
||||
pub(super) fn parse_uri_list_param(value: &RpcValue) -> Result<Vec<String>, String> {
|
||||
match value {
|
||||
RpcValue::String(uri) => Ok(vec![uri.clone()]),
|
||||
RpcValue::Array(items) => {
|
||||
let mut uris = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
match item {
|
||||
RpcValue::String(uri) => uris.push(uri.clone()),
|
||||
_ => return Err("uri array must contain only strings".to_owned()),
|
||||
}
|
||||
}
|
||||
if uris.is_empty() {
|
||||
return Err("uri array must not be empty".to_owned());
|
||||
}
|
||||
Ok(uris)
|
||||
}
|
||||
_ => Err("uris must be an array of strings".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a URI array parameter that may legally be empty.
|
||||
pub(super) fn parse_uri_array_allow_empty(
|
||||
value: &RpcValue,
|
||||
label: &str,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let RpcValue::Array(items) = value else {
|
||||
return Err(format!("{label} must be an array of strings"));
|
||||
};
|
||||
let mut uris = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
if let RpcValue::String(uri) = item {
|
||||
uris.push(uri.clone());
|
||||
}
|
||||
}
|
||||
Ok(uris)
|
||||
}
|
||||
|
||||
/// Parses an optional webseed URI array for add-torrent style methods.
|
||||
pub(super) fn parse_optional_uri_array(
|
||||
value: &RpcValue,
|
||||
method: &str,
|
||||
) -> Result<Vec<String>, String> {
|
||||
match value {
|
||||
RpcValue::Array(items) => {
|
||||
let mut uris = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
match item {
|
||||
RpcValue::String(uri) => uris.push(uri.clone()),
|
||||
_ => {
|
||||
return Err(format!("{method} webseed uris must contain only strings"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(uris)
|
||||
}
|
||||
_ => Err(format!("{method} webseed uris must be an array of strings")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an optional RPC options object into owned key-value entries.
|
||||
pub(super) fn parse_optional_option_object(
|
||||
value: Option<&RpcValue>,
|
||||
method: &str,
|
||||
) -> Result<Vec<(String, RpcValue)>, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let RpcValue::Object(options) = value else {
|
||||
return Err(format!("{method} options must be a struct/object"));
|
||||
};
|
||||
Ok(options
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Parses an optional queue position parameter.
|
||||
pub(super) fn parse_optional_position(
|
||||
value: Option<&RpcValue>,
|
||||
method: &str,
|
||||
) -> Result<Option<usize>, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
match value {
|
||||
RpcValue::Number(value) if *value >= 0 => Ok(usize_from_i64(*value)),
|
||||
_ => Err(format!("{method} position must be a non-negative integer")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a required 1-based file index parameter.
|
||||
pub(super) fn parse_required_file_index(value: &RpcValue) -> Option<usize> {
|
||||
match value {
|
||||
RpcValue::Number(value) if *value >= 1 => usize_from_i64(*value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether a URI looks actionable for aria2-style add methods.
|
||||
pub(super) fn is_rpc_uri_candidate(uri: &str) -> bool {
|
||||
rpc_uri_has_ascii_prefix(uri, "magnet:?")
|
||||
|| uri.contains("://")
|
||||
|| rpc_uri_has_ascii_suffix(uri, ".torrent")
|
||||
}
|
||||
|
||||
/// Returns whether a URI starts with an ASCII prefix, ignoring case.
|
||||
pub(super) fn rpc_uri_has_ascii_prefix(uri: &str, prefix: &str) -> bool {
|
||||
uri.get(..prefix.len())
|
||||
.is_some_and(|head| head.eq_ignore_ascii_case(prefix))
|
||||
}
|
||||
|
||||
/// Returns whether a URI ends with an ASCII suffix, ignoring case.
|
||||
pub(super) fn rpc_uri_has_ascii_suffix(uri: &str, suffix: &str) -> bool {
|
||||
uri.get(uri.len().saturating_sub(suffix.len())..)
|
||||
.is_some_and(|tail| tail.eq_ignore_ascii_case(suffix))
|
||||
}
|
||||
|
||||
/// Extracts a display file name from a URI when one is obvious.
|
||||
pub(super) fn rpc_uri_file_name(uri: &str) -> Option<String> {
|
||||
let trimmed = uri
|
||||
.split(['?', '#'])
|
||||
.next()
|
||||
.unwrap_or(uri)
|
||||
.trim_end_matches('/');
|
||||
let candidate = trimmed.rsplit('/').next()?;
|
||||
if candidate.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(candidate.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an optional status-field allowlist parameter.
|
||||
pub(super) fn parse_optional_status_keys(
|
||||
value: Option<&RpcValue>,
|
||||
method: &str,
|
||||
) -> Result<Option<BTreeSet<String>>, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
let RpcValue::Array(items) = value else {
|
||||
return Err(format!("{method} keys must be an array of strings"));
|
||||
};
|
||||
if items.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut keys = BTreeSet::new();
|
||||
for item in items {
|
||||
match item {
|
||||
RpcValue::String(key) => {
|
||||
keys.insert(key.clone());
|
||||
}
|
||||
_ => return Err(format!("{method} keys must contain only strings")),
|
||||
}
|
||||
}
|
||||
Ok(Some(keys))
|
||||
}
|
||||
|
||||
/// Filters a status payload down to the requested field set.
|
||||
pub(super) fn filter_status_payload(
|
||||
payload: RpcValue,
|
||||
keys: Option<&BTreeSet<String>>,
|
||||
) -> RpcValue {
|
||||
let Some(keys) = keys else {
|
||||
return payload;
|
||||
};
|
||||
match payload {
|
||||
RpcValue::Object(fields) => RpcValue::Object(
|
||||
fields
|
||||
.into_iter()
|
||||
.filter(|(key, _)| keys.contains(key))
|
||||
.collect(),
|
||||
),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies RPC option values to a request group using aria2's stringly option model.
|
||||
pub(super) fn apply_group_options(group: &mut RequestGroup, options: Vec<(String, RpcValue)>) {
|
||||
for (key, value) in options {
|
||||
match value {
|
||||
RpcValue::String(value) => group.set_option(key, value),
|
||||
RpcValue::Number(value) => group.set_option(key, value.to_string()),
|
||||
RpcValue::Bool(value) => group.set_option(key, if value { "true" } else { "false" }),
|
||||
RpcValue::Null => group.set_option(key, ""),
|
||||
RpcValue::Array(_) | RpcValue::Object(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies already-normalized string options to a request group directly.
|
||||
pub(super) fn apply_group_string_options(group: &mut RequestGroup, options: Vec<(String, String)>) {
|
||||
for (key, value) in options {
|
||||
group.set_option(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds implied request-group options from a metalink plan entry.
|
||||
pub(super) fn metalink_default_options(
|
||||
entry: &aria2_rust_pro_protocol::metalink::MetalinkDownloadPlanEntry,
|
||||
) -> Vec<(String, RpcValue)> {
|
||||
let mut options = Vec::new();
|
||||
if !entry.file_name.trim().is_empty() {
|
||||
options.push(("out".to_owned(), RpcValue::String(entry.file_name.clone())));
|
||||
}
|
||||
if let Some(checksum) = &entry.checksum {
|
||||
options.push((
|
||||
"checksum".to_owned(),
|
||||
RpcValue::String(format!("{}={}", checksum.algorithm, checksum.expected_hex)),
|
||||
));
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
/// Decodes a base64 metalink payload when the caller did not send raw XML.
|
||||
pub(super) fn decode_metalink_payload(value: &str) -> Option<String> {
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(value.as_bytes())
|
||||
.ok()?;
|
||||
let text = String::from_utf8(bytes).ok()?;
|
||||
text.contains("<metalink").then_some(text)
|
||||
}
|
||||
|
||||
/// Slices a download-handle list using aria2's positive and negative offset rules.
|
||||
pub(super) fn slice_handles_by_offset(
|
||||
handles: Vec<DownloadHandle>,
|
||||
offset: i64,
|
||||
max: usize,
|
||||
) -> Vec<DownloadHandle> {
|
||||
if max == 0 || handles.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if offset >= 0 {
|
||||
return handles
|
||||
.into_iter()
|
||||
.skip(usize_from_i64(offset).unwrap_or(usize::MAX))
|
||||
.take(max)
|
||||
.collect();
|
||||
}
|
||||
|
||||
let reversed = handles.into_iter().rev().collect::<Vec<_>>();
|
||||
let start = offset
|
||||
.checked_neg()
|
||||
.and_then(|value| value.checked_sub(1))
|
||||
.and_then(usize_from_i64)
|
||||
.unwrap_or_default();
|
||||
reversed.into_iter().skip(start).take(max).collect()
|
||||
}
|
||||
|
||||
/// Looks up a named XML-RPC struct member.
|
||||
pub(super) fn xmlrpc_member_value<'a>(
|
||||
members: &'a [XmlRpcMember],
|
||||
name: &str,
|
||||
) -> Option<&'a XmlRpcValue> {
|
||||
members
|
||||
.iter()
|
||||
.find(|member| member.name == name)
|
||||
.map(|member| &member.value)
|
||||
}
|
||||
|
||||
/// Parses the completed byte count from a `Content-Range` header value.
|
||||
pub(super) fn parse_content_range_completed_length(value: &str) -> Option<u64> {
|
||||
let mut parts = value.split_whitespace();
|
||||
let unit = parts.next()?;
|
||||
if !unit.eq_ignore_ascii_case("bytes") {
|
||||
return None;
|
||||
}
|
||||
let range = parts.next()?;
|
||||
let (start, end) = range.split_once('-')?;
|
||||
let start = start.parse::<u64>().ok()?;
|
||||
let end = end.parse::<u64>().ok()?;
|
||||
if end < start {
|
||||
return None;
|
||||
}
|
||||
Some(end - start + 1)
|
||||
}
|
||||
|
||||
/// Returns whether an HTTP status should preserve retry eligibility.
|
||||
pub(super) fn is_retry_relevant_status(status: u16) -> bool {
|
||||
matches!(status, 408 | 425 | 429 | 500 | 502 | 503 | 504)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
use super::compat_support::apply_bt_select_file_option;
|
||||
use super::{
|
||||
CoreError, DownloadEngine, DownloadId, DownloadStatus, InProcessRpcDispatcher, JsonRpcRequest,
|
||||
JsonRpcResponse, QueuePositionMode, RpcError, RpcValue, SaveSessionTarget,
|
||||
first_forbidden_change_global_option_key, first_forbidden_change_option_key, i64_from_usize,
|
||||
is_rpc_uri_candidate, parse_optional_position, parse_required_file_index,
|
||||
parse_uri_array_allow_empty, state_transition_rpc_error,
|
||||
};
|
||||
|
||||
impl InProcessRpcDispatcher {
|
||||
/// Handles `aria2.changeGlobalOption` after rejecting unsupported dynamic keys.
|
||||
pub(super) fn handle_change_global_option(
|
||||
&mut self,
|
||||
request: JsonRpcRequest,
|
||||
) -> JsonRpcResponse {
|
||||
let Some(RpcValue::Object(map)) = request.params.first() else {
|
||||
return JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::invalid_params("aria2.changeGlobalOption needs option object"),
|
||||
);
|
||||
};
|
||||
if let Some(option) = first_forbidden_change_global_option_key(map) {
|
||||
return JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::invalid_params(&format!(
|
||||
"aria2.changeGlobalOption does not allow dynamic updates for option: {option}"
|
||||
)),
|
||||
);
|
||||
}
|
||||
let patch = self.rpc_object_to_patch(map.clone());
|
||||
self.engine.apply_options(patch);
|
||||
JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned()))
|
||||
}
|
||||
|
||||
/// Handles `aria2.changeOption` by applying validated per-download option patches.
|
||||
pub(super) fn handle_change_option(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
let gid = match self.parse_gid_from_first_param(&request, "aria2.changeOption") {
|
||||
Ok(gid) => gid,
|
||||
Err(error) => return JsonRpcResponse::error(request.id, error),
|
||||
};
|
||||
let Some(RpcValue::Object(map)) = request.params.get(1) else {
|
||||
return JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::invalid_params("aria2.changeOption needs option object"),
|
||||
);
|
||||
};
|
||||
if let Some(option) = first_forbidden_change_option_key(map) {
|
||||
return JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::invalid_params(&format!(
|
||||
"aria2.changeOption does not allow dynamic updates for option: {option}"
|
||||
)),
|
||||
);
|
||||
}
|
||||
let patch = self.rpc_object_to_patch(map.clone());
|
||||
let select_file_option = map
|
||||
.get("select-file")
|
||||
.map(|value| self.option_value_text(&self.rpc_value_to_option_value(value.clone())));
|
||||
match self.engine.handle_mut(gid) {
|
||||
Some(group) => {
|
||||
if let Some(select_file_value) = select_file_option
|
||||
&& let Err(message) = apply_bt_select_file_option(group, &select_file_value)
|
||||
{
|
||||
return JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::invalid_params(&format!("invalid select-file option: {message}")),
|
||||
);
|
||||
}
|
||||
group.options_mut().merge(patch);
|
||||
JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned()))
|
||||
}
|
||||
None => JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::unsupported(&format!("Cannot change option for GID#{gid}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles `aria2.changePosition` by moving waiting downloads within the queue.
|
||||
pub(super) fn handle_change_position(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
let gid = match self.parse_gid_from_first_param(&request, "aria2.changePosition") {
|
||||
Ok(gid) => gid,
|
||||
Err(error) => return JsonRpcResponse::error(request.id, error),
|
||||
};
|
||||
let Some(position) = request.params.get(1).and_then(|value| match value {
|
||||
RpcValue::Number(value) => Some(*value),
|
||||
_ => None,
|
||||
}) else {
|
||||
return JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::invalid_params("aria2.changePosition needs position"),
|
||||
);
|
||||
};
|
||||
let Some(mode_text) = request.params.get(2).and_then(|value| match value {
|
||||
RpcValue::String(value) => Some(value.as_str()),
|
||||
_ => None,
|
||||
}) else {
|
||||
return JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::invalid_params("aria2.changePosition needs mode"),
|
||||
);
|
||||
};
|
||||
let mode = match mode_text {
|
||||
"POS_SET" => QueuePositionMode::Set,
|
||||
"POS_CUR" => QueuePositionMode::Cur,
|
||||
"POS_END" => QueuePositionMode::End,
|
||||
_ => {
|
||||
return JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::invalid_params("Illegal argument."),
|
||||
);
|
||||
}
|
||||
};
|
||||
match self.engine.change_position(gid, position, mode) {
|
||||
Ok(dest) => {
|
||||
JsonRpcResponse::success(request.id, RpcValue::Number(i64_from_usize(dest)))
|
||||
}
|
||||
Err(_) => JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::unsupported(&format!("GID#{gid} not found in the waiting queue.")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles `aria2.changeUri` by removing and inserting source URIs for a download.
|
||||
pub(super) fn handle_change_uri(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
let gid = match self.parse_gid_from_first_param(&request, "aria2.changeUri") {
|
||||
Ok(gid) => gid,
|
||||
Err(error) => return JsonRpcResponse::error(request.id, error),
|
||||
};
|
||||
let Some(file_index) = request.params.get(1).and_then(parse_required_file_index) else {
|
||||
return JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::invalid_params("aria2.changeUri needs fileIndex"),
|
||||
);
|
||||
};
|
||||
let Some(del_uris_value) = request.params.get(2) else {
|
||||
return JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::invalid_params("aria2.changeUri needs delUris"),
|
||||
);
|
||||
};
|
||||
let del_uris = match parse_uri_array_allow_empty(del_uris_value, "aria2.changeUri delUris")
|
||||
{
|
||||
Ok(uris) => uris,
|
||||
Err(error) => {
|
||||
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error));
|
||||
}
|
||||
};
|
||||
let Some(add_uris_value) = request.params.get(3) else {
|
||||
return JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::invalid_params("aria2.changeUri needs addUris"),
|
||||
);
|
||||
};
|
||||
let add_uris = match parse_uri_array_allow_empty(add_uris_value, "aria2.changeUri addUris")
|
||||
{
|
||||
Ok(uris) => uris,
|
||||
Err(error) => {
|
||||
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error));
|
||||
}
|
||||
};
|
||||
let position = match parse_optional_position(request.params.get(4), "aria2.changeUri") {
|
||||
Ok(position) => position,
|
||||
Err(error) => {
|
||||
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&error));
|
||||
}
|
||||
};
|
||||
if file_index != 1 {
|
||||
return JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::unsupported("fileIndex is out of range"),
|
||||
);
|
||||
}
|
||||
let Some(group) = self.engine.handle_mut(gid) else {
|
||||
return JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::unsupported(&format!("Cannot remove URIs from GID#{gid}")),
|
||||
);
|
||||
};
|
||||
let mut deleted = 0_i64;
|
||||
for uri in del_uris {
|
||||
if group.context_mut().remove_first_matching_uri(&uri) {
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
let mut inserted = 0_i64;
|
||||
if let Some(mut position) = position {
|
||||
for uri in add_uris {
|
||||
if !is_rpc_uri_candidate(&uri) {
|
||||
continue;
|
||||
}
|
||||
group.context_mut().insert_uri(position, uri);
|
||||
position += 1;
|
||||
inserted += 1;
|
||||
}
|
||||
} else {
|
||||
for uri in add_uris {
|
||||
if !is_rpc_uri_candidate(&uri) {
|
||||
continue;
|
||||
}
|
||||
group.context_mut().append_uri(uri);
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
JsonRpcResponse::success(
|
||||
request.id,
|
||||
RpcValue::Array(vec![RpcValue::Number(deleted), RpcValue::Number(inserted)]),
|
||||
)
|
||||
}
|
||||
|
||||
/// Handles `aria2.purgeDownloadResult` by removing all stopped download results.
|
||||
pub(super) fn handle_purge_download_result(
|
||||
&mut self,
|
||||
request: JsonRpcRequest,
|
||||
) -> JsonRpcResponse {
|
||||
self.engine.purge_download_results();
|
||||
JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned()))
|
||||
}
|
||||
|
||||
/// Handles `aria2.removeDownloadResult` for a single stopped download result.
|
||||
pub(super) fn handle_remove_download_result(
|
||||
&mut self,
|
||||
request: JsonRpcRequest,
|
||||
) -> JsonRpcResponse {
|
||||
let Some(gid_text) = request.params.first().and_then(|value| match value {
|
||||
RpcValue::String(gid) => Some(gid.clone()),
|
||||
_ => None,
|
||||
}) else {
|
||||
return JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::invalid_params("aria2.removeDownloadResult needs gid"),
|
||||
);
|
||||
};
|
||||
let Some(gid) = DownloadId::parse_hex(&gid_text) else {
|
||||
return JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::unsupported(&format!("Invalid GID {gid_text}")),
|
||||
);
|
||||
};
|
||||
match self.engine.remove_download_result(gid) {
|
||||
Ok(()) => JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned())),
|
||||
Err(_) => JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError {
|
||||
code: crate::model::RpcErrorCode::ApplicationError,
|
||||
kind: crate::model::RpcErrorKind::Internal,
|
||||
message: format!("Could not remove download result of GID#{gid_text}"),
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles `aria2.saveSession` by writing the runtime session target when configured.
|
||||
pub(super) fn handle_save_session(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
let target = self
|
||||
.engine
|
||||
.session()
|
||||
.session_file()
|
||||
.cloned()
|
||||
.map(SaveSessionTarget::Path)
|
||||
.unwrap_or(SaveSessionTarget::Memory);
|
||||
match self.engine.save_session(target) {
|
||||
Ok(()) => JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned())),
|
||||
Err(error) => {
|
||||
JsonRpcResponse::error(request.id, RpcError::unsupported(&error.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles graceful shutdown requests with the aria2 success sentinel.
|
||||
pub(super) fn handle_shutdown(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
match self.engine.shutdown() {
|
||||
Ok(()) => JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned())),
|
||||
Err(error) => {
|
||||
JsonRpcResponse::error(request.id, RpcError::unsupported(&error.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles forced shutdown requests with the aria2 success sentinel.
|
||||
pub(super) fn handle_force_shutdown(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
match self.engine.force_shutdown() {
|
||||
Ok(()) => JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned())),
|
||||
Err(error) => {
|
||||
JsonRpcResponse::error(request.id, RpcError::unsupported(&error.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles pause-all variants by pausing eligible active or waiting downloads.
|
||||
pub(super) fn handle_pause_all(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
let gids: Vec<_> = self
|
||||
.engine
|
||||
.registry()
|
||||
.handles()
|
||||
.filter(|handle| {
|
||||
self.engine
|
||||
.registry()
|
||||
.get(handle.gid())
|
||||
.is_some_and(|group| {
|
||||
matches!(
|
||||
group.status(),
|
||||
DownloadStatus::Active | DownloadStatus::Waiting
|
||||
)
|
||||
})
|
||||
})
|
||||
.map(|handle| handle.gid())
|
||||
.collect();
|
||||
for gid in gids {
|
||||
let _ = self.engine.pause(gid);
|
||||
}
|
||||
JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned()))
|
||||
}
|
||||
|
||||
/// Handles `aria2.unpauseAll` by resuming eligible paused downloads.
|
||||
pub(super) fn handle_unpause_all(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
let gids: Vec<_> = self
|
||||
.engine
|
||||
.registry()
|
||||
.handles()
|
||||
.filter(|handle| {
|
||||
self.engine
|
||||
.registry()
|
||||
.get(handle.gid())
|
||||
.is_some_and(|group| group.status() == &DownloadStatus::Paused)
|
||||
})
|
||||
.map(|handle| handle.gid())
|
||||
.collect();
|
||||
for gid in gids {
|
||||
let _ = self.engine.resume(gid);
|
||||
}
|
||||
JsonRpcResponse::success(request.id, RpcValue::String("OK".to_owned()))
|
||||
}
|
||||
|
||||
/// Handles single-download pause, resume, and remove state transitions.
|
||||
pub(super) fn handle_state_transition<F>(
|
||||
&mut self,
|
||||
request: JsonRpcRequest,
|
||||
method: &'static str,
|
||||
mut apply: F,
|
||||
) -> JsonRpcResponse
|
||||
where
|
||||
F: FnMut(&mut DownloadEngine, DownloadId) -> Result<(), CoreError>,
|
||||
{
|
||||
let Some(RpcValue::String(gid)) = request.params.first() else {
|
||||
return JsonRpcResponse::error(request.id, RpcError::invalid_params(method));
|
||||
};
|
||||
let Some(gid) = DownloadId::parse_hex(gid) else {
|
||||
return JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::unsupported(&format!("Invalid GID {gid}")),
|
||||
);
|
||||
};
|
||||
match apply(&mut self.engine, gid) {
|
||||
Ok(()) => JsonRpcResponse::success(request.id, RpcValue::String(gid.to_string())),
|
||||
Err(error) => {
|
||||
JsonRpcResponse::error(request.id, state_transition_rpc_error(method, gid, &error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
use aria2_rust_pro_compat::per_download_option_specs;
|
||||
|
||||
use super::{
|
||||
BT_STATUS_FIELDS, BTreeMap, BtFileInfo, BtTrackerInfo, Digest, DownloadStatus,
|
||||
InProcessRpcDispatcher, OptionKey, OptionPatch, OptionValue, PieceId, PieceState, RequestGroup,
|
||||
RpcValue, option_specs_for_global_view, rpc_bt_info_hash, rpc_share_ratio_text,
|
||||
rpc_share_time_text, rpc_uri_file_name, rpc_uri_has_ascii_prefix, rpc_uri_has_ascii_suffix,
|
||||
verified_length_for_range,
|
||||
};
|
||||
|
||||
impl InProcessRpcDispatcher {
|
||||
/// Builds the full aria2 `tellStatus` object for a request group.
|
||||
pub(super) fn rpc_status_payload(&self, group: &RequestGroup) -> RpcValue {
|
||||
let snapshot = self
|
||||
.engine
|
||||
.progress_snapshot(group.gid())
|
||||
.unwrap_or_else(|_| {
|
||||
aria2_rust_pro_core::ProgressSnapshot::new(group.gid(), *group.status())
|
||||
});
|
||||
let piece_length = group.piece_length().max(1);
|
||||
let total_length = snapshot.total_length;
|
||||
let num_pieces = if total_length == 0 {
|
||||
0
|
||||
} else {
|
||||
total_length.div_ceil(piece_length)
|
||||
};
|
||||
let completed_pieces = group
|
||||
.piece_map()
|
||||
.iter()
|
||||
.filter(|(_, state)| **state == PieceState::Verified)
|
||||
.count()
|
||||
.try_into()
|
||||
.unwrap_or(u64::MAX);
|
||||
let mut status = BTreeMap::from([
|
||||
("gid".to_owned(), RpcValue::String(group.gid().to_string())),
|
||||
(
|
||||
"status".to_owned(),
|
||||
RpcValue::String(self.rpc_status_name(group.status()).to_owned()),
|
||||
),
|
||||
(
|
||||
"totalLength".to_owned(),
|
||||
RpcValue::String(snapshot.total_length.to_string()),
|
||||
),
|
||||
(
|
||||
"completedLength".to_owned(),
|
||||
RpcValue::String(snapshot.completed_length.to_string()),
|
||||
),
|
||||
(
|
||||
"uploadLength".to_owned(),
|
||||
RpcValue::String(snapshot.upload_length.to_string()),
|
||||
),
|
||||
(
|
||||
"uploadSpeed".to_owned(),
|
||||
RpcValue::String(snapshot.upload_speed.to_string()),
|
||||
),
|
||||
(
|
||||
"shareRatio".to_owned(),
|
||||
RpcValue::String(rpc_share_ratio_text(snapshot.share_ratio_milli)),
|
||||
),
|
||||
(
|
||||
"shareRatioProgress".to_owned(),
|
||||
RpcValue::String(rpc_share_ratio_text(snapshot.share_ratio_milli)),
|
||||
),
|
||||
(
|
||||
"shareRatioRemaining".to_owned(),
|
||||
RpcValue::String("0.000".to_owned()),
|
||||
),
|
||||
(
|
||||
"shareTime".to_owned(),
|
||||
RpcValue::String(rpc_share_time_text(&snapshot)),
|
||||
),
|
||||
(
|
||||
"downloadSpeed".to_owned(),
|
||||
RpcValue::String(snapshot.download_speed.to_string()),
|
||||
),
|
||||
(
|
||||
"retryCount".to_owned(),
|
||||
RpcValue::String(group.retry_count().to_string()),
|
||||
),
|
||||
(
|
||||
"retryAttempts".to_owned(),
|
||||
RpcValue::Array(
|
||||
group
|
||||
.retry_attempts()
|
||||
.iter()
|
||||
.map(|attempt| {
|
||||
RpcValue::Object(BTreeMap::from([
|
||||
(
|
||||
"attempt".to_owned(),
|
||||
RpcValue::String(attempt.attempt.to_string()),
|
||||
),
|
||||
(
|
||||
"offset".to_owned(),
|
||||
RpcValue::String(attempt.offset.to_string()),
|
||||
),
|
||||
(
|
||||
"length".to_owned(),
|
||||
RpcValue::String(
|
||||
attempt.length.unwrap_or_default().to_string(),
|
||||
),
|
||||
),
|
||||
(
|
||||
"recoverable".to_owned(),
|
||||
RpcValue::Bool(attempt.recoverable),
|
||||
),
|
||||
(
|
||||
"error".to_owned(),
|
||||
RpcValue::String(attempt.error.clone().unwrap_or_default()),
|
||||
),
|
||||
]))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
(
|
||||
"numSeeders".to_owned(),
|
||||
RpcValue::String(self.rpc_bt_num_seeders(group).to_string()),
|
||||
),
|
||||
(
|
||||
"seeders".to_owned(),
|
||||
RpcValue::String(self.rpc_bt_num_seeders(group).to_string()),
|
||||
),
|
||||
(
|
||||
"connections".to_owned(),
|
||||
RpcValue::String(snapshot.num_connections.to_string()),
|
||||
),
|
||||
(
|
||||
"activeSegments".to_owned(),
|
||||
RpcValue::String(snapshot.num_connections.to_string()),
|
||||
),
|
||||
(
|
||||
"pieceLength".to_owned(),
|
||||
RpcValue::String(piece_length.to_string()),
|
||||
),
|
||||
(
|
||||
"numPieces".to_owned(),
|
||||
RpcValue::String(num_pieces.to_string()),
|
||||
),
|
||||
(
|
||||
"completedPieces".to_owned(),
|
||||
RpcValue::String(completed_pieces.to_string()),
|
||||
),
|
||||
("errorCode".to_owned(), RpcValue::String("0".to_owned())),
|
||||
("dir".to_owned(), RpcValue::String(String::new())),
|
||||
(
|
||||
"resumeState".to_owned(),
|
||||
self.rpc_resume_state_payload(group),
|
||||
),
|
||||
(
|
||||
"files".to_owned(),
|
||||
RpcValue::Array(vec![self.rpc_file_payload(group)]),
|
||||
),
|
||||
]);
|
||||
status.extend(self.rpc_bt_status_fields(group));
|
||||
RpcValue::Object(status)
|
||||
}
|
||||
|
||||
/// Maps internal download states to aria2 status names.
|
||||
pub(super) fn rpc_status_name(&self, status: &DownloadStatus) -> &'static str {
|
||||
status.as_rpc_status()
|
||||
}
|
||||
|
||||
/// Builds the effective global option map visible through RPC.
|
||||
pub(super) fn effective_global_option_map(&self) -> BTreeMap<String, RpcValue> {
|
||||
option_specs_for_global_view()
|
||||
.into_iter()
|
||||
.map(|spec| {
|
||||
let key = spec
|
||||
.rpc_names
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(spec.name)
|
||||
.to_owned();
|
||||
let value = self
|
||||
.engine
|
||||
.session()
|
||||
.global_options()
|
||||
.get(&OptionKey::new(spec.name))
|
||||
.map(|value| self.option_value_text(value))
|
||||
.unwrap_or_else(|| spec.default_value.to_owned());
|
||||
(key, RpcValue::String(value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Builds the effective per-download option map with global fallbacks applied.
|
||||
pub(super) fn effective_download_option_map(
|
||||
&self,
|
||||
group: &RequestGroup,
|
||||
) -> BTreeMap<String, RpcValue> {
|
||||
per_download_option_specs()
|
||||
.into_iter()
|
||||
.map(|spec| {
|
||||
let key = spec
|
||||
.rpc_names
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(spec.name)
|
||||
.to_owned();
|
||||
let value = group
|
||||
.options()
|
||||
.get(&OptionKey::new(spec.name))
|
||||
.map(|value| self.option_value_text(value))
|
||||
.or_else(|| {
|
||||
self.engine
|
||||
.session()
|
||||
.global_options()
|
||||
.get(&OptionKey::new(spec.name))
|
||||
.map(|value| self.option_value_text(value))
|
||||
})
|
||||
.unwrap_or_else(|| spec.default_value.to_owned());
|
||||
(key, RpcValue::String(value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Converts an internal option value into aria2's stringly RPC representation.
|
||||
pub(super) fn option_value_text(&self, value: &OptionValue) -> String {
|
||||
match value {
|
||||
OptionValue::Bool(value) => value.to_string(),
|
||||
OptionValue::Int(value) => value.to_string(),
|
||||
OptionValue::UInt(value) => value.to_string(),
|
||||
OptionValue::Text(value) => value.clone(),
|
||||
OptionValue::List(value) => value.join(","),
|
||||
OptionValue::Map(value) => value
|
||||
.iter()
|
||||
.map(|(key, value)| format!("{key}={value}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
OptionValue::Empty => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts an RPC option object into an engine option patch.
|
||||
pub(super) fn rpc_object_to_patch(&self, map: BTreeMap<String, RpcValue>) -> OptionPatch {
|
||||
let mut patch = OptionPatch::new();
|
||||
for (key, value) in map {
|
||||
patch.insert(key, self.rpc_value_to_option_value(value));
|
||||
}
|
||||
patch
|
||||
}
|
||||
|
||||
/// Converts a single RPC value into the engine option-value model.
|
||||
pub(super) fn rpc_value_to_option_value(&self, value: RpcValue) -> OptionValue {
|
||||
match value {
|
||||
RpcValue::Null => OptionValue::Empty,
|
||||
RpcValue::Bool(value) => OptionValue::Bool(value),
|
||||
RpcValue::Number(value) => OptionValue::Int(value),
|
||||
RpcValue::String(value) => OptionValue::Text(value),
|
||||
RpcValue::Array(values) => OptionValue::List(
|
||||
values
|
||||
.into_iter()
|
||||
.map(|value| self.option_value_text(&self.rpc_value_to_option_value(value)))
|
||||
.collect(),
|
||||
),
|
||||
RpcValue::Object(values) => OptionValue::Map(
|
||||
values
|
||||
.into_iter()
|
||||
.map(|(key, value)| {
|
||||
(
|
||||
key,
|
||||
self.option_value_text(&self.rpc_value_to_option_value(value)),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the displayed output path for a download.
|
||||
pub(super) fn rpc_target_path(&self, group: &RequestGroup) -> String {
|
||||
let dir = group
|
||||
.options()
|
||||
.get(&OptionKey::from("dir"))
|
||||
.or_else(|| {
|
||||
self.engine
|
||||
.session()
|
||||
.global_options()
|
||||
.get(&OptionKey::from("dir"))
|
||||
})
|
||||
.and_then(OptionValue::as_text)
|
||||
.map(std::path::PathBuf::from);
|
||||
let file_name = group
|
||||
.options()
|
||||
.get(&OptionKey::from("out"))
|
||||
.and_then(OptionValue::as_text)
|
||||
.map(str::to_owned)
|
||||
.or_else(|| rpc_uri_file_name(group.uri()))
|
||||
.unwrap_or_else(|| group.gid().to_string());
|
||||
match dir {
|
||||
Some(dir) => dir.join(file_name).to_string_lossy().into_owned(),
|
||||
None => file_name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds aria2 URI entries for a request group.
|
||||
pub(super) fn rpc_uris_payload(&self, group: &RequestGroup) -> Vec<RpcValue> {
|
||||
if let Some(uri) = group.bt().and_then(|bt| bt.magnet_uri.clone()) {
|
||||
return vec![RpcValue::Object(BTreeMap::from([
|
||||
("status".to_owned(), RpcValue::String("used".to_owned())),
|
||||
("uri".to_owned(), RpcValue::String(uri)),
|
||||
]))];
|
||||
}
|
||||
group
|
||||
.uris()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, uri)| {
|
||||
RpcValue::Object(BTreeMap::from([
|
||||
(
|
||||
"status".to_owned(),
|
||||
RpcValue::String(if index == 0 { "used" } else { "waiting" }.to_owned()),
|
||||
),
|
||||
("uri".to_owned(), RpcValue::String(uri.clone())),
|
||||
]))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Builds aria2 file payloads for a request group.
|
||||
pub(super) fn rpc_file_payloads(&self, group: &RequestGroup) -> Vec<RpcValue> {
|
||||
match group.bt() {
|
||||
Some(bt) if !bt.files.is_empty() => bt
|
||||
.files
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, file)| self.rpc_bt_file_payload(group, index, file))
|
||||
.collect(),
|
||||
_ => vec![self.rpc_file_payload(group)],
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the single-file payload used for non-BitTorrent downloads.
|
||||
pub(super) fn rpc_file_payload(&self, group: &RequestGroup) -> RpcValue {
|
||||
let snapshot = self
|
||||
.engine
|
||||
.progress_snapshot(group.gid())
|
||||
.unwrap_or_else(|_| {
|
||||
aria2_rust_pro_core::ProgressSnapshot::new(group.gid(), group.status().clone())
|
||||
});
|
||||
let piece_length = group.piece_length().max(1);
|
||||
let total_length = snapshot.total_length;
|
||||
let num_pieces = if total_length == 0 {
|
||||
0
|
||||
} else {
|
||||
total_length.div_ceil(piece_length)
|
||||
};
|
||||
let bitfield = self.rpc_piece_bitfield(group, num_pieces);
|
||||
let path = self.rpc_target_path(group);
|
||||
let completed_length =
|
||||
verified_length_for_range(group, 0, total_length, piece_length, total_length);
|
||||
let mut file = BTreeMap::from([
|
||||
("index".to_owned(), RpcValue::String("1".to_owned())),
|
||||
("path".to_owned(), RpcValue::String(path)),
|
||||
(
|
||||
"length".to_owned(),
|
||||
RpcValue::String(snapshot.total_length.to_string()),
|
||||
),
|
||||
(
|
||||
"completedLength".to_owned(),
|
||||
RpcValue::String(completed_length.to_string()),
|
||||
),
|
||||
(
|
||||
"pieceLength".to_owned(),
|
||||
RpcValue::String(piece_length.to_string()),
|
||||
),
|
||||
(
|
||||
"numPieces".to_owned(),
|
||||
RpcValue::String(num_pieces.to_string()),
|
||||
),
|
||||
("bitfield".to_owned(), RpcValue::String(bitfield)),
|
||||
("selected".to_owned(), RpcValue::String("true".to_owned())),
|
||||
(
|
||||
"uris".to_owned(),
|
||||
RpcValue::Array(self.rpc_uris_payload(group)),
|
||||
),
|
||||
]);
|
||||
file.insert("isBt".to_owned(), RpcValue::Bool(self.rpc_is_bt(group)));
|
||||
file.insert("btPath".to_owned(), RpcValue::String(String::new()));
|
||||
file.insert(
|
||||
"btCompletedPieces".to_owned(),
|
||||
RpcValue::String(
|
||||
group
|
||||
.piece_map()
|
||||
.iter()
|
||||
.filter(|(_, state)| **state == PieceState::Verified)
|
||||
.count()
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
RpcValue::Object(file)
|
||||
}
|
||||
|
||||
/// Builds one aria2 BitTorrent file payload from torrent metadata and progress.
|
||||
pub(super) fn rpc_bt_file_payload(
|
||||
&self,
|
||||
group: &RequestGroup,
|
||||
index: usize,
|
||||
file: &BtFileInfo,
|
||||
) -> RpcValue {
|
||||
let piece_length = group.piece_length().max(1);
|
||||
let file_completed = verified_length_for_range(
|
||||
group,
|
||||
file.piece_offset.unwrap_or_default(),
|
||||
file.length,
|
||||
piece_length,
|
||||
group.total_length(),
|
||||
);
|
||||
let num_pieces = if file.length == 0 {
|
||||
0
|
||||
} else {
|
||||
file.length.div_ceil(piece_length)
|
||||
};
|
||||
RpcValue::Object(BTreeMap::from([
|
||||
(
|
||||
"index".to_owned(),
|
||||
RpcValue::String((index + 1).to_string()),
|
||||
),
|
||||
("path".to_owned(), RpcValue::String(file.path.clone())),
|
||||
(
|
||||
"length".to_owned(),
|
||||
RpcValue::String(file.length.to_string()),
|
||||
),
|
||||
(
|
||||
"completedLength".to_owned(),
|
||||
RpcValue::String(file_completed.to_string()),
|
||||
),
|
||||
(
|
||||
"pieceLength".to_owned(),
|
||||
RpcValue::String(piece_length.to_string()),
|
||||
),
|
||||
(
|
||||
"numPieces".to_owned(),
|
||||
RpcValue::String(num_pieces.to_string()),
|
||||
),
|
||||
(
|
||||
"bitfield".to_owned(),
|
||||
RpcValue::String(self.rpc_piece_bitfield(group, num_pieces)),
|
||||
),
|
||||
(
|
||||
"selected".to_owned(),
|
||||
RpcValue::String(file.selected.to_string()),
|
||||
),
|
||||
(
|
||||
"uris".to_owned(),
|
||||
RpcValue::Array(self.rpc_uris_payload(group)),
|
||||
),
|
||||
("isBt".to_owned(), RpcValue::Bool(true)),
|
||||
("btPath".to_owned(), RpcValue::String(file.path.clone())),
|
||||
(
|
||||
"btCompletedPieces".to_owned(),
|
||||
RpcValue::String(
|
||||
group
|
||||
.piece_map()
|
||||
.iter()
|
||||
.filter(|(_, state)| **state == PieceState::Verified)
|
||||
.count()
|
||||
.to_string(),
|
||||
),
|
||||
),
|
||||
]))
|
||||
}
|
||||
|
||||
/// Builds the resume-state metadata exposed in dispatcher status payloads.
|
||||
pub(super) fn rpc_resume_state_payload(&self, group: &RequestGroup) -> RpcValue {
|
||||
match group.resume_state() {
|
||||
Some(state) => RpcValue::Object(BTreeMap::from([
|
||||
("persisted".to_owned(), RpcValue::Bool(state.persisted)),
|
||||
(
|
||||
"resumeOffset".to_owned(),
|
||||
RpcValue::String(state.resume_offset.to_string()),
|
||||
),
|
||||
(
|
||||
"validatedLength".to_owned(),
|
||||
RpcValue::String(state.validated_length.unwrap_or_default().to_string()),
|
||||
),
|
||||
(
|
||||
"segmentCursor".to_owned(),
|
||||
RpcValue::String(
|
||||
state
|
||||
.segment_cursor
|
||||
.map(|piece| piece.0.to_string())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
),
|
||||
])),
|
||||
None => RpcValue::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes verified pieces as the hexadecimal bitfield expected by aria2 clients.
|
||||
pub(super) fn rpc_piece_bitfield(&self, group: &RequestGroup, num_pieces: u64) -> String {
|
||||
let mut bitfield = String::with_capacity(num_pieces as usize);
|
||||
for piece_index in 0..num_pieces {
|
||||
let state = group.piece_state(PieceId(piece_index as u32));
|
||||
let marker = match state {
|
||||
Some(PieceState::Verified) => '2',
|
||||
Some(PieceState::Downloading) => '1',
|
||||
_ => '0',
|
||||
};
|
||||
bitfield.push(marker);
|
||||
}
|
||||
bitfield
|
||||
}
|
||||
|
||||
/// Builds a server payload for a non-BitTorrent download.
|
||||
pub(super) fn rpc_server_payload(&self, group: &RequestGroup) -> RpcValue {
|
||||
let host = self.rpc_server_host(group.uri());
|
||||
let mut top = BTreeMap::from([
|
||||
("index".to_owned(), RpcValue::String("1".to_owned())),
|
||||
(
|
||||
"servers".to_owned(),
|
||||
RpcValue::Array(vec![RpcValue::Object(BTreeMap::from([
|
||||
("uri".to_owned(), RpcValue::String(group.uri().to_owned())),
|
||||
(
|
||||
"currentUri".to_owned(),
|
||||
RpcValue::String(group.uri().to_owned()),
|
||||
),
|
||||
("downloadSpeed".to_owned(), RpcValue::String("0".to_owned())),
|
||||
("host".to_owned(), RpcValue::String(host)),
|
||||
]))]),
|
||||
),
|
||||
]);
|
||||
top.insert("isBt".to_owned(), RpcValue::Bool(self.rpc_is_bt(group)));
|
||||
RpcValue::Object(top)
|
||||
}
|
||||
|
||||
/// Builds server or tracker payloads for `aria2.getServers`.
|
||||
pub(super) fn rpc_server_payloads(&self, group: &RequestGroup) -> Vec<RpcValue> {
|
||||
match group.bt() {
|
||||
Some(bt) if !bt.trackers.is_empty() => bt
|
||||
.trackers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, tracker)| self.rpc_bt_server_payload(index, tracker))
|
||||
.collect(),
|
||||
_ => vec![self.rpc_server_payload(group)],
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a tracker row for BitTorrent server payloads.
|
||||
pub(super) fn rpc_bt_server_payload(&self, index: usize, tracker: &BtTrackerInfo) -> RpcValue {
|
||||
let host = self.rpc_server_host(&tracker.url);
|
||||
RpcValue::Object(BTreeMap::from([
|
||||
(
|
||||
"index".to_owned(),
|
||||
RpcValue::String((index + 1).to_string()),
|
||||
),
|
||||
(
|
||||
"servers".to_owned(),
|
||||
RpcValue::Array(vec![RpcValue::Object(BTreeMap::from([
|
||||
("uri".to_owned(), RpcValue::String(tracker.url.clone())),
|
||||
(
|
||||
"currentUri".to_owned(),
|
||||
RpcValue::String(tracker.url.clone()),
|
||||
),
|
||||
("downloadSpeed".to_owned(), RpcValue::String("0".to_owned())),
|
||||
("host".to_owned(), RpcValue::String(host)),
|
||||
]))]),
|
||||
),
|
||||
("isBt".to_owned(), RpcValue::Bool(true)),
|
||||
]))
|
||||
}
|
||||
|
||||
/// Returns whether the request group has BitTorrent runtime metadata.
|
||||
pub(super) fn rpc_is_bt(&self, group: &RequestGroup) -> bool {
|
||||
if group.bt().is_some() {
|
||||
return true;
|
||||
}
|
||||
rpc_uri_has_ascii_prefix(group.uri(), "magnet:?")
|
||||
|| rpc_uri_has_ascii_suffix(group.uri(), ".torrent")
|
||||
}
|
||||
|
||||
/// Builds the BitTorrent-specific portion of an aria2 status payload.
|
||||
pub(super) fn rpc_bt_status_fields(&self, group: &RequestGroup) -> BTreeMap<String, RpcValue> {
|
||||
let mut fields = BTreeMap::new();
|
||||
let is_bt = self.rpc_is_bt(group);
|
||||
fields.insert("isBt".to_owned(), RpcValue::Bool(is_bt));
|
||||
fields.insert("mode".to_owned(), RpcValue::String("single".to_owned()));
|
||||
let info_hash = group
|
||||
.bt()
|
||||
.map(|bt| bt.info_hash.clone())
|
||||
.or_else(|| rpc_bt_info_hash(group.uri()))
|
||||
.unwrap_or_default();
|
||||
fields.insert("infoHash".to_owned(), RpcValue::String(info_hash));
|
||||
fields.insert(
|
||||
"seeder".to_owned(),
|
||||
RpcValue::String(self.rpc_bt_is_seeder(group).to_string()),
|
||||
);
|
||||
let piece_length = group.piece_length().max(1);
|
||||
let num_pieces = if group.total_length() == 0 {
|
||||
0
|
||||
} else {
|
||||
group.total_length().div_ceil(piece_length)
|
||||
};
|
||||
fields.insert(
|
||||
"bitfield".to_owned(),
|
||||
RpcValue::String(self.rpc_piece_bitfield(group, num_pieces)),
|
||||
);
|
||||
fields.insert(
|
||||
"announceList".to_owned(),
|
||||
RpcValue::Array(self.rpc_bt_announce_list(group)),
|
||||
);
|
||||
fields.insert("followedBy".to_owned(), RpcValue::Array(Vec::new()));
|
||||
fields.insert("following".to_owned(), RpcValue::String(String::new()));
|
||||
fields.insert("belongsTo".to_owned(), RpcValue::String(String::new()));
|
||||
fields.insert(
|
||||
"verifiedLength".to_owned(),
|
||||
RpcValue::String(
|
||||
(group
|
||||
.piece_map()
|
||||
.iter()
|
||||
.filter(|(_, state)| **state == PieceState::Verified)
|
||||
.count() as u64
|
||||
* piece_length)
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
fields.insert(
|
||||
"verifyIntegrityPending".to_owned(),
|
||||
RpcValue::String("false".to_owned()),
|
||||
);
|
||||
fields.insert(
|
||||
"metadataOnly".to_owned(),
|
||||
RpcValue::Bool(
|
||||
group
|
||||
.bt()
|
||||
.map(|bt| bt.metadata_only)
|
||||
.unwrap_or_else(|| rpc_uri_has_ascii_prefix(group.uri(), "magnet:?")),
|
||||
),
|
||||
);
|
||||
fields.insert(
|
||||
"magnetUri".to_owned(),
|
||||
RpcValue::String(
|
||||
group
|
||||
.bt()
|
||||
.and_then(|bt| bt.magnet_uri.clone())
|
||||
.or_else(|| {
|
||||
rpc_uri_has_ascii_prefix(group.uri(), "magnet:?")
|
||||
.then(|| group.uri().to_owned())
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
);
|
||||
fields.insert(
|
||||
"creationDate".to_owned(),
|
||||
RpcValue::String(
|
||||
group
|
||||
.bt()
|
||||
.and_then(|bt| bt.creation_date.clone())
|
||||
.unwrap_or_else(|| "0".to_owned()),
|
||||
),
|
||||
);
|
||||
fields.insert(
|
||||
"comment".to_owned(),
|
||||
RpcValue::String(
|
||||
group
|
||||
.bt()
|
||||
.and_then(|bt| bt.comment.clone())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
);
|
||||
fields.insert(
|
||||
"btFieldCoverage".to_owned(),
|
||||
RpcValue::Array(
|
||||
BT_STATUS_FIELDS
|
||||
.iter()
|
||||
.map(|name| RpcValue::String((*name).to_owned()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
fields
|
||||
}
|
||||
|
||||
/// Builds the nested announce-list representation for BitTorrent status payloads.
|
||||
pub(super) fn rpc_bt_announce_list(&self, group: &RequestGroup) -> Vec<RpcValue> {
|
||||
let Some(bt) = group.bt() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut tiers = BTreeMap::<u32, Vec<String>>::new();
|
||||
for tracker in &bt.trackers {
|
||||
tiers
|
||||
.entry(tracker.tier.unwrap_or(0))
|
||||
.or_default()
|
||||
.push(tracker.url.clone());
|
||||
}
|
||||
tiers
|
||||
.into_values()
|
||||
.map(|tier| RpcValue::Array(tier.into_iter().map(RpcValue::String).collect()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Builds peer rows for `aria2.getPeers`.
|
||||
pub(super) fn rpc_peer_payload(&self, group: &RequestGroup) -> Vec<RpcValue> {
|
||||
if !self.rpc_is_bt(group) {
|
||||
return Vec::new();
|
||||
}
|
||||
match group.bt() {
|
||||
Some(bt) => bt
|
||||
.peers
|
||||
.iter()
|
||||
.map(|peer| {
|
||||
RpcValue::Object(BTreeMap::from([
|
||||
(
|
||||
"peerId".to_owned(),
|
||||
RpcValue::String(peer.peer_id.clone().unwrap_or_default()),
|
||||
),
|
||||
("ip".to_owned(), RpcValue::String(peer.ip.clone())),
|
||||
("port".to_owned(), RpcValue::String(peer.port.to_string())),
|
||||
("bitfield".to_owned(), RpcValue::String(String::new())),
|
||||
(
|
||||
"amChoking".to_owned(),
|
||||
RpcValue::String(peer.choked.to_string()),
|
||||
),
|
||||
(
|
||||
"peerChoking".to_owned(),
|
||||
RpcValue::String(peer.choked.to_string()),
|
||||
),
|
||||
(
|
||||
"downloadSpeed".to_owned(),
|
||||
RpcValue::String(peer.download_speed.to_string()),
|
||||
),
|
||||
(
|
||||
"uploadSpeed".to_owned(),
|
||||
RpcValue::String(peer.upload_speed.to_string()),
|
||||
),
|
||||
(
|
||||
"seeder".to_owned(),
|
||||
RpcValue::String(peer.seeder.to_string()),
|
||||
),
|
||||
]))
|
||||
})
|
||||
.collect(),
|
||||
None => vec![RpcValue::Object(BTreeMap::from([
|
||||
("peerId".to_owned(), RpcValue::String(String::new())),
|
||||
("ip".to_owned(), RpcValue::String(String::new())),
|
||||
("port".to_owned(), RpcValue::String("0".to_owned())),
|
||||
("bitfield".to_owned(), RpcValue::String(String::new())),
|
||||
("amChoking".to_owned(), RpcValue::String("true".to_owned())),
|
||||
(
|
||||
"peerChoking".to_owned(),
|
||||
RpcValue::String("true".to_owned()),
|
||||
),
|
||||
("downloadSpeed".to_owned(), RpcValue::String("0".to_owned())),
|
||||
("uploadSpeed".to_owned(), RpcValue::String("0".to_owned())),
|
||||
("seeder".to_owned(), RpcValue::String("false".to_owned())),
|
||||
]))],
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the host portion displayed in server payloads.
|
||||
pub(super) fn rpc_server_host(&self, uri: &str) -> String {
|
||||
uri.split_once("://")
|
||||
.map(|(_, rest)| rest)
|
||||
.unwrap_or(uri)
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// Counts known BitTorrent seeders for a request group.
|
||||
pub(super) fn rpc_bt_num_seeders(&self, group: &RequestGroup) -> u32 {
|
||||
group
|
||||
.bt()
|
||||
.map(|bt| {
|
||||
let from_trackers = bt
|
||||
.trackers
|
||||
.iter()
|
||||
.filter_map(|tracker| tracker.seeders)
|
||||
.max();
|
||||
from_trackers
|
||||
.unwrap_or_else(|| bt.peers.iter().filter(|peer| peer.seeder).count() as u32)
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Returns whether local BitTorrent state should be reported as seeding.
|
||||
pub(super) fn rpc_bt_is_seeder(&self, group: &RequestGroup) -> bool {
|
||||
self.engine
|
||||
.progress_snapshot(group.gid())
|
||||
.map(|snapshot| snapshot.bt_true_seeding)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
use super::{
|
||||
BTreeMap, DownloadId, DownloadStatus, InProcessRpcDispatcher, JsonRpcRequest, JsonRpcResponse,
|
||||
RpcError, RpcValue, filter_status_payload, parse_optional_status_keys, rpc_enabled_features,
|
||||
slice_handles_by_offset, usize_from_i64,
|
||||
};
|
||||
|
||||
impl InProcessRpcDispatcher {
|
||||
/// Handles `aria2.tellStatus` and optional status-key filtering.
|
||||
pub(super) fn handle_tell_status(&self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
let gid = match self.parse_gid_from_first_param(&request, "aria2.tellStatus") {
|
||||
Ok(gid) => gid,
|
||||
Err(error) => return JsonRpcResponse::error(request.id, error),
|
||||
};
|
||||
let keys = match parse_optional_status_keys(request.params.get(1), "aria2.tellStatus") {
|
||||
Ok(keys) => keys,
|
||||
Err(message) => {
|
||||
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&message));
|
||||
}
|
||||
};
|
||||
match self.engine.registry().get(gid) {
|
||||
Some(group) => JsonRpcResponse::success(
|
||||
request.id,
|
||||
filter_status_payload(self.rpc_status_payload(group), keys.as_ref()),
|
||||
),
|
||||
None => JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::unsupported(&format!("No such download for GID#{gid}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles `aria2.tellActive` by returning active download payloads.
|
||||
pub(super) fn handle_tell_active(&self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
let keys = match parse_optional_status_keys(request.params.first(), "aria2.tellActive") {
|
||||
Ok(keys) => keys,
|
||||
Err(message) => {
|
||||
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&message));
|
||||
}
|
||||
};
|
||||
let values = self
|
||||
.engine
|
||||
.tell_active()
|
||||
.into_iter()
|
||||
.filter_map(|handle| self.engine.registry().get(handle.gid()))
|
||||
.map(|group| filter_status_payload(self.rpc_status_payload(group), keys.as_ref()))
|
||||
.collect();
|
||||
JsonRpcResponse::success(request.id, RpcValue::Array(values))
|
||||
}
|
||||
|
||||
/// Handles `aria2.tellWaiting` with aria2-compatible offset and limit semantics.
|
||||
pub(super) fn handle_tell_waiting(&self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
let (offset, max) = self.parse_offset_and_max(&request);
|
||||
let keys = match parse_optional_status_keys(request.params.get(2), "aria2.tellWaiting") {
|
||||
Ok(keys) => keys,
|
||||
Err(message) => {
|
||||
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&message));
|
||||
}
|
||||
};
|
||||
let values = slice_handles_by_offset(self.engine.tell_waiting(), offset, max)
|
||||
.into_iter()
|
||||
.filter_map(|handle| self.engine.registry().get(handle.gid()))
|
||||
.map(|group| filter_status_payload(self.rpc_status_payload(group), keys.as_ref()))
|
||||
.collect();
|
||||
JsonRpcResponse::success(request.id, RpcValue::Array(values))
|
||||
}
|
||||
|
||||
/// Handles `aria2.tellStopped` with stopped-queue ordering and status filtering.
|
||||
pub(super) fn handle_tell_stopped(&self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
let (offset, max) = self.parse_offset_and_max(&request);
|
||||
let keys = match parse_optional_status_keys(request.params.get(2), "aria2.tellStopped") {
|
||||
Ok(keys) => keys,
|
||||
Err(message) => {
|
||||
return JsonRpcResponse::error(request.id, RpcError::invalid_params(&message));
|
||||
}
|
||||
};
|
||||
let values = slice_handles_by_offset(self.engine.tell_stopped(), offset, max)
|
||||
.into_iter()
|
||||
.filter_map(|handle| self.engine.registry().get(handle.gid()))
|
||||
.map(|group| filter_status_payload(self.rpc_status_payload(group), keys.as_ref()))
|
||||
.collect();
|
||||
JsonRpcResponse::success(request.id, RpcValue::Array(values))
|
||||
}
|
||||
|
||||
/// Handles global statistics requests using the upstream aria2 response shape.
|
||||
pub(super) fn handle_tell_global_stat(&self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
let stat = self.engine.get_global_stat();
|
||||
JsonRpcResponse::success(
|
||||
request.id,
|
||||
RpcValue::Object(BTreeMap::from([
|
||||
(
|
||||
"downloadSpeed".to_owned(),
|
||||
RpcValue::String(stat.download_speed.to_string()),
|
||||
),
|
||||
(
|
||||
"uploadSpeed".to_owned(),
|
||||
RpcValue::String(stat.upload_speed.to_string()),
|
||||
),
|
||||
(
|
||||
"numActive".to_owned(),
|
||||
RpcValue::String(stat.num_active.to_string()),
|
||||
),
|
||||
(
|
||||
"numWaiting".to_owned(),
|
||||
RpcValue::String(stat.num_waiting.to_string()),
|
||||
),
|
||||
(
|
||||
"numStopped".to_owned(),
|
||||
RpcValue::String(stat.num_stopped.to_string()),
|
||||
),
|
||||
(
|
||||
"numStoppedTotal".to_owned(),
|
||||
RpcValue::String(self.engine.num_stopped_total().to_string()),
|
||||
),
|
||||
])),
|
||||
)
|
||||
}
|
||||
|
||||
/// Handles `aria2.getGlobalOption` by exposing effective runtime option values.
|
||||
pub(super) fn handle_get_global_option(&self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
JsonRpcResponse::success(
|
||||
request.id,
|
||||
RpcValue::Object(self.effective_global_option_map()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Handles `aria2.getOption` for a single tracked download.
|
||||
pub(super) fn handle_get_option(&self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
let gid = match self.parse_gid_from_first_param(&request, "aria2.getOption") {
|
||||
Ok(gid) => gid,
|
||||
Err(error) => return JsonRpcResponse::error(request.id, error),
|
||||
};
|
||||
match self.engine.registry().get(gid) {
|
||||
Some(group) => JsonRpcResponse::success(
|
||||
request.id,
|
||||
RpcValue::Object(self.effective_download_option_map(group)),
|
||||
),
|
||||
None => JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::unsupported(&format!("Cannot get option for GID#{gid}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles `aria2.getSessionInfo` by returning the stable dispatcher session id.
|
||||
pub(super) fn handle_get_session_info(&self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
JsonRpcResponse::success(
|
||||
request.id,
|
||||
RpcValue::Object(BTreeMap::from([(
|
||||
"sessionId".to_owned(),
|
||||
RpcValue::String(self.session_id.clone()),
|
||||
)])),
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds the aria2-compatible version payload shared by JSON-RPC and XML-RPC.
|
||||
pub(super) fn rpc_version_payload(&self) -> RpcValue {
|
||||
RpcValue::Object(BTreeMap::from([
|
||||
(
|
||||
"version".to_owned(),
|
||||
RpcValue::String(aria2_rust_pro_compat::VERSION.to_owned()),
|
||||
),
|
||||
(
|
||||
"enabledFeatures".to_owned(),
|
||||
RpcValue::Array(
|
||||
rpc_enabled_features()
|
||||
.into_iter()
|
||||
.map(|feature| RpcValue::String((*feature).to_owned()))
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
]))
|
||||
}
|
||||
|
||||
/// Handles `aria2.getUris` by projecting stored source URIs for a download.
|
||||
pub(super) fn handle_get_uris(&self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
let gid = match self.parse_gid_from_first_param(&request, "aria2.getUris") {
|
||||
Ok(gid) => gid,
|
||||
Err(error) => return JsonRpcResponse::error(request.id, error),
|
||||
};
|
||||
match self.engine.registry().get(gid) {
|
||||
Some(group) => {
|
||||
JsonRpcResponse::success(request.id, RpcValue::Array(self.rpc_uris_payload(group)))
|
||||
}
|
||||
None => JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::unsupported(&format!("No URI data is available for GID#{gid}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles `aria2.getFiles` by returning per-file progress payloads.
|
||||
pub(super) fn handle_get_files(&self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
let gid = match self.parse_gid_from_first_param(&request, "aria2.getFiles") {
|
||||
Ok(gid) => gid,
|
||||
Err(error) => return JsonRpcResponse::error(request.id, error),
|
||||
};
|
||||
match self.engine.registry().get(gid) {
|
||||
Some(group) => {
|
||||
JsonRpcResponse::success(request.id, RpcValue::Array(self.rpc_file_payloads(group)))
|
||||
}
|
||||
None => JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::unsupported(&format!("No file data is available for GID#{gid}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles `aria2.getServers` by exposing active server or tracker rows.
|
||||
pub(super) fn handle_get_servers(&self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
let gid = match self.parse_gid_from_first_param(&request, "aria2.getServers") {
|
||||
Ok(gid) => gid,
|
||||
Err(error) => return JsonRpcResponse::error(request.id, error),
|
||||
};
|
||||
match self.engine.registry().get(gid) {
|
||||
Some(group) if group.status() == &DownloadStatus::Active => JsonRpcResponse::success(
|
||||
request.id,
|
||||
RpcValue::Array(self.rpc_server_payloads(group)),
|
||||
),
|
||||
_ => JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::unsupported(&format!("No active download for GID#{gid}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles `aria2.getPeers` by returning BitTorrent peer rows for BT downloads.
|
||||
pub(super) fn handle_get_peers(&self, request: JsonRpcRequest) -> JsonRpcResponse {
|
||||
let gid = match self.parse_gid_from_first_param(&request, "aria2.getPeers") {
|
||||
Ok(gid) => gid,
|
||||
Err(error) => return JsonRpcResponse::error(request.id, error),
|
||||
};
|
||||
match self.engine.registry().get(gid) {
|
||||
Some(group) => {
|
||||
JsonRpcResponse::success(request.id, RpcValue::Array(self.rpc_peer_payload(group)))
|
||||
}
|
||||
None => JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::unsupported(&format!("No peer data is available for GID#{gid}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the first RPC parameter as a download id and maps errors to RPC failures.
|
||||
pub(super) fn parse_gid_from_first_param(
|
||||
&self,
|
||||
request: &JsonRpcRequest,
|
||||
method: &'static str,
|
||||
) -> Result<DownloadId, RpcError> {
|
||||
let Some(RpcValue::String(gid)) = request.params.first() else {
|
||||
return Err(RpcError::invalid_params(&format!("{method} needs gid")));
|
||||
};
|
||||
DownloadId::parse_hex(gid)
|
||||
.ok_or_else(|| RpcError::unsupported(&format!("Invalid GID {gid}")))
|
||||
}
|
||||
|
||||
/// Parses optional queue pagination parameters using aria2 defaults.
|
||||
pub(super) fn parse_offset_and_max(&self, request: &JsonRpcRequest) -> (i64, usize) {
|
||||
let offset = match request.params.first() {
|
||||
Some(RpcValue::Number(value)) => *value,
|
||||
_ => 0,
|
||||
};
|
||||
let max = match request.params.get(1) {
|
||||
Some(RpcValue::Number(value)) if *value >= 0 => {
|
||||
usize_from_i64(*value).unwrap_or(usize::MAX)
|
||||
}
|
||||
_ => usize::MAX,
|
||||
};
|
||||
(offset, max)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
sync::Mutex,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use aria2_rust_pro_core::{
|
||||
BtFileInfo, BtPeerInfo, BtPieceAvailabilityUpdate, BtRuntimeState, DownloadId, PieceId,
|
||||
PieceState, RuntimeConfig,
|
||||
};
|
||||
use aria2_rust_pro_protocol::{
|
||||
DhtMessageModel, DhtNodeModel, DhtTransport, TrackerRequestModel, TrackerResponseModel,
|
||||
TrackerScrapeModel, TrackerTransport,
|
||||
torrent::{
|
||||
DhtGetPeersQueryModel, DhtMessageBody, DhtQueryModel, PeerWireBitfieldModel,
|
||||
PeerWireExtensionHandshakeModel, PeerWireHandshakeModel, PeerWireMessageKind,
|
||||
PeerWireMetadataMessageModel, PeerWireMetadataMessageType, PeerWirePieceBlockModel,
|
||||
TorrentMessageModel, parse_torrent_metadata,
|
||||
},
|
||||
transport::{
|
||||
PeerWireTransportConnector, PeerWireTransportRequest, PeerWireTransportResponse,
|
||||
TransportEndpoint, TransportError, TransportErrorKind, TransportScheme,
|
||||
},
|
||||
};
|
||||
use aria2_rust_pro_storage::load_session_file;
|
||||
use base64::Engine;
|
||||
|
||||
use super::{
|
||||
BtRuntimeCoordinatorAction, BtRuntimeCoordinatorStepStatus, InProcessRpcDispatcher,
|
||||
bt_metadata_piece_span, decode_hex_string_exact,
|
||||
};
|
||||
use crate::{
|
||||
jsonrpc::{JsonRpcRequest, jsonrpc_request_from_json, jsonrpc_response_to_json},
|
||||
methods::RpcMethod,
|
||||
model::{RpcError, RpcMeta, RpcValue},
|
||||
xmlrpc::{XmlRpcMember, XmlRpcMethodCall, XmlRpcParam, XmlRpcValue},
|
||||
};
|
||||
|
||||
#[doc(hidden)]
|
||||
fn temp_session_path(name: &str) -> std::path::PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock should be monotonic enough for test naming")
|
||||
.as_nanos();
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"aria2-rust-pro-rpc-test-{}-{nanos}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&root).expect("temp dir should be creatable");
|
||||
root.join(name)
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
fn request(method: RpcMethod, params: Vec<RpcValue>) -> JsonRpcRequest {
|
||||
JsonRpcRequest {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id: None,
|
||||
method: method.as_str().to_owned(),
|
||||
params,
|
||||
meta: RpcMeta::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
fn request_with_method_name(method: &str, params: Vec<RpcValue>) -> JsonRpcRequest {
|
||||
JsonRpcRequest {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id: None,
|
||||
method: method.to_owned(),
|
||||
params,
|
||||
meta: RpcMeta::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
fn add_uri(dispatcher: &mut InProcessRpcDispatcher, uri: &str) -> String {
|
||||
let response = dispatcher.dispatch_json(request(
|
||||
RpcMethod::Aria2AddUri,
|
||||
vec![RpcValue::String(uri.to_owned())],
|
||||
));
|
||||
match response.result {
|
||||
Some(RpcValue::String(gid)) => gid,
|
||||
other => panic!("unexpected addUri result: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[test]
|
||||
fn add_uri_direct_registers_uri_and_options_without_jsonrpc_roundtrip() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let gid = dispatcher
|
||||
.add_uri_direct(
|
||||
vec![
|
||||
"https://example.org/direct-a.iso".to_owned(),
|
||||
"https://example.org/direct-b.iso".to_owned(),
|
||||
],
|
||||
vec![("split".to_owned(), RpcValue::String("8".to_owned()))],
|
||||
)
|
||||
.expect("direct addUri should register");
|
||||
let group = dispatcher
|
||||
.engine
|
||||
.handle_mut(download_id(&gid))
|
||||
.expect("direct addUri group should exist");
|
||||
|
||||
assert_eq!(group.uri(), "https://example.org/direct-a.iso");
|
||||
assert_eq!(
|
||||
group.uris(),
|
||||
&[
|
||||
"https://example.org/direct-a.iso".to_owned(),
|
||||
"https://example.org/direct-b.iso".to_owned(),
|
||||
]
|
||||
);
|
||||
assert_eq!(group.option_limit("split"), Some(8));
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
fn download_id(gid: &str) -> DownloadId {
|
||||
DownloadId::parse_hex(gid).expect("gid should parse into DownloadId")
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
fn compact_peer(ip: [u8; 4], port: u16) -> Vec<u8> {
|
||||
let mut bytes = Vec::with_capacity(6);
|
||||
bytes.extend_from_slice(&ip);
|
||||
bytes.extend_from_slice(&port.to_be_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
fn compact_node(node_id_byte: u8, ip: [u8; 4], port: u16) -> Vec<u8> {
|
||||
let mut bytes = vec![node_id_byte; 20];
|
||||
bytes.extend_from_slice(&ip);
|
||||
bytes.extend_from_slice(&port.to_be_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[derive(Debug)]
|
||||
struct FakeDhtTransport {
|
||||
#[doc(hidden)]
|
||||
response: DhtMessageModel,
|
||||
#[doc(hidden)]
|
||||
seen: Mutex<Vec<(DhtNodeModel, DhtMessageModel)>>,
|
||||
}
|
||||
|
||||
impl FakeDhtTransport {
|
||||
#[doc(hidden)]
|
||||
fn new(response: DhtMessageModel) -> Self {
|
||||
Self {
|
||||
response,
|
||||
seen: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
fn seen(&self) -> Vec<(DhtNodeModel, DhtMessageModel)> {
|
||||
self.seen
|
||||
.lock()
|
||||
.expect("seen requests mutex should not be poisoned")
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl DhtTransport for FakeDhtTransport {
|
||||
#[doc(hidden)]
|
||||
fn send_message(
|
||||
&self,
|
||||
node: &DhtNodeModel,
|
||||
message: &DhtMessageModel,
|
||||
) -> Result<DhtMessageModel, TransportError> {
|
||||
self.seen
|
||||
.lock()
|
||||
.expect("seen requests mutex should not be poisoned")
|
||||
.push((node.clone(), message.clone()));
|
||||
Ok(self.response.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[derive(Debug)]
|
||||
struct FakeTrackerTransport {
|
||||
#[doc(hidden)]
|
||||
announce_response: TrackerResponseModel,
|
||||
#[doc(hidden)]
|
||||
scrape_response: Option<TrackerScrapeModel>,
|
||||
#[doc(hidden)]
|
||||
seen_announces: Mutex<Vec<TrackerRequestModel>>,
|
||||
#[doc(hidden)]
|
||||
seen_scrapes: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FakeTrackerTransport {
|
||||
#[doc(hidden)]
|
||||
fn new(
|
||||
announce_response: TrackerResponseModel,
|
||||
scrape_response: Option<TrackerScrapeModel>,
|
||||
) -> Self {
|
||||
Self {
|
||||
announce_response,
|
||||
scrape_response,
|
||||
seen_announces: Mutex::new(Vec::new()),
|
||||
seen_scrapes: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
fn seen_announces(&self) -> Vec<TrackerRequestModel> {
|
||||
self.seen_announces
|
||||
.lock()
|
||||
.expect("tracker announce mutex should not be poisoned")
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
fn seen_scrapes(&self) -> Vec<String> {
|
||||
self.seen_scrapes
|
||||
.lock()
|
||||
.expect("tracker scrape mutex should not be poisoned")
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl TrackerTransport for FakeTrackerTransport {
|
||||
#[doc(hidden)]
|
||||
fn announce(
|
||||
&self,
|
||||
request: &TrackerRequestModel,
|
||||
) -> Result<TrackerResponseModel, TransportError> {
|
||||
self.seen_announces
|
||||
.lock()
|
||||
.expect("tracker announce mutex should not be poisoned")
|
||||
.push(request.clone());
|
||||
Ok(self.announce_response.clone())
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
fn scrape(&self, url: &str) -> Result<TrackerScrapeModel, TransportError> {
|
||||
self.seen_scrapes
|
||||
.lock()
|
||||
.expect("tracker scrape mutex should not be poisoned")
|
||||
.push(url.to_owned());
|
||||
self.scrape_response.clone().ok_or_else(|| TransportError {
|
||||
kind: TransportErrorKind::ProtocolViolation,
|
||||
message: "scrape unavailable".to_owned(),
|
||||
source: None,
|
||||
context: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[derive(Debug)]
|
||||
struct FakePeerWireConnector {
|
||||
#[doc(hidden)]
|
||||
response_payload: Vec<u8>,
|
||||
#[doc(hidden)]
|
||||
seen: Mutex<Vec<PeerWireTransportRequest>>,
|
||||
}
|
||||
|
||||
impl FakePeerWireConnector {
|
||||
#[doc(hidden)]
|
||||
fn new(response_payload: Vec<u8>) -> Self {
|
||||
Self {
|
||||
response_payload,
|
||||
seen: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
fn seen(&self) -> Vec<PeerWireTransportRequest> {
|
||||
self.seen
|
||||
.lock()
|
||||
.expect("peer-wire seen requests mutex should not be poisoned")
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[derive(Debug)]
|
||||
struct SequencedPeerWireConnector {
|
||||
#[doc(hidden)]
|
||||
response_payloads: Mutex<Vec<Vec<u8>>>,
|
||||
#[doc(hidden)]
|
||||
seen: Mutex<Vec<PeerWireTransportRequest>>,
|
||||
}
|
||||
|
||||
impl SequencedPeerWireConnector {
|
||||
#[doc(hidden)]
|
||||
fn new(response_payloads: Vec<Vec<u8>>) -> Self {
|
||||
Self {
|
||||
response_payloads: Mutex::new(response_payloads),
|
||||
seen: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
fn seen(&self) -> Vec<PeerWireTransportRequest> {
|
||||
self.seen
|
||||
.lock()
|
||||
.expect("sequenced peer-wire seen requests mutex should not be poisoned")
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl PeerWireTransportConnector for SequencedPeerWireConnector {
|
||||
#[doc(hidden)]
|
||||
fn connect_peer_wire(
|
||||
&self,
|
||||
request: &PeerWireTransportRequest,
|
||||
) -> Result<PeerWireTransportResponse, TransportError> {
|
||||
self.seen
|
||||
.lock()
|
||||
.expect("sequenced peer-wire seen requests mutex should not be poisoned")
|
||||
.push(request.clone());
|
||||
let payload = self
|
||||
.response_payloads
|
||||
.lock()
|
||||
.expect("sequenced peer-wire payload mutex should not be poisoned")
|
||||
.remove(0);
|
||||
Ok(PeerWireTransportResponse {
|
||||
endpoint: TransportEndpoint {
|
||||
scheme: TransportScheme::BitTorrent,
|
||||
address: request.endpoint.address.clone(),
|
||||
},
|
||||
payload,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl PeerWireTransportConnector for FakePeerWireConnector {
|
||||
#[doc(hidden)]
|
||||
fn connect_peer_wire(
|
||||
&self,
|
||||
request: &PeerWireTransportRequest,
|
||||
) -> Result<PeerWireTransportResponse, TransportError> {
|
||||
self.seen
|
||||
.lock()
|
||||
.expect("peer-wire seen requests mutex should not be poisoned")
|
||||
.push(request.clone());
|
||||
Ok(PeerWireTransportResponse {
|
||||
endpoint: TransportEndpoint {
|
||||
scheme: TransportScheme::BitTorrent,
|
||||
address: request.endpoint.address.clone(),
|
||||
},
|
||||
payload: self.response_payload.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[derive(Debug)]
|
||||
struct RoutedDhtTransport {
|
||||
#[doc(hidden)]
|
||||
get_peers_response: DhtMessageModel,
|
||||
#[doc(hidden)]
|
||||
announce_peer_response: DhtMessageModel,
|
||||
#[doc(hidden)]
|
||||
find_node_response: Option<DhtMessageModel>,
|
||||
#[doc(hidden)]
|
||||
ping_response: Option<DhtMessageModel>,
|
||||
#[doc(hidden)]
|
||||
seen: Mutex<Vec<(DhtNodeModel, DhtMessageModel)>>,
|
||||
}
|
||||
|
||||
impl RoutedDhtTransport {
|
||||
#[doc(hidden)]
|
||||
fn new(get_peers_response: DhtMessageModel, announce_peer_response: DhtMessageModel) -> Self {
|
||||
Self {
|
||||
get_peers_response,
|
||||
announce_peer_response,
|
||||
find_node_response: None,
|
||||
ping_response: None,
|
||||
seen: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
fn seen(&self) -> Vec<(DhtNodeModel, DhtMessageModel)> {
|
||||
self.seen
|
||||
.lock()
|
||||
.expect("routed dht seen mutex should not be poisoned")
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl DhtTransport for RoutedDhtTransport {
|
||||
#[doc(hidden)]
|
||||
fn send_message(
|
||||
&self,
|
||||
node: &DhtNodeModel,
|
||||
message: &DhtMessageModel,
|
||||
) -> Result<DhtMessageModel, TransportError> {
|
||||
self.seen
|
||||
.lock()
|
||||
.expect("routed dht seen mutex should not be poisoned")
|
||||
.push((node.clone(), message.clone()));
|
||||
match &message.body {
|
||||
DhtMessageBody::Query(DhtQueryModel::GetPeers(_)) => {
|
||||
Ok(self.get_peers_response.clone())
|
||||
}
|
||||
DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(_)) => {
|
||||
Ok(self.announce_peer_response.clone())
|
||||
}
|
||||
DhtMessageBody::Query(DhtQueryModel::FindNode(_)) => self
|
||||
.find_node_response
|
||||
.clone()
|
||||
.ok_or_else(|| TransportError {
|
||||
kind: TransportErrorKind::ProtocolViolation,
|
||||
message: "find_node unavailable".to_owned(),
|
||||
source: None,
|
||||
context: None,
|
||||
}),
|
||||
DhtMessageBody::Query(DhtQueryModel::Ping(_)) => {
|
||||
self.ping_response.clone().ok_or_else(|| TransportError {
|
||||
kind: TransportErrorKind::ProtocolViolation,
|
||||
message: "ping unavailable".to_owned(),
|
||||
source: None,
|
||||
context: None,
|
||||
})
|
||||
}
|
||||
_ => Err(TransportError {
|
||||
kind: TransportErrorKind::ProtocolViolation,
|
||||
message: "unexpected dht method for routed transport".to_owned(),
|
||||
source: None,
|
||||
context: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
fn peer_wire_handshake_and_frames(
|
||||
info_hash: [u8; 20],
|
||||
peer_id: [u8; 20],
|
||||
frames: &[PeerWireMessageKind],
|
||||
) -> Vec<u8> {
|
||||
let mut bytes = PeerWireHandshakeModel::new(info_hash, peer_id).serialize();
|
||||
for frame in frames {
|
||||
bytes.extend_from_slice(
|
||||
&TorrentMessageModel::from_peer_wire_kind(frame.clone())
|
||||
.serialize_peer_wire_frame()
|
||||
.expect("peer-wire frame should serialize"),
|
||||
);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
fn peer_from_ip(ip: &str, port: u16) -> BtPeerInfo {
|
||||
BtPeerInfo {
|
||||
peer_id: None,
|
||||
ip: ip.to_owned(),
|
||||
port,
|
||||
client_name: None,
|
||||
interested: false,
|
||||
choked: true,
|
||||
download_speed: 0,
|
||||
upload_speed: 0,
|
||||
seeder: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
fn single_file_torrent_bytes(name: &str, comment_len: usize) -> Vec<u8> {
|
||||
let comment = "x".repeat(comment_len);
|
||||
format!(
|
||||
"d8:announce35:http://tracker.example.org/announce7:comment{}:{}4:infod6:lengthi2048e4:name{}:{}12:piece lengthi1024e6:pieces20:aaaaaaaaaaaaaaaaaaaaee",
|
||||
comment.len(),
|
||||
comment,
|
||||
name.len(),
|
||||
name
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[test]
|
||||
fn bt_runtime_coordinator_snapshot_surfaces_partial_magnet_runtime_and_recommended_actions() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let gid = add_uri(
|
||||
&mut dispatcher,
|
||||
"magnet:?xt=urn:btih:1234567890abcdef1234567890abcdef12345678&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
|
||||
);
|
||||
let download_id = download_id(&gid);
|
||||
{
|
||||
let group = dispatcher
|
||||
.engine
|
||||
.handle_mut(download_id)
|
||||
.expect("download group should exist");
|
||||
group.set_piece_length(1_024);
|
||||
group.set_total_length(4_096);
|
||||
group.set_piece_state(PieceId(0), PieceState::Pending);
|
||||
group.set_piece_state(PieceId(1), PieceState::Missing);
|
||||
group.set_dht_token(Some(b"cached-token".to_vec()));
|
||||
let bt = group.bt_mut().expect("bt runtime state should exist");
|
||||
bt.dht_nodes = vec!["bad-node".to_owned(), "127.0.0.9:6881".to_owned()];
|
||||
bt.peers = vec![peer_from_ip("127.0.0.7", 51413)];
|
||||
}
|
||||
|
||||
let snapshot = dispatcher
|
||||
.bt_runtime_coordinator_snapshot(&gid)
|
||||
.expect("snapshot should inspect bt runtime");
|
||||
|
||||
assert!(snapshot.metadata_only);
|
||||
assert!(snapshot.metadata_exchange_pending);
|
||||
assert_eq!(snapshot.tracker_count, 1);
|
||||
assert_eq!(snapshot.dht_node_count, 2);
|
||||
assert_eq!(snapshot.addressable_dht_node_count, 1);
|
||||
assert_eq!(snapshot.peer_count, 1);
|
||||
assert_eq!(snapshot.connectable_peer_count, 1);
|
||||
assert_eq!(snapshot.requestable_piece_count, 2);
|
||||
assert_eq!(
|
||||
snapshot.recommended_actions,
|
||||
vec![
|
||||
BtRuntimeCoordinatorAction::TrackerAnnounce,
|
||||
BtRuntimeCoordinatorAction::DhtGetPeers,
|
||||
BtRuntimeCoordinatorAction::DhtAnnouncePeer,
|
||||
BtRuntimeCoordinatorAction::PeerWireExchange,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[test]
|
||||
fn drive_bt_runtime_once_executes_newly_unlocked_bt_steps_within_one_iteration() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let gid = add_uri(
|
||||
&mut dispatcher,
|
||||
"magnet:?xt=urn:btih:fedcba9876543210fedcba9876543210fedcba98&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
|
||||
);
|
||||
let download_id = download_id(&gid);
|
||||
{
|
||||
let group = dispatcher
|
||||
.engine
|
||||
.handle_mut(download_id)
|
||||
.expect("download group should exist");
|
||||
group.set_piece_length(1_024);
|
||||
group.set_total_length(2_048);
|
||||
group.set_piece_state(PieceId(0), PieceState::Pending);
|
||||
group.set_piece_state(PieceId(1), PieceState::Missing);
|
||||
let bt = group.bt_mut().expect("bt runtime state should exist");
|
||||
bt.dht_nodes = vec!["127.0.0.11:6881".to_owned()];
|
||||
}
|
||||
|
||||
let tracker = FakeTrackerTransport::new(
|
||||
TrackerResponseModel {
|
||||
peers: aria2_rust_pro_protocol::TrackerPeerListModel {
|
||||
interval_sec: 1_800,
|
||||
peers: vec![aria2_rust_pro_protocol::torrent::TorrentPeerModel {
|
||||
ip: "127.0.0.21".to_owned(),
|
||||
port: 51_413,
|
||||
peer_id: Some(*b"12345678901234567890"),
|
||||
client_name: Some("tracker-peer".to_owned()),
|
||||
interested: false,
|
||||
choked: false,
|
||||
}],
|
||||
min_interval_sec: None,
|
||||
tracker_id: Some("tracker-id".to_owned()),
|
||||
},
|
||||
scrape: None,
|
||||
},
|
||||
Some(TrackerScrapeModel {
|
||||
complete: Some(5),
|
||||
downloaded: Some(8),
|
||||
incomplete: Some(3),
|
||||
files: Vec::new(),
|
||||
}),
|
||||
);
|
||||
let dht = RoutedDhtTransport::new(
|
||||
DhtMessageModel::get_peers_response(
|
||||
b"gp".to_vec(),
|
||||
vec![0x99; 20],
|
||||
Some(b"announce-token".to_vec()),
|
||||
Some(compact_node(0x77, [127, 0, 0, 31], 6882)),
|
||||
Vec::new(),
|
||||
),
|
||||
DhtMessageModel::ping_response(b"ap".to_vec(), vec![0x55; 20]),
|
||||
);
|
||||
let connector = FakePeerWireConnector::new(peer_wire_handshake_and_frames(
|
||||
[
|
||||
0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54,
|
||||
0x32, 0x10, 0xfe, 0xdc, 0xba, 0x98,
|
||||
],
|
||||
*b"-PC0001-LOOP-PEER-01",
|
||||
&[PeerWireMessageKind::Unchoke],
|
||||
));
|
||||
|
||||
let report = dispatcher
|
||||
.drive_bt_runtime_once(
|
||||
&gid,
|
||||
Some(&tracker),
|
||||
Some(&dht),
|
||||
Some(&connector),
|
||||
Some(1_050),
|
||||
)
|
||||
.expect("coordinator loop should run");
|
||||
|
||||
assert!(report.initial_snapshot.metadata_exchange_pending);
|
||||
assert!(report.final_snapshot.metadata_exchange_pending);
|
||||
assert_eq!(
|
||||
report
|
||||
.steps
|
||||
.iter()
|
||||
.map(|step| (step.action, step.status))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
(
|
||||
BtRuntimeCoordinatorAction::AdvanceClock,
|
||||
BtRuntimeCoordinatorStepStatus::Executed,
|
||||
),
|
||||
(
|
||||
BtRuntimeCoordinatorAction::TrackerAnnounce,
|
||||
BtRuntimeCoordinatorStepStatus::Executed,
|
||||
),
|
||||
(
|
||||
BtRuntimeCoordinatorAction::DhtGetPeers,
|
||||
BtRuntimeCoordinatorStepStatus::Executed,
|
||||
),
|
||||
(
|
||||
BtRuntimeCoordinatorAction::DhtAnnouncePeer,
|
||||
BtRuntimeCoordinatorStepStatus::Executed,
|
||||
),
|
||||
(
|
||||
BtRuntimeCoordinatorAction::PeerWireExchange,
|
||||
BtRuntimeCoordinatorStepStatus::Executed,
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
assert_eq!(tracker.seen_announces().len(), 1);
|
||||
assert_eq!(
|
||||
tracker.seen_scrapes(),
|
||||
vec!["http://tracker.example.org/announce".to_owned()]
|
||||
);
|
||||
assert_eq!(
|
||||
dht.seen().len(),
|
||||
2,
|
||||
"get_peers plus announce_peer should run"
|
||||
);
|
||||
assert_eq!(
|
||||
connector.seen().len(),
|
||||
1,
|
||||
"peer-wire should run after peers arrive"
|
||||
);
|
||||
|
||||
let final_snapshot = dispatcher
|
||||
.bt_runtime_coordinator_snapshot(&gid)
|
||||
.expect("final snapshot should remain readable");
|
||||
assert!(final_snapshot.has_dht_token);
|
||||
assert!(final_snapshot.connectable_peer_count >= 1);
|
||||
assert!(final_snapshot.addressable_dht_node_count >= 2);
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
fn xml_request(method_name: &str) -> XmlRpcMethodCall {
|
||||
XmlRpcMethodCall {
|
||||
method_name: method_name.to_owned(),
|
||||
params: Vec::new(),
|
||||
meta: RpcMeta::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
fn xml_request_with_params(method_name: &str, params: Vec<XmlRpcValue>) -> XmlRpcMethodCall {
|
||||
XmlRpcMethodCall {
|
||||
method_name: method_name.to_owned(),
|
||||
params: params
|
||||
.into_iter()
|
||||
.map(|value| XmlRpcParam { value })
|
||||
.collect(),
|
||||
meta: RpcMeta::default(),
|
||||
}
|
||||
}
|
||||
|
||||
mod bt_and_extension;
|
||||
mod protocol_surface;
|
||||
mod queue_and_options;
|
||||
@@ -0,0 +1,7 @@
|
||||
pub(super) use super::*;
|
||||
|
||||
mod bt_status_and_magnet;
|
||||
mod dht_runtime;
|
||||
mod extensions_and_multicall;
|
||||
mod peer_wire_runtime;
|
||||
mod tracker_and_bridges;
|
||||
+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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
//! Method validation and compatibility-oriented RPC handler stubs.
|
||||
#![expect(
|
||||
clippy::needless_pass_by_value,
|
||||
reason = "handler signatures intentionally mirror transport payloads and centralized compat wording"
|
||||
)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aria2_rust_pro_compat::version_line;
|
||||
|
||||
use crate::{
|
||||
jsonrpc::{
|
||||
JsonRpcNotification, JsonRpcRequest, SYNTHETIC_INVALID_PARAMS_METHOD,
|
||||
SYNTHETIC_INVALID_REQUEST_METHOD,
|
||||
},
|
||||
methods::{
|
||||
RpcMethod, is_required_rpc_method, rpc_method, rpc_method_names, rpc_notification_names,
|
||||
},
|
||||
model::{RpcError, RpcErrorCode, RpcErrorKind, RpcMeta, RpcResultEnvelope, RpcValue},
|
||||
xmlrpc::{
|
||||
XmlRpcFault, XmlRpcMethodCall, XmlRpcMethodResponse, rpc_value_to_xmlrpc,
|
||||
xmlrpc_value_to_rpc,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
/// Context passed into handler execution.
|
||||
pub struct RpcHandlerContext {
|
||||
/// Authentication state derived from the active transport.
|
||||
pub auth: crate::model::RpcAuthContext,
|
||||
/// Per-request metadata propagated through the stack.
|
||||
pub meta: RpcMeta,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
/// Registry of compatibility-oriented RPC handlers.
|
||||
pub struct RpcHandlerRegistry;
|
||||
|
||||
impl RpcHandlerRegistry {
|
||||
#[must_use]
|
||||
/// Handles a JSON-RPC request and returns a normalized envelope.
|
||||
pub fn handle_json(self, request: JsonRpcRequest, ctx: RpcHandlerContext) -> RpcResultEnvelope {
|
||||
let _ = self;
|
||||
let _ = ctx;
|
||||
let synthetic_error_message = request
|
||||
.params
|
||||
.first()
|
||||
.and_then(|value| match value {
|
||||
RpcValue::String(message) => Some(message.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or("Invalid Request.");
|
||||
match request.method.as_str() {
|
||||
SYNTHETIC_INVALID_REQUEST_METHOD => RpcResultEnvelope {
|
||||
result: None,
|
||||
error: Some(RpcError {
|
||||
code: RpcErrorCode::InvalidRequest,
|
||||
kind: RpcErrorKind::InvalidParams,
|
||||
message: synthetic_error_message.to_owned(),
|
||||
}),
|
||||
},
|
||||
SYNTHETIC_INVALID_PARAMS_METHOD => RpcResultEnvelope {
|
||||
result: None,
|
||||
error: Some(RpcError::invalid_params(synthetic_error_message)),
|
||||
},
|
||||
_ => Self::handle_method_request(request),
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes a resolved method name through the compatibility stub table.
|
||||
fn handle_method_request(request: JsonRpcRequest) -> RpcResultEnvelope {
|
||||
let Some(method) = rpc_method(&request.method) else {
|
||||
return unknown_or_stubbed_method(&request.method);
|
||||
};
|
||||
|
||||
if let Some(error) = validate_method_params(method, &request.params) {
|
||||
return error_envelope(error);
|
||||
}
|
||||
|
||||
compatibility_response(method)
|
||||
}
|
||||
|
||||
/// Handles a JSON-RPC notification.
|
||||
pub fn handle_notification(self, _notification: JsonRpcNotification, _ctx: RpcHandlerContext) {
|
||||
let _ = self;
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Handles an XML-RPC method call by reusing the JSON handler path.
|
||||
pub fn handle_xml(
|
||||
self,
|
||||
request: XmlRpcMethodCall,
|
||||
ctx: RpcHandlerContext,
|
||||
) -> XmlRpcMethodResponse {
|
||||
let meta = ctx.meta.clone();
|
||||
let envelope = self.handle_json(
|
||||
JsonRpcRequest {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id: None,
|
||||
method: request.method_name,
|
||||
params: request
|
||||
.params
|
||||
.into_iter()
|
||||
.map(|p| xmlrpc_value_to_rpc(p.value))
|
||||
.collect(),
|
||||
meta,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
match envelope.error {
|
||||
Some(error) => XmlRpcMethodResponse {
|
||||
value: None,
|
||||
fault: Some(XmlRpcFault {
|
||||
code: 1,
|
||||
message: error.message.clone(),
|
||||
error: Some(error),
|
||||
}),
|
||||
meta: RpcMeta::default(),
|
||||
},
|
||||
None => XmlRpcMethodResponse {
|
||||
value: envelope.result.map(rpc_value_to_xmlrpc),
|
||||
fault: None,
|
||||
meta: RpcMeta::default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Produces the canned compatibility response for a resolved RPC method.
|
||||
fn compatibility_response(method: RpcMethod) -> RpcResultEnvelope {
|
||||
match method {
|
||||
RpcMethod::Aria2AddUri
|
||||
| RpcMethod::Aria2AddTorrent
|
||||
| RpcMethod::Aria2AddMetalink
|
||||
| RpcMethod::Aria2ChangeGlobalOption
|
||||
| RpcMethod::Aria2ChangeOption
|
||||
| RpcMethod::Aria2SaveSession
|
||||
| RpcMethod::Aria2Shutdown
|
||||
| RpcMethod::Aria2ForceShutdown => success_envelope(RpcValue::Null),
|
||||
RpcMethod::Aria2Remove
|
||||
| RpcMethod::Aria2ForceRemove
|
||||
| RpcMethod::Aria2Pause
|
||||
| RpcMethod::Aria2PauseAll
|
||||
| RpcMethod::Aria2ForcePause
|
||||
| RpcMethod::Aria2ForcePauseAll
|
||||
| RpcMethod::Aria2Unpause
|
||||
| RpcMethod::Aria2UnpauseAll => success_envelope(RpcValue::Bool(true)),
|
||||
RpcMethod::Aria2TellStatus
|
||||
| RpcMethod::Aria2TellGlobalStat
|
||||
| RpcMethod::Aria2GetGlobalOption
|
||||
| RpcMethod::Aria2GetOption
|
||||
| RpcMethod::Aria2GetSessionInfo => success_envelope(RpcValue::Object(BTreeMap::new())),
|
||||
RpcMethod::Aria2TellActive
|
||||
| RpcMethod::Aria2TellWaiting
|
||||
| RpcMethod::Aria2TellStopped
|
||||
| RpcMethod::Aria2GetUris
|
||||
| RpcMethod::Aria2GetFiles
|
||||
| RpcMethod::Aria2GetPeers
|
||||
| RpcMethod::Aria2GetServers
|
||||
| RpcMethod::SystemMulticall => success_envelope(RpcValue::Array(Vec::new())),
|
||||
RpcMethod::Aria2ChangeUri => success_envelope(RpcValue::Array(vec![
|
||||
RpcValue::Number(0),
|
||||
RpcValue::Number(0),
|
||||
])),
|
||||
RpcMethod::Aria2GetVersion => success_envelope(RpcValue::Object(BTreeMap::from([
|
||||
("version".to_owned(), RpcValue::String(version_line())),
|
||||
("rpcVersion".to_owned(), RpcValue::String("2.0".to_owned())),
|
||||
]))),
|
||||
RpcMethod::SystemListMethods => success_envelope(RpcValue::Array(
|
||||
rpc_method_names()
|
||||
.into_iter()
|
||||
.map(|name| RpcValue::String(name.to_owned()))
|
||||
.collect(),
|
||||
)),
|
||||
RpcMethod::SystemListNotifications => success_envelope(RpcValue::Array(
|
||||
rpc_notification_names()
|
||||
.into_iter()
|
||||
.map(|name| RpcValue::String(name.to_owned()))
|
||||
.collect(),
|
||||
)),
|
||||
_ => unknown_or_stubbed_method(method.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a successful result payload in a normalized handler envelope.
|
||||
const fn success_envelope(result: RpcValue) -> RpcResultEnvelope {
|
||||
RpcResultEnvelope {
|
||||
result: Some(result),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps an error payload in a normalized handler envelope.
|
||||
const fn error_envelope(error: RpcError) -> RpcResultEnvelope {
|
||||
RpcResultEnvelope {
|
||||
result: None,
|
||||
error: Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Distinguishes required-but-stubbed methods from truly unknown compatibility names.
|
||||
fn unknown_or_stubbed_method(method: &str) -> RpcResultEnvelope {
|
||||
if is_required_rpc_method(method) {
|
||||
error_envelope(RpcError::unsupported("rpc method stubbed"))
|
||||
} else {
|
||||
error_envelope(RpcError::unknown_method(method))
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates the documented positional parameter contract for compatibility methods.
|
||||
fn validate_method_params(method: RpcMethod, params: &[RpcValue]) -> Option<RpcError> {
|
||||
match method {
|
||||
RpcMethod::SystemListMethods if !params.is_empty() => Some(RpcError::invalid_params(
|
||||
"system.listMethods takes no parameters",
|
||||
)),
|
||||
RpcMethod::SystemListNotifications if !params.is_empty() => Some(RpcError::invalid_params(
|
||||
"system.listNotifications takes no parameters",
|
||||
)),
|
||||
RpcMethod::Aria2GetVersion if !params.is_empty() => Some(RpcError::invalid_params(
|
||||
"aria2.getVersion takes no parameters",
|
||||
)),
|
||||
RpcMethod::Aria2GetSessionInfo if !params.is_empty() => Some(RpcError::invalid_params(
|
||||
"aria2.getSessionInfo takes no parameters",
|
||||
)),
|
||||
RpcMethod::Aria2TellGlobalStat if !params.is_empty() => Some(RpcError::invalid_params(
|
||||
"aria2.getGlobalStat takes no parameters",
|
||||
)),
|
||||
RpcMethod::SystemMulticall if params.is_empty() => Some(RpcError::invalid_params(
|
||||
"system.multicall requires method specs",
|
||||
)),
|
||||
RpcMethod::SystemMulticall if !matches!(params.first(), Some(RpcValue::Array(_))) => Some(
|
||||
RpcError::invalid_params("system.multicall expected array of method specs"),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Unit tests for transport-neutral RPC handler behavior.
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
model::RpcAuthContext,
|
||||
xmlrpc::{XmlRpcMember, XmlRpcParam, XmlRpcValue, xmlrpc_value_to_rpc},
|
||||
};
|
||||
|
||||
/// Builds a default handler context for unit tests.
|
||||
fn ctx() -> RpcHandlerContext {
|
||||
RpcHandlerContext {
|
||||
auth: RpcAuthContext::default(),
|
||||
meta: RpcMeta::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that unknown `XML-RPC` methods return a shared fault payload.
|
||||
fn handle_xml_unknown_method_returns_fault_with_error_payload() {
|
||||
let registry = RpcHandlerRegistry;
|
||||
let response = registry.handle_xml(
|
||||
XmlRpcMethodCall {
|
||||
method_name: "aria2.notFound".to_owned(),
|
||||
params: Vec::new(),
|
||||
meta: RpcMeta::default(),
|
||||
},
|
||||
ctx(),
|
||||
);
|
||||
|
||||
assert!(response.value.is_none());
|
||||
let fault = response.fault.expect("expected fault");
|
||||
assert_eq!(fault.code, 1);
|
||||
let expected = RpcError::unknown_method("aria2.notFound");
|
||||
assert_eq!(fault.message, expected.message);
|
||||
assert_eq!(fault.error, Some(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that nested `XML-RPC` values convert with the shared compatibility rules.
|
||||
fn xml_param_conversion_handles_nested_values_with_shared_rules() {
|
||||
let nested = XmlRpcValue::Struct(vec![XmlRpcMember {
|
||||
name: "outer".to_owned(),
|
||||
value: XmlRpcValue::Array(vec![
|
||||
XmlRpcValue::Double(3.25),
|
||||
XmlRpcValue::Base64(vec![0x41, 0x42]),
|
||||
XmlRpcValue::Struct(vec![XmlRpcMember {
|
||||
name: "inner".to_owned(),
|
||||
value: XmlRpcValue::Bool(true),
|
||||
}]),
|
||||
]),
|
||||
}]);
|
||||
|
||||
let expected = RpcValue::Object(BTreeMap::from([(
|
||||
"outer".to_owned(),
|
||||
RpcValue::Array(vec![
|
||||
RpcValue::String("3.25".to_owned()),
|
||||
RpcValue::String("QUI=".to_owned()),
|
||||
RpcValue::Object(BTreeMap::from([("inner".to_owned(), RpcValue::Bool(true))])),
|
||||
]),
|
||||
)]));
|
||||
|
||||
assert_eq!(xmlrpc_value_to_rpc(nested), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that nested array and struct parameters are accepted for `XML-RPC` addUri.
|
||||
fn handle_xml_accepts_nested_struct_and_array_params() {
|
||||
let registry = RpcHandlerRegistry;
|
||||
let response = registry.handle_xml(
|
||||
XmlRpcMethodCall {
|
||||
method_name: "aria2.addUri".to_owned(),
|
||||
params: vec![XmlRpcParam {
|
||||
value: XmlRpcValue::Array(vec![XmlRpcValue::Struct(vec![XmlRpcMember {
|
||||
name: "k".to_owned(),
|
||||
value: XmlRpcValue::Array(vec![XmlRpcValue::Int(1), XmlRpcValue::Nil]),
|
||||
}])]),
|
||||
}],
|
||||
meta: RpcMeta::default(),
|
||||
},
|
||||
ctx(),
|
||||
);
|
||||
|
||||
assert!(response.fault.is_none());
|
||||
assert!(matches!(response.value, Some(XmlRpcValue::Nil)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that `system.listMethods` keeps the upstream public method ordering.
|
||||
fn handle_json_list_methods_matches_upstream_order() {
|
||||
let registry = RpcHandlerRegistry;
|
||||
let response = registry.handle_json(
|
||||
JsonRpcRequest {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id: None,
|
||||
method: "system.listMethods".to_owned(),
|
||||
params: Vec::new(),
|
||||
meta: RpcMeta::default(),
|
||||
},
|
||||
ctx(),
|
||||
);
|
||||
|
||||
match response.result {
|
||||
Some(RpcValue::Array(items)) => {
|
||||
let names = items
|
||||
.into_iter()
|
||||
.map(|item| match item {
|
||||
RpcValue::String(name) => name,
|
||||
other => panic!("unexpected method item: {other:?}"),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"aria2.addUri".to_owned(),
|
||||
"aria2.addTorrent".to_owned(),
|
||||
"aria2.getPeers".to_owned(),
|
||||
"aria2.addMetalink".to_owned(),
|
||||
"aria2.remove".to_owned(),
|
||||
"aria2.pause".to_owned(),
|
||||
"aria2.forcePause".to_owned(),
|
||||
"aria2.pauseAll".to_owned(),
|
||||
"aria2.forcePauseAll".to_owned(),
|
||||
"aria2.unpause".to_owned(),
|
||||
"aria2.unpauseAll".to_owned(),
|
||||
"aria2.forceRemove".to_owned(),
|
||||
"aria2.changePosition".to_owned(),
|
||||
"aria2.tellStatus".to_owned(),
|
||||
"aria2.getUris".to_owned(),
|
||||
"aria2.getFiles".to_owned(),
|
||||
"aria2.getServers".to_owned(),
|
||||
"aria2.tellActive".to_owned(),
|
||||
"aria2.tellWaiting".to_owned(),
|
||||
"aria2.tellStopped".to_owned(),
|
||||
"aria2.getOption".to_owned(),
|
||||
"aria2.changeUri".to_owned(),
|
||||
"aria2.changeOption".to_owned(),
|
||||
"aria2.getGlobalOption".to_owned(),
|
||||
"aria2.changeGlobalOption".to_owned(),
|
||||
"aria2.purgeDownloadResult".to_owned(),
|
||||
"aria2.removeDownloadResult".to_owned(),
|
||||
"aria2.getVersion".to_owned(),
|
||||
"aria2.getSessionInfo".to_owned(),
|
||||
"aria2.shutdown".to_owned(),
|
||||
"aria2.forceShutdown".to_owned(),
|
||||
"aria2.getGlobalStat".to_owned(),
|
||||
"aria2.saveSession".to_owned(),
|
||||
"system.multicall".to_owned(),
|
||||
"system.listMethods".to_owned(),
|
||||
"system.listNotifications".to_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected listMethods result: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that `system.listNotifications` returns the upstream aria2 names.
|
||||
fn handle_json_list_notifications_matches_upstream_aria2_names() {
|
||||
let registry = RpcHandlerRegistry;
|
||||
let response = registry.handle_json(
|
||||
JsonRpcRequest {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id: None,
|
||||
method: "system.listNotifications".to_owned(),
|
||||
params: Vec::new(),
|
||||
meta: RpcMeta::default(),
|
||||
},
|
||||
ctx(),
|
||||
);
|
||||
|
||||
match response.result {
|
||||
Some(RpcValue::Array(items)) => {
|
||||
let names = items
|
||||
.into_iter()
|
||||
.map(|item| match item {
|
||||
RpcValue::String(name) => name,
|
||||
other => panic!("unexpected notification item: {other:?}"),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"aria2.onDownloadStart".to_owned(),
|
||||
"aria2.onDownloadPause".to_owned(),
|
||||
"aria2.onDownloadStop".to_owned(),
|
||||
"aria2.onDownloadComplete".to_owned(),
|
||||
"aria2.onDownloadError".to_owned(),
|
||||
"aria2.onBtDownloadComplete".to_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected listNotifications result: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that `system.listMethods` rejects unexpected parameters.
|
||||
fn handle_json_list_methods_rejects_unexpected_params() {
|
||||
let registry = RpcHandlerRegistry;
|
||||
let response = registry.handle_json(
|
||||
JsonRpcRequest {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id: None,
|
||||
method: "system.listMethods".to_owned(),
|
||||
params: vec![RpcValue::Bool(true)],
|
||||
meta: RpcMeta::default(),
|
||||
},
|
||||
ctx(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
response.error,
|
||||
Some(RpcError::invalid_params(
|
||||
"system.listMethods takes no parameters"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that `system.listNotifications` rejects unexpected parameters.
|
||||
fn handle_json_list_notifications_rejects_unexpected_params() {
|
||||
let registry = RpcHandlerRegistry;
|
||||
let response = registry.handle_json(
|
||||
JsonRpcRequest {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id: None,
|
||||
method: "system.listNotifications".to_owned(),
|
||||
params: vec![RpcValue::Bool(true)],
|
||||
meta: RpcMeta::default(),
|
||||
},
|
||||
ctx(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
response.error,
|
||||
Some(RpcError::invalid_params(
|
||||
"system.listNotifications takes no parameters"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that `aria2.getVersion` rejects unexpected parameters.
|
||||
fn handle_json_get_version_rejects_unexpected_params() {
|
||||
let registry = RpcHandlerRegistry;
|
||||
let response = registry.handle_json(
|
||||
JsonRpcRequest {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id: None,
|
||||
method: "aria2.getVersion".to_owned(),
|
||||
params: vec![RpcValue::String("token:abc".to_owned())],
|
||||
meta: RpcMeta::default(),
|
||||
},
|
||||
ctx(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
response.error,
|
||||
Some(RpcError::invalid_params(
|
||||
"aria2.getVersion takes no parameters"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that `system.multicall` rejects a missing method-spec array.
|
||||
fn handle_json_multicall_rejects_missing_method_specs() {
|
||||
let registry = RpcHandlerRegistry;
|
||||
let response = registry.handle_json(
|
||||
JsonRpcRequest {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id: None,
|
||||
method: "system.multicall".to_owned(),
|
||||
params: Vec::new(),
|
||||
meta: RpcMeta::default(),
|
||||
},
|
||||
ctx(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
response.error,
|
||||
Some(RpcError::invalid_params(
|
||||
"system.multicall requires method specs"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that the legacy multicall alias still enforces array-shaped method specs.
|
||||
fn handle_json_legacy_multicall_alias_rejects_non_array_method_specs() {
|
||||
let registry = RpcHandlerRegistry;
|
||||
let response = registry.handle_json(
|
||||
JsonRpcRequest {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id: None,
|
||||
method: "aria2.multicall".to_owned(),
|
||||
params: vec![RpcValue::Bool(true)],
|
||||
meta: RpcMeta::default(),
|
||||
},
|
||||
ctx(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
response.error,
|
||||
Some(RpcError::invalid_params(
|
||||
"system.multicall expected array of method specs"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that the synthetic invalid-request marker becomes a `JSON-RPC` invalid-request error.
|
||||
fn handle_json_synthetic_invalid_request_returns_jsonrpc_invalid_request_error() {
|
||||
let registry = RpcHandlerRegistry;
|
||||
let response = registry.handle_json(
|
||||
JsonRpcRequest {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id: Some(crate::jsonrpc::JsonRpcId::Null),
|
||||
method: SYNTHETIC_INVALID_REQUEST_METHOD.to_owned(),
|
||||
params: vec![RpcValue::String("Invalid Request.".to_owned())],
|
||||
meta: RpcMeta::default(),
|
||||
},
|
||||
ctx(),
|
||||
);
|
||||
|
||||
assert!(response.result.is_none());
|
||||
assert_eq!(
|
||||
response.error,
|
||||
Some(RpcError {
|
||||
code: RpcErrorCode::InvalidRequest,
|
||||
kind: RpcErrorKind::InvalidParams,
|
||||
message: "Invalid Request.".to_owned(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that the synthetic invalid-params marker becomes a `JSON-RPC` invalid-params error.
|
||||
fn handle_json_synthetic_invalid_params_returns_jsonrpc_invalid_params_error() {
|
||||
let registry = RpcHandlerRegistry;
|
||||
let response = registry.handle_json(
|
||||
JsonRpcRequest {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id: Some(crate::jsonrpc::JsonRpcId::String("q2".to_owned())),
|
||||
method: SYNTHETIC_INVALID_PARAMS_METHOD.to_owned(),
|
||||
params: vec![RpcValue::String("Invalid params.".to_owned())],
|
||||
meta: RpcMeta::default(),
|
||||
},
|
||||
ctx(),
|
||||
);
|
||||
|
||||
assert!(response.result.is_none());
|
||||
assert_eq!(
|
||||
response.error,
|
||||
Some(RpcError {
|
||||
code: RpcErrorCode::InvalidParams,
|
||||
kind: RpcErrorKind::InvalidParams,
|
||||
message: "Invalid params.".to_owned(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that `XML-RPC` listNotifications returns the prefixed aria2 event names.
|
||||
fn handle_xml_list_notifications_returns_prefixed_names() {
|
||||
let registry = RpcHandlerRegistry;
|
||||
let response = registry.handle_xml(
|
||||
XmlRpcMethodCall {
|
||||
method_name: "system.listNotifications".to_owned(),
|
||||
params: Vec::new(),
|
||||
meta: RpcMeta::default(),
|
||||
},
|
||||
ctx(),
|
||||
);
|
||||
|
||||
assert!(response.fault.is_none());
|
||||
match response.value {
|
||||
Some(XmlRpcValue::Array(items)) => {
|
||||
let names = items
|
||||
.into_iter()
|
||||
.map(|item| match item {
|
||||
XmlRpcValue::String(name) => name,
|
||||
other => panic!("unexpected XML notification item: {other:?}"),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"aria2.onDownloadStart".to_owned(),
|
||||
"aria2.onDownloadPause".to_owned(),
|
||||
"aria2.onDownloadStop".to_owned(),
|
||||
"aria2.onDownloadComplete".to_owned(),
|
||||
"aria2.onDownloadError".to_owned(),
|
||||
"aria2.onBtDownloadComplete".to_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected XML listNotifications result: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
//! JSON-RPC request and response types plus serde helpers.
|
||||
#![expect(
|
||||
clippy::redundant_pub_crate,
|
||||
reason = "JSON-RPC model type names stay intentionally explicit for transport parity"
|
||||
)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Map, Number, Value};
|
||||
|
||||
use crate::model::{RpcError, RpcMeta, RpcValue};
|
||||
|
||||
/// Synthetic method marker used when malformed payloads need an invalid-request envelope.
|
||||
pub(super) const SYNTHETIC_INVALID_REQUEST_METHOD: &str = "__aria2_rust_pro.invalid_request__";
|
||||
/// Synthetic method marker used when malformed payloads need an invalid-params envelope.
|
||||
pub(super) const SYNTHETIC_INVALID_PARAMS_METHOD: &str = "__aria2_rust_pro.invalid_params__";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// JSON-RPC id values accepted by the compatibility surface.
|
||||
pub enum JsonRpcId {
|
||||
/// Explicit JSON `null` id.
|
||||
Null,
|
||||
/// Boolean id.
|
||||
Bool(bool),
|
||||
/// Integer id.
|
||||
Number(i64),
|
||||
/// String id.
|
||||
String(String),
|
||||
/// Structured array id retained for compatibility.
|
||||
Array(Vec<RpcValue>),
|
||||
/// Structured object id retained for compatibility.
|
||||
Object(BTreeMap<String, RpcValue>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// Canonical JSON-RPC request model used by the dispatcher.
|
||||
pub struct JsonRpcRequest {
|
||||
/// Advertised JSON-RPC version.
|
||||
pub jsonrpc: Option<String>,
|
||||
/// Request identifier, if one was provided.
|
||||
pub id: Option<JsonRpcId>,
|
||||
/// Requested method name.
|
||||
pub method: String,
|
||||
/// Positional parameters.
|
||||
pub params: Vec<RpcValue>,
|
||||
/// Supplemental per-request metadata.
|
||||
pub meta: RpcMeta,
|
||||
}
|
||||
|
||||
impl JsonRpcRequest {
|
||||
#[must_use]
|
||||
/// Creates an empty JSON-RPC 2.0 request for the given method name.
|
||||
pub fn new(method: impl Into<String>) -> Self {
|
||||
Self {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id: None,
|
||||
method: method.into(),
|
||||
params: Vec::new(),
|
||||
meta: RpcMeta::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// Canonical JSON-RPC notification model.
|
||||
pub struct JsonRpcNotification {
|
||||
/// Advertised JSON-RPC version.
|
||||
pub jsonrpc: Option<String>,
|
||||
/// Notification method name.
|
||||
pub method: String,
|
||||
/// Positional parameters.
|
||||
pub params: Vec<RpcValue>,
|
||||
/// Supplemental per-request metadata.
|
||||
pub meta: RpcMeta,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// Canonical JSON-RPC response model.
|
||||
pub struct JsonRpcResponse {
|
||||
/// Advertised JSON-RPC version.
|
||||
pub jsonrpc: Option<String>,
|
||||
/// Echoed request identifier.
|
||||
pub id: Option<JsonRpcId>,
|
||||
/// Successful result payload, if any.
|
||||
pub result: Option<RpcValue>,
|
||||
/// Error payload, if the request failed.
|
||||
pub error: Option<RpcError>,
|
||||
/// Supplemental metadata attached to the response.
|
||||
pub meta: RpcMeta,
|
||||
}
|
||||
|
||||
impl JsonRpcResponse {
|
||||
#[must_use]
|
||||
/// Creates a successful JSON-RPC response.
|
||||
pub fn success(id: Option<JsonRpcId>, value: RpcValue) -> Self {
|
||||
Self {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id,
|
||||
result: Some(value),
|
||||
error: None,
|
||||
meta: RpcMeta::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Creates an error JSON-RPC response.
|
||||
pub fn error(id: Option<JsonRpcId>, error: RpcError) -> Self {
|
||||
Self {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id,
|
||||
result: None,
|
||||
error: Some(error),
|
||||
meta: RpcMeta::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// Parsed top-level JSON-RPC payload.
|
||||
pub(super) enum JsonRpcPayload {
|
||||
/// Single request payload.
|
||||
Single(JsonRpcRequest),
|
||||
/// Batch payload retaining per-item parse failures.
|
||||
Batch(Vec<Result<JsonRpcRequest, String>>),
|
||||
}
|
||||
|
||||
/// Converts a transport-neutral RPC value into a `serde_json::Value`.
|
||||
fn rpc_value_to_json(value: &RpcValue) -> Value {
|
||||
match value {
|
||||
RpcValue::Null => Value::Null,
|
||||
RpcValue::Bool(value) => Value::Bool(*value),
|
||||
RpcValue::Number(value) => Value::Number(Number::from(*value)),
|
||||
RpcValue::String(value) => Value::String(value.clone()),
|
||||
RpcValue::Array(values) => Value::Array(values.iter().map(rpc_value_to_json).collect()),
|
||||
RpcValue::Object(values) => Value::Object(
|
||||
values
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), rpc_value_to_json(value)))
|
||||
.collect::<Map<String, Value>>(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a parsed JSON value into the transport-neutral RPC value tree.
|
||||
fn json_value_to_rpc(value: Value) -> RpcValue {
|
||||
match value {
|
||||
Value::Null => RpcValue::Null,
|
||||
Value::Bool(value) => RpcValue::Bool(value),
|
||||
Value::Number(value) => RpcValue::Number(value.as_i64().unwrap_or_default()),
|
||||
Value::String(value) => RpcValue::String(value),
|
||||
Value::Array(values) => {
|
||||
RpcValue::Array(values.into_iter().map(json_value_to_rpc).collect())
|
||||
}
|
||||
Value::Object(values) => RpcValue::Object(
|
||||
values
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key, json_value_to_rpc(value)))
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an `id` member into the compatibility-layer `JsonRpcId` shape.
|
||||
fn parse_jsonrpc_id(value: &Value) -> Option<JsonRpcId> {
|
||||
match value {
|
||||
Value::Null => Some(JsonRpcId::Null),
|
||||
Value::Bool(value) => Some(JsonRpcId::Bool(*value)),
|
||||
Value::Number(value) => value.as_i64().map(JsonRpcId::Number),
|
||||
Value::String(value) => Some(JsonRpcId::String(value.clone())),
|
||||
Value::Array(values) => Some(JsonRpcId::Array(
|
||||
values.iter().cloned().map(json_value_to_rpc).collect(),
|
||||
)),
|
||||
Value::Object(values) => Some(JsonRpcId::Object(
|
||||
values
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), json_value_to_rpc(value.clone())))
|
||||
.collect(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts an internal JSON-RPC id back into `serde_json` form for rendering.
|
||||
fn jsonrpc_id_to_json(id: &JsonRpcId) -> Value {
|
||||
match id {
|
||||
JsonRpcId::Null => Value::Null,
|
||||
JsonRpcId::Bool(value) => Value::Bool(*value),
|
||||
JsonRpcId::Number(value) => Value::Number(Number::from(*value)),
|
||||
JsonRpcId::String(value) => Value::String(value.clone()),
|
||||
JsonRpcId::Array(values) => Value::Array(values.iter().map(rpc_value_to_json).collect()),
|
||||
JsonRpcId::Object(values) => Value::Object(
|
||||
values
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), rpc_value_to_json(value)))
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a synthetic request carrying a parse-time protocol error.
|
||||
fn synthetic_error_request(
|
||||
method: &'static str,
|
||||
id: Option<JsonRpcId>,
|
||||
message: impl Into<String>,
|
||||
jsonrpc: Option<String>,
|
||||
) -> JsonRpcRequest {
|
||||
JsonRpcRequest {
|
||||
jsonrpc,
|
||||
id,
|
||||
method: method.to_owned(),
|
||||
params: vec![RpcValue::String(message.into())],
|
||||
meta: RpcMeta::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the canonical invalid-request synthetic request wrapper.
|
||||
fn invalid_request(id: Option<JsonRpcId>, jsonrpc: Option<String>) -> JsonRpcRequest {
|
||||
synthetic_error_request(
|
||||
SYNTHETIC_INVALID_REQUEST_METHOD,
|
||||
id,
|
||||
"Invalid Request.",
|
||||
jsonrpc,
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds the canonical invalid-parameters synthetic request wrapper.
|
||||
fn invalid_params(id: Option<JsonRpcId>, jsonrpc: Option<String>) -> JsonRpcRequest {
|
||||
synthetic_error_request(
|
||||
SYNTHETIC_INVALID_PARAMS_METHOD,
|
||||
id,
|
||||
"Invalid params.",
|
||||
jsonrpc,
|
||||
)
|
||||
}
|
||||
|
||||
/// Parses a request object while normalizing malformed members into synthetic requests.
|
||||
fn jsonrpc_request_from_object(mut object: Map<String, Value>) -> JsonRpcRequest {
|
||||
let has_id = object.contains_key("id");
|
||||
let id = object.get("id").and_then(parse_jsonrpc_id);
|
||||
let jsonrpc = object
|
||||
.get("jsonrpc")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
if !has_id {
|
||||
return invalid_request(Some(JsonRpcId::Null), jsonrpc);
|
||||
}
|
||||
let Some(id) = id else {
|
||||
return invalid_request(Some(JsonRpcId::Null), jsonrpc);
|
||||
};
|
||||
let method = object
|
||||
.get("method")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
let Some(method) = method else {
|
||||
return invalid_request(Some(id), jsonrpc);
|
||||
};
|
||||
let params = match object.remove("params") {
|
||||
Some(Value::Array(values)) => values.into_iter().map(json_value_to_rpc).collect(),
|
||||
Some(_) => return invalid_params(Some(id), jsonrpc),
|
||||
None => Vec::new(),
|
||||
};
|
||||
JsonRpcRequest {
|
||||
jsonrpc,
|
||||
id: Some(id),
|
||||
method,
|
||||
params,
|
||||
meta: RpcMeta::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalizes any JSON value into a request-shaped compatibility payload.
|
||||
fn jsonrpc_request_from_value(value: Value) -> JsonRpcRequest {
|
||||
match value {
|
||||
Value::Object(object) => jsonrpc_request_from_object(object),
|
||||
_ => invalid_request(Some(JsonRpcId::Null), Some("2.0".to_owned())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a single JSON-RPC request from raw JSON text.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `json` is not valid JSON text.
|
||||
pub fn jsonrpc_request_from_json(json: &str) -> Result<JsonRpcRequest, String> {
|
||||
let value: Value = serde_json::from_str(json).map_err(|error| error.to_string())?;
|
||||
Ok(jsonrpc_request_from_value(value))
|
||||
}
|
||||
|
||||
/// Parses either a single or batch JSON-RPC payload from raw JSON text.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `json` is not valid JSON text.
|
||||
pub(super) fn jsonrpc_payload_from_json(json: &str) -> Result<JsonRpcPayload, String> {
|
||||
let value: Value = serde_json::from_str(json).map_err(|error| error.to_string())?;
|
||||
match value {
|
||||
Value::Array(items) => Ok(JsonRpcPayload::Batch(
|
||||
items
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
Value::Object(object) => Some(Ok(jsonrpc_request_from_object(object))),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
)),
|
||||
other => Ok(JsonRpcPayload::Single(jsonrpc_request_from_value(other))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a JSON-RPC response to a compact JSON string.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the response cannot be serialized to JSON text.
|
||||
pub(super) fn jsonrpc_response_to_json(response: &JsonRpcResponse) -> Result<String, String> {
|
||||
serde_json::to_string(&jsonrpc_response_to_value(response)).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Renders a JSON-RPC response to a `serde_json` value without a text round-trip.
|
||||
fn jsonrpc_response_to_value(response: &JsonRpcResponse) -> Value {
|
||||
let mut object = Map::new();
|
||||
object.insert(
|
||||
"jsonrpc".to_owned(),
|
||||
Value::String(response.jsonrpc.clone().unwrap_or_else(|| "2.0".to_owned())),
|
||||
);
|
||||
object.insert(
|
||||
"id".to_owned(),
|
||||
response.id.as_ref().map_or(Value::Null, jsonrpc_id_to_json),
|
||||
);
|
||||
if let Some(result) = &response.result {
|
||||
object.insert("result".to_owned(), rpc_value_to_json(result));
|
||||
}
|
||||
if let Some(error) = &response.error {
|
||||
object.insert(
|
||||
"error".to_owned(),
|
||||
Value::Object(Map::from_iter([
|
||||
(
|
||||
"code".to_owned(),
|
||||
Value::Number(Number::from(match error.code {
|
||||
crate::model::RpcErrorCode::ParseError => -32700_i32,
|
||||
crate::model::RpcErrorCode::InvalidRequest => -32600_i32,
|
||||
crate::model::RpcErrorCode::MethodNotFound => -32601_i32,
|
||||
crate::model::RpcErrorCode::InvalidParams => -32602_i32,
|
||||
crate::model::RpcErrorCode::InternalError => -32603_i32,
|
||||
crate::model::RpcErrorCode::ApplicationError => -32000_i32,
|
||||
})),
|
||||
),
|
||||
("message".to_owned(), Value::String(error.message.clone())),
|
||||
])),
|
||||
);
|
||||
}
|
||||
Value::Object(object)
|
||||
}
|
||||
|
||||
/// Renders a batch of JSON-RPC responses to a compact JSON array string.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when any response cannot be serialized to JSON text.
|
||||
pub(super) fn jsonrpc_batch_response_to_json(
|
||||
responses: &[JsonRpcResponse],
|
||||
) -> Result<String, String> {
|
||||
let rendered = responses
|
||||
.iter()
|
||||
.map(jsonrpc_response_to_value)
|
||||
.collect::<Vec<_>>();
|
||||
serde_json::to_string(&Value::Array(rendered)).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Unit tests for `JSON-RPC` parsing and rendering helpers.
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{RpcErrorCode, RpcErrorKind};
|
||||
|
||||
#[test]
|
||||
/// Verifies that array parameters parse into the normalized request model.
|
||||
fn parses_jsonrpc_request_with_array_params() {
|
||||
let request = jsonrpc_request_from_json(
|
||||
r#"{"jsonrpc":"2.0","id":"q1","method":"aria2.tellStatus","params":["token:abc","2089b05ecca3d829"]}"#,
|
||||
)
|
||||
.expect("request should parse");
|
||||
|
||||
assert_eq!(request.jsonrpc.as_deref(), Some("2.0"));
|
||||
assert_eq!(request.id, Some(JsonRpcId::String("q1".to_owned())));
|
||||
assert_eq!(request.method, "aria2.tellStatus");
|
||||
assert_eq!(request.params.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that boolean and structured ids round-trip into the compatibility id surface.
|
||||
fn parses_jsonrpc_request_with_boolean_and_structured_id() {
|
||||
let boolean_id = jsonrpc_request_from_json(
|
||||
r#"{"jsonrpc":"2.0","id":true,"method":"aria2.getVersion","params":[]}"#,
|
||||
)
|
||||
.expect("boolean id request should parse");
|
||||
assert_eq!(boolean_id.id, Some(JsonRpcId::Bool(true)));
|
||||
|
||||
let object_id = jsonrpc_request_from_json(
|
||||
r#"{"jsonrpc":"2.0","id":{"client":"ui","seq":3},"method":"aria2.getVersion","params":[]}"#,
|
||||
)
|
||||
.expect("object id request should parse");
|
||||
assert_eq!(
|
||||
object_id.id,
|
||||
Some(JsonRpcId::Object(BTreeMap::from([
|
||||
("client".to_owned(), RpcValue::String("ui".to_owned())),
|
||||
("seq".to_owned(), RpcValue::Number(3)),
|
||||
])))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that a missing request id becomes the synthetic invalid-request wrapper.
|
||||
fn missing_jsonrpc_id_becomes_synthetic_invalid_request() {
|
||||
let request = jsonrpc_request_from_json(
|
||||
r#"{"jsonrpc":"2.0","method":"aria2.getVersion","params":[]}"#,
|
||||
)
|
||||
.expect("missing id request should still parse");
|
||||
|
||||
assert_eq!(request.id, Some(JsonRpcId::Null));
|
||||
assert_eq!(request.method, SYNTHETIC_INVALID_REQUEST_METHOD);
|
||||
assert_eq!(
|
||||
request.params,
|
||||
vec![RpcValue::String("Invalid Request.".to_owned())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that named parameters become the synthetic invalid-params wrapper.
|
||||
fn named_params_become_synthetic_invalid_params_request() {
|
||||
let request = jsonrpc_request_from_json(
|
||||
r#"{"jsonrpc":"2.0","id":"q2","method":"aria2.getVersion","params":{"gid":"abc"}}"#,
|
||||
)
|
||||
.expect("named params request should still parse");
|
||||
|
||||
assert_eq!(request.id, Some(JsonRpcId::String("q2".to_owned())));
|
||||
assert_eq!(request.method, SYNTHETIC_INVALID_PARAMS_METHOD);
|
||||
assert_eq!(
|
||||
request.params,
|
||||
vec![RpcValue::String("Invalid params.".to_owned())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that error responses render into the expected compact `JSON-RPC` shape.
|
||||
fn renders_jsonrpc_error_response() {
|
||||
let json = jsonrpc_response_to_json(&JsonRpcResponse {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id: Some(JsonRpcId::Number(7)),
|
||||
result: None,
|
||||
error: Some(RpcError {
|
||||
code: RpcErrorCode::InvalidParams,
|
||||
kind: RpcErrorKind::InvalidParams,
|
||||
message: "bad params".to_owned(),
|
||||
}),
|
||||
meta: RpcMeta::default(),
|
||||
})
|
||||
.expect("response should render");
|
||||
|
||||
assert!(json.contains("\"jsonrpc\":\"2.0\""));
|
||||
assert!(json.contains("\"id\":7"));
|
||||
assert!(json.contains("\"code\":-32602"));
|
||||
assert!(json.contains("\"message\":\"bad params\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that batch payloads preserve per-item request parsing.
|
||||
fn parses_jsonrpc_batch_payload() {
|
||||
let payload = jsonrpc_payload_from_json(
|
||||
r#"[{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]},{"jsonrpc":"2.0","method":"aria2.tellActive","params":[]}]"#,
|
||||
)
|
||||
.expect("batch payload should parse");
|
||||
|
||||
match payload {
|
||||
JsonRpcPayload::Batch(items) => {
|
||||
let [first, second] = items.as_slice() else {
|
||||
panic!("expected exactly two batch items, got {}", items.len());
|
||||
};
|
||||
assert!(first.as_ref().is_ok_and(|request| request.id.is_some()));
|
||||
assert!(
|
||||
second
|
||||
.as_ref()
|
||||
.is_ok_and(|request| request.id == Some(JsonRpcId::Null))
|
||||
);
|
||||
}
|
||||
other @ JsonRpcPayload::Single(_) => {
|
||||
panic!("expected batch payload, got {other:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that an empty batch parses as an empty batch payload.
|
||||
fn parses_empty_jsonrpc_batch_payload_as_empty_batch() {
|
||||
let payload = jsonrpc_payload_from_json("[]").expect("empty batch should parse");
|
||||
assert_eq!(payload, JsonRpcPayload::Batch(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that non-object batch members are ignored like upstream aria2.
|
||||
fn batch_payload_ignores_non_object_members_like_upstream_aria2() {
|
||||
let payload = jsonrpc_payload_from_json(
|
||||
r#"[{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]},7,true]"#,
|
||||
)
|
||||
.expect("batch payload should parse");
|
||||
|
||||
match payload {
|
||||
JsonRpcPayload::Batch(items) => {
|
||||
let [first] = items.as_slice() else {
|
||||
panic!("expected exactly one batch item, got {}", items.len());
|
||||
};
|
||||
assert!(
|
||||
first
|
||||
.as_ref()
|
||||
.is_ok_and(|request| request.id == Some(JsonRpcId::Number(1)))
|
||||
);
|
||||
}
|
||||
other @ JsonRpcPayload::Single(_) => {
|
||||
panic!("expected batch payload, got {other:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that batch responses render as a compact JSON array.
|
||||
fn renders_jsonrpc_batch_response() {
|
||||
let json = jsonrpc_batch_response_to_json(&[
|
||||
JsonRpcResponse::success(
|
||||
Some(JsonRpcId::Number(1)),
|
||||
RpcValue::String("ok".to_owned()),
|
||||
),
|
||||
JsonRpcResponse::error(
|
||||
Some(JsonRpcId::Number(2)),
|
||||
RpcError {
|
||||
code: RpcErrorCode::InvalidRequest,
|
||||
kind: RpcErrorKind::InvalidParams,
|
||||
message: "bad request".to_owned(),
|
||||
},
|
||||
),
|
||||
])
|
||||
.expect("batch response should render");
|
||||
|
||||
assert!(json.starts_with('['));
|
||||
assert!(json.contains("\"id\":1"));
|
||||
assert!(json.contains("\"id\":2"));
|
||||
assert!(json.contains("\"bad request\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! RPC compatibility surface for the `aria2-rust-pro` workspace.
|
||||
//!
|
||||
//! This crate owns request parsing, transport-neutral routing, JSON-RPC and
|
||||
//! XML-RPC compatibility types, and the in-process dispatcher used by the
|
||||
//! server-facing crates.
|
||||
#![expect(
|
||||
clippy::multiple_crate_versions,
|
||||
reason = "workspace dependency resolution is shared across crates and not owned by rpc alone"
|
||||
)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(test)]
|
||||
use aria2_rust_pro_storage as _;
|
||||
|
||||
/// In-process RPC dispatcher backed by the download engine.
|
||||
pub(crate) mod dispatcher;
|
||||
/// RPC method registry and request handlers.
|
||||
pub(crate) mod handlers;
|
||||
/// JSON-RPC request and response types plus serde helpers.
|
||||
pub(crate) mod jsonrpc;
|
||||
/// Canonical RPC method metadata.
|
||||
pub(crate) mod methods;
|
||||
/// Shared RPC value, error, and metadata model types.
|
||||
pub(crate) mod model;
|
||||
/// Transport-neutral request routing helpers.
|
||||
pub(crate) mod router;
|
||||
/// Minimal HTTP and WebSocket server glue for the RPC surface.
|
||||
pub(crate) mod server;
|
||||
/// Session and authentication token state.
|
||||
pub(crate) mod session;
|
||||
/// WebSocket notification fan-out helpers.
|
||||
pub(crate) mod websocket;
|
||||
/// XML-RPC request and response types plus parser/renderer helpers.
|
||||
pub(crate) mod xmlrpc;
|
||||
|
||||
/// Re-exports of the in-process dispatcher and lightweight internal summaries.
|
||||
pub use dispatcher::{InProcessRpcDispatcher, RpcStatusSummary};
|
||||
/// Re-exports of the primary JSON-RPC message types and testable wire codecs.
|
||||
pub use jsonrpc::{
|
||||
JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, jsonrpc_request_from_json,
|
||||
};
|
||||
/// Re-exports of the RPC method catalog and lookup helpers.
|
||||
pub use methods::{
|
||||
REQUIRED_RPC_METHODS, RPC_METHOD_LEDGER, RpcMethod, is_required_rpc_method, rpc_method_names,
|
||||
};
|
||||
/// Re-exports of the shared RPC model types.
|
||||
pub use model::{
|
||||
RpcAuthContext, RpcError, RpcErrorCode, RpcErrorKind, RpcMeta, RpcOptionMap, RpcResultEnvelope,
|
||||
RpcValue,
|
||||
};
|
||||
/// Re-exports of the transport-neutral router types.
|
||||
pub use router::{RpcDispatchRequest, RpcDispatchResult, RpcRouter};
|
||||
/// Re-exports of the server configuration and entrypoints.
|
||||
pub use server::{
|
||||
RpcListenerStub, RpcServerConfig, RpcServerTransport, RpcServerTransportConfig,
|
||||
serve_rpc_listener,
|
||||
};
|
||||
/// Re-exports of RPC session state types.
|
||||
pub use session::{RpcAuthToken, RpcSession, RpcSessionInfo, RpcSessionStore};
|
||||
/// Re-exports of WebSocket notification types and registries.
|
||||
pub use websocket::{
|
||||
RpcNotificationEvent, RpcNotificationKind, RpcWebSocketFrame, WebSocketNotificationRegistry,
|
||||
WebSocketSessionRegistry, WebSocketSessionState, WebSocketSubscription,
|
||||
};
|
||||
/// Re-exports of XML-RPC model types and wire codec helpers.
|
||||
pub use xmlrpc::{
|
||||
XmlRpcFault, XmlRpcMember, XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, XmlRpcValue,
|
||||
rpc_value_to_xmlrpc, xmlrpc_method_call_from_xml, xmlrpc_method_call_to_xml,
|
||||
xmlrpc_method_response_from_xml, xmlrpc_method_response_to_xml, xmlrpc_value_to_rpc,
|
||||
};
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Canonical RPC method catalog and lookup helpers.
|
||||
#![expect(
|
||||
clippy::redundant_pub_crate,
|
||||
reason = "method-catalog helpers stay crate-internal while retaining explicit visibilities"
|
||||
)]
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::model::RpcValue;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
/// RPC methods supported or reserved by the aria2-compatible surface.
|
||||
pub enum RpcMethod {
|
||||
/// Adds one or more download URIs.
|
||||
Aria2AddUri,
|
||||
/// Adds a torrent payload.
|
||||
Aria2AddTorrent,
|
||||
/// Adds a metalink payload.
|
||||
Aria2AddMetalink,
|
||||
/// Removes a download.
|
||||
Aria2Remove,
|
||||
/// Force-removes a download.
|
||||
Aria2ForceRemove,
|
||||
/// Pauses a download.
|
||||
Aria2Pause,
|
||||
/// Pauses all active downloads.
|
||||
Aria2PauseAll,
|
||||
/// Force-pauses a download.
|
||||
Aria2ForcePause,
|
||||
/// Force-pauses all active downloads.
|
||||
Aria2ForcePauseAll,
|
||||
/// Resumes a paused download.
|
||||
Aria2Unpause,
|
||||
/// Resumes all paused downloads.
|
||||
Aria2UnpauseAll,
|
||||
/// Returns status for a single download.
|
||||
Aria2TellStatus,
|
||||
/// Returns active downloads.
|
||||
Aria2TellActive,
|
||||
/// Returns waiting downloads.
|
||||
Aria2TellWaiting,
|
||||
/// Returns stopped downloads.
|
||||
Aria2TellStopped,
|
||||
/// Returns URIs for a download.
|
||||
Aria2GetUris,
|
||||
/// Returns files for a download.
|
||||
Aria2GetFiles,
|
||||
/// Returns peers for a `BitTorrent` download.
|
||||
Aria2GetPeers,
|
||||
/// Returns servers for a download.
|
||||
Aria2GetServers,
|
||||
/// Changes queue position for a download.
|
||||
Aria2ChangePosition,
|
||||
/// Changes URIs attached to a download.
|
||||
Aria2ChangeUri,
|
||||
/// Purges completed download results.
|
||||
Aria2PurgeDownloadResult,
|
||||
/// Removes one completed download result.
|
||||
Aria2RemoveDownloadResult,
|
||||
/// Returns aggregate global statistics.
|
||||
Aria2TellGlobalStat,
|
||||
/// Returns per-download options.
|
||||
Aria2GetOption,
|
||||
/// Changes per-download options.
|
||||
Aria2ChangeOption,
|
||||
/// Returns global options.
|
||||
Aria2GetGlobalOption,
|
||||
/// Changes global options.
|
||||
Aria2ChangeGlobalOption,
|
||||
/// Returns version and enabled feature metadata.
|
||||
Aria2GetVersion,
|
||||
/// Returns session metadata.
|
||||
Aria2GetSessionInfo,
|
||||
/// Persists the current session.
|
||||
Aria2SaveSession,
|
||||
/// Requests graceful shutdown.
|
||||
Aria2Shutdown,
|
||||
/// Requests forced shutdown.
|
||||
Aria2ForceShutdown,
|
||||
/// Executes a batch of nested method calls.
|
||||
SystemMulticall,
|
||||
/// Returns the method catalog.
|
||||
SystemListMethods,
|
||||
/// Returns the notification catalog.
|
||||
SystemListNotifications,
|
||||
}
|
||||
|
||||
impl RpcMethod {
|
||||
/// Returns the canonical method name exposed on the wire.
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Aria2AddUri => "aria2.addUri",
|
||||
Self::Aria2AddTorrent => "aria2.addTorrent",
|
||||
Self::Aria2AddMetalink => "aria2.addMetalink",
|
||||
Self::Aria2Remove => "aria2.remove",
|
||||
Self::Aria2ForceRemove => "aria2.forceRemove",
|
||||
Self::Aria2Pause => "aria2.pause",
|
||||
Self::Aria2PauseAll => "aria2.pauseAll",
|
||||
Self::Aria2ForcePause => "aria2.forcePause",
|
||||
Self::Aria2ForcePauseAll => "aria2.forcePauseAll",
|
||||
Self::Aria2Unpause => "aria2.unpause",
|
||||
Self::Aria2UnpauseAll => "aria2.unpauseAll",
|
||||
Self::Aria2TellStatus => "aria2.tellStatus",
|
||||
Self::Aria2TellActive => "aria2.tellActive",
|
||||
Self::Aria2TellWaiting => "aria2.tellWaiting",
|
||||
Self::Aria2TellStopped => "aria2.tellStopped",
|
||||
Self::Aria2GetUris => "aria2.getUris",
|
||||
Self::Aria2GetFiles => "aria2.getFiles",
|
||||
Self::Aria2GetPeers => "aria2.getPeers",
|
||||
Self::Aria2GetServers => "aria2.getServers",
|
||||
Self::Aria2ChangePosition => "aria2.changePosition",
|
||||
Self::Aria2ChangeUri => "aria2.changeUri",
|
||||
Self::Aria2PurgeDownloadResult => "aria2.purgeDownloadResult",
|
||||
Self::Aria2RemoveDownloadResult => "aria2.removeDownloadResult",
|
||||
Self::Aria2TellGlobalStat => "aria2.getGlobalStat",
|
||||
Self::Aria2GetOption => "aria2.getOption",
|
||||
Self::Aria2ChangeOption => "aria2.changeOption",
|
||||
Self::Aria2GetGlobalOption => "aria2.getGlobalOption",
|
||||
Self::Aria2ChangeGlobalOption => "aria2.changeGlobalOption",
|
||||
Self::Aria2GetVersion => "aria2.getVersion",
|
||||
Self::Aria2GetSessionInfo => "aria2.getSessionInfo",
|
||||
Self::Aria2SaveSession => "aria2.saveSession",
|
||||
Self::Aria2Shutdown => "aria2.shutdown",
|
||||
Self::Aria2ForceShutdown => "aria2.forceShutdown",
|
||||
Self::SystemMulticall => "system.multicall",
|
||||
Self::SystemListMethods => "system.listMethods",
|
||||
Self::SystemListNotifications => "system.listNotifications",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns accepted legacy aliases for compatibility handling.
|
||||
#[must_use]
|
||||
pub const fn legacy_aliases(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::Aria2TellGlobalStat => &["aria2.tellGlobalStat"],
|
||||
Self::SystemMulticall => &["aria2.multicall"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical method ledger in the order exposed to compatibility consumers.
|
||||
pub const RPC_METHOD_LEDGER: &[RpcMethod] = &[
|
||||
RpcMethod::Aria2AddUri,
|
||||
RpcMethod::Aria2AddTorrent,
|
||||
RpcMethod::Aria2GetPeers,
|
||||
RpcMethod::Aria2AddMetalink,
|
||||
RpcMethod::Aria2Remove,
|
||||
RpcMethod::Aria2Pause,
|
||||
RpcMethod::Aria2ForcePause,
|
||||
RpcMethod::Aria2PauseAll,
|
||||
RpcMethod::Aria2ForcePauseAll,
|
||||
RpcMethod::Aria2Unpause,
|
||||
RpcMethod::Aria2UnpauseAll,
|
||||
RpcMethod::Aria2ForceRemove,
|
||||
RpcMethod::Aria2ChangePosition,
|
||||
RpcMethod::Aria2TellStatus,
|
||||
RpcMethod::Aria2GetUris,
|
||||
RpcMethod::Aria2GetFiles,
|
||||
RpcMethod::Aria2GetServers,
|
||||
RpcMethod::Aria2TellActive,
|
||||
RpcMethod::Aria2TellWaiting,
|
||||
RpcMethod::Aria2TellStopped,
|
||||
RpcMethod::Aria2GetOption,
|
||||
RpcMethod::Aria2ChangeUri,
|
||||
RpcMethod::Aria2ChangeOption,
|
||||
RpcMethod::Aria2GetGlobalOption,
|
||||
RpcMethod::Aria2ChangeGlobalOption,
|
||||
RpcMethod::Aria2PurgeDownloadResult,
|
||||
RpcMethod::Aria2RemoveDownloadResult,
|
||||
RpcMethod::Aria2GetVersion,
|
||||
RpcMethod::Aria2GetSessionInfo,
|
||||
RpcMethod::Aria2Shutdown,
|
||||
RpcMethod::Aria2ForceShutdown,
|
||||
RpcMethod::Aria2TellGlobalStat,
|
||||
RpcMethod::Aria2SaveSession,
|
||||
RpcMethod::SystemMulticall,
|
||||
RpcMethod::SystemListMethods,
|
||||
RpcMethod::SystemListNotifications,
|
||||
];
|
||||
|
||||
/// Required upstream-compatible method names that should resolve distinctly.
|
||||
pub const REQUIRED_RPC_METHODS: &[&str] = &[
|
||||
"aria2.addUri",
|
||||
"aria2.addTorrent",
|
||||
"aria2.getPeers",
|
||||
"aria2.addMetalink",
|
||||
"aria2.remove",
|
||||
"aria2.pause",
|
||||
"aria2.forcePause",
|
||||
"aria2.pauseAll",
|
||||
"aria2.forcePauseAll",
|
||||
"aria2.unpause",
|
||||
"aria2.unpauseAll",
|
||||
"aria2.forceRemove",
|
||||
"aria2.changePosition",
|
||||
"aria2.tellStatus",
|
||||
"aria2.getUris",
|
||||
"aria2.getFiles",
|
||||
"aria2.getServers",
|
||||
"aria2.tellActive",
|
||||
"aria2.tellWaiting",
|
||||
"aria2.tellStopped",
|
||||
"aria2.getOption",
|
||||
"aria2.changeUri",
|
||||
"aria2.changeOption",
|
||||
"aria2.getGlobalOption",
|
||||
"aria2.changeGlobalOption",
|
||||
"aria2.purgeDownloadResult",
|
||||
"aria2.removeDownloadResult",
|
||||
"aria2.getVersion",
|
||||
"aria2.getSessionInfo",
|
||||
"aria2.shutdown",
|
||||
"aria2.forceShutdown",
|
||||
"aria2.getGlobalStat",
|
||||
"aria2.saveSession",
|
||||
"system.multicall",
|
||||
"system.listMethods",
|
||||
"system.listNotifications",
|
||||
];
|
||||
|
||||
/// Canonical WebSocket notification names in upstream-compatible order.
|
||||
const RPC_NOTIFICATION_NAMES: &[&str] = &[
|
||||
"aria2.onDownloadStart",
|
||||
"aria2.onDownloadPause",
|
||||
"aria2.onDownloadStop",
|
||||
"aria2.onDownloadComplete",
|
||||
"aria2.onDownloadError",
|
||||
"aria2.onBtDownloadComplete",
|
||||
];
|
||||
|
||||
/// Returns whether a method name is part of the required compatibility set.
|
||||
#[must_use]
|
||||
pub fn is_required_rpc_method(method: &str) -> bool {
|
||||
REQUIRED_RPC_METHODS.contains(&method)
|
||||
}
|
||||
|
||||
/// Resolves a method name or legacy alias into the canonical method variant.
|
||||
#[must_use]
|
||||
pub(super) fn rpc_method(name: &str) -> Option<RpcMethod> {
|
||||
RPC_METHOD_LEDGER
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|method| method.as_str() == name || method.legacy_aliases().contains(&name))
|
||||
}
|
||||
|
||||
/// Returns the canonical RPC method names in ledger order.
|
||||
#[must_use]
|
||||
pub fn rpc_method_names() -> Vec<&'static str> {
|
||||
RPC_METHOD_LEDGER
|
||||
.iter()
|
||||
.copied()
|
||||
.map(RpcMethod::as_str)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the canonical WebSocket notification method names.
|
||||
#[must_use]
|
||||
pub(super) fn rpc_notification_names() -> Vec<&'static str> {
|
||||
RPC_NOTIFICATION_NAMES.to_vec()
|
||||
}
|
||||
|
||||
/// Builds an RPC value containing the canonical method name list.
|
||||
#[must_use]
|
||||
#[cfg(test)]
|
||||
fn method_name_value_list() -> RpcValue {
|
||||
RpcValue::Array(
|
||||
rpc_method_names()
|
||||
.into_iter()
|
||||
.map(|m| RpcValue::String(m.to_owned()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Unit tests for the public RPC method and notification catalog.
|
||||
mod tests {
|
||||
use super::{
|
||||
RpcMethod, method_name_value_list, rpc_method, rpc_method_names, rpc_notification_names,
|
||||
};
|
||||
use crate::model::RpcValue;
|
||||
|
||||
#[test]
|
||||
/// Verifies that legacy aliases resolve internally without leaking into public catalogs.
|
||||
fn legacy_aliases_resolve_but_do_not_leak_into_public_method_names() {
|
||||
assert_eq!(
|
||||
rpc_method("aria2.tellGlobalStat"),
|
||||
Some(RpcMethod::Aria2TellGlobalStat)
|
||||
);
|
||||
assert_eq!(
|
||||
rpc_method("aria2.multicall"),
|
||||
Some(RpcMethod::SystemMulticall)
|
||||
);
|
||||
|
||||
let method_names = rpc_method_names();
|
||||
assert!(method_names.contains(&"aria2.getGlobalStat"));
|
||||
assert!(method_names.contains(&"system.multicall"));
|
||||
assert!(!method_names.contains(&"aria2.tellGlobalStat"));
|
||||
assert!(!method_names.contains(&"aria2.multicall"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that public notification names keep the upstream aria2 order.
|
||||
fn public_notification_names_keep_upstream_order() {
|
||||
assert_eq!(
|
||||
rpc_notification_names(),
|
||||
vec![
|
||||
"aria2.onDownloadStart",
|
||||
"aria2.onDownloadPause",
|
||||
"aria2.onDownloadStop",
|
||||
"aria2.onDownloadComplete",
|
||||
"aria2.onDownloadError",
|
||||
"aria2.onBtDownloadComplete",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that `system.listMethods` emits only public method names.
|
||||
fn method_name_value_list_uses_public_names_only() {
|
||||
match method_name_value_list() {
|
||||
RpcValue::Array(values) => {
|
||||
let names = values
|
||||
.into_iter()
|
||||
.map(|value| match value {
|
||||
RpcValue::String(name) => name,
|
||||
other => panic!("unexpected method value: {other:?}"),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(names.iter().any(|name| name == "aria2.getGlobalStat"));
|
||||
assert!(!names.iter().any(|name| name == "aria2.tellGlobalStat"));
|
||||
assert!(!names.iter().any(|name| name == "aria2.multicall"));
|
||||
}
|
||||
other => panic!("unexpected method list payload: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Shared value, error, and metadata types used across RPC transports.
|
||||
#![expect(
|
||||
clippy::redundant_pub_crate,
|
||||
reason = "shared RPC model items stay crate-internal while retaining explicit visibilities"
|
||||
)]
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Canonical RPC option map shape for aria2-compatible option payloads.
|
||||
pub type RpcOptionMap = BTreeMap<String, String>;
|
||||
/// BitTorrent-specific status fields that aria2-compatible clients may request.
|
||||
pub(super) const BT_STATUS_FIELDS: &[&str] = &[
|
||||
"infoHash",
|
||||
"numSeeders",
|
||||
"seeder",
|
||||
"connections",
|
||||
"activeSegments",
|
||||
"pieceLength",
|
||||
"numPieces",
|
||||
"completedPieces",
|
||||
"bitfield",
|
||||
"announceList",
|
||||
"followedBy",
|
||||
"following",
|
||||
"belongsTo",
|
||||
"verifiedLength",
|
||||
"verifyIntegrityPending",
|
||||
"isBt",
|
||||
"metadataOnly",
|
||||
"magnetUri",
|
||||
"creationDate",
|
||||
"comment",
|
||||
"mode",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// Transport-neutral RPC value representation.
|
||||
pub enum RpcValue {
|
||||
/// Null or absent value.
|
||||
Null,
|
||||
/// Boolean scalar value.
|
||||
Bool(bool),
|
||||
/// Signed integer value.
|
||||
Number(i64),
|
||||
/// UTF-8 string value.
|
||||
String(String),
|
||||
/// Ordered list of nested values.
|
||||
Array(Vec<Self>),
|
||||
/// Map of named nested values.
|
||||
Object(BTreeMap<String, Self>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// Broad category for RPC failures.
|
||||
pub enum RpcErrorKind {
|
||||
/// The method name is not recognized.
|
||||
UnknownMethod,
|
||||
/// Parameters are malformed or unsupported.
|
||||
InvalidParams,
|
||||
/// Authentication or authorization failed.
|
||||
Unauthorized,
|
||||
/// The method exists but is not implemented by this backend.
|
||||
Unsupported,
|
||||
/// The backend encountered an unexpected internal failure.
|
||||
Internal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// JSON-RPC compatible error code surface.
|
||||
pub enum RpcErrorCode {
|
||||
/// Invalid JSON payload syntax.
|
||||
ParseError = -32700,
|
||||
/// Payload shape is not a valid JSON-RPC request.
|
||||
InvalidRequest = -32600,
|
||||
/// Method name is unknown.
|
||||
MethodNotFound = -32601,
|
||||
/// Parameters are invalid for the method.
|
||||
InvalidParams = -32602,
|
||||
/// Generic server-side failure.
|
||||
InternalError = -32603,
|
||||
/// Application-defined aria2-compatible failure.
|
||||
ApplicationError = -32000,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// Normalized error payload shared across transports.
|
||||
pub struct RpcError {
|
||||
/// JSON-RPC compatible numeric code.
|
||||
pub code: RpcErrorCode,
|
||||
/// Broad error classification.
|
||||
pub kind: RpcErrorKind,
|
||||
/// Human-readable failure message.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl RpcError {
|
||||
#[must_use]
|
||||
/// Builds a method-not-found error in aria2-compatible wording.
|
||||
pub fn unknown_method(method: &str) -> Self {
|
||||
Self {
|
||||
code: RpcErrorCode::MethodNotFound,
|
||||
kind: RpcErrorKind::UnknownMethod,
|
||||
message: format!("Method not found: {method}"),
|
||||
}
|
||||
}
|
||||
#[must_use]
|
||||
/// Builds a parse error with the provided message.
|
||||
pub fn parse_error(message: &str) -> Self {
|
||||
Self {
|
||||
code: RpcErrorCode::ParseError,
|
||||
kind: RpcErrorKind::InvalidParams,
|
||||
message: message.to_owned(),
|
||||
}
|
||||
}
|
||||
#[must_use]
|
||||
/// Builds an invalid-request error with the provided message.
|
||||
pub fn invalid_request(message: &str) -> Self {
|
||||
Self {
|
||||
code: RpcErrorCode::InvalidRequest,
|
||||
kind: RpcErrorKind::InvalidParams,
|
||||
message: message.to_owned(),
|
||||
}
|
||||
}
|
||||
#[must_use]
|
||||
/// Builds an unsupported-method error with the provided message.
|
||||
pub fn unsupported(message: &str) -> Self {
|
||||
Self {
|
||||
code: RpcErrorCode::ApplicationError,
|
||||
kind: RpcErrorKind::Unsupported,
|
||||
message: message.to_owned(),
|
||||
}
|
||||
}
|
||||
#[must_use]
|
||||
/// Builds an invalid-parameters error with the provided message.
|
||||
pub fn invalid_params(message: &str) -> Self {
|
||||
Self {
|
||||
code: RpcErrorCode::InvalidParams,
|
||||
kind: RpcErrorKind::InvalidParams,
|
||||
message: message.to_owned(),
|
||||
}
|
||||
}
|
||||
#[must_use]
|
||||
/// Builds an authorization failure with the provided message.
|
||||
pub fn unauthorized(message: &str) -> Self {
|
||||
Self {
|
||||
code: RpcErrorCode::ApplicationError,
|
||||
kind: RpcErrorKind::Unauthorized,
|
||||
message: message.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns the XML-RPC fault code used for this normalized error.
|
||||
pub const fn xml_fault_code(&self) -> i32 {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// Transport-neutral result envelope returned by handlers.
|
||||
pub struct RpcResultEnvelope {
|
||||
/// Successful result payload, if any.
|
||||
pub result: Option<RpcValue>,
|
||||
/// Error payload, if the request failed.
|
||||
pub error: Option<RpcError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
/// Optional metadata attached to inbound or outbound RPC traffic.
|
||||
pub struct RpcMeta {
|
||||
/// Correlation identifier propagated across RPC hops.
|
||||
pub trace_id: Option<String>,
|
||||
/// Logical client identifier, if known.
|
||||
pub client: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
/// Authentication context derived from the active transport session.
|
||||
pub struct RpcAuthContext {
|
||||
/// Secret token presented by the client.
|
||||
pub token: Option<String>,
|
||||
/// Transport session identifier, if one has been established.
|
||||
pub session_id: Option<String>,
|
||||
/// Whether the current request is authenticated.
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Unit tests for the shared RPC model surface.
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
/// Verifies that unknown methods use the upstream aria2 wording.
|
||||
fn unknown_method_uses_upstream_style_message() {
|
||||
let error = RpcError::unknown_method("aria2.notFound");
|
||||
|
||||
assert_eq!(error.code, RpcErrorCode::MethodNotFound);
|
||||
assert_eq!(error.kind, RpcErrorKind::UnknownMethod);
|
||||
assert_eq!(error.message, "Method not found: aria2.notFound");
|
||||
assert_eq!(error.xml_fault_code(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that parse and invalid-request errors keep their transport codes.
|
||||
fn parse_and_invalid_request_keep_transport_codes() {
|
||||
let parse = RpcError::parse_error("unexpected trailing token");
|
||||
let invalid = RpcError::invalid_request("jsonrpc batch request must not be empty");
|
||||
|
||||
assert_eq!(parse.code, RpcErrorCode::ParseError);
|
||||
assert_eq!(parse.kind, RpcErrorKind::InvalidParams);
|
||||
assert_eq!(parse.message, "unexpected trailing token");
|
||||
|
||||
assert_eq!(invalid.code, RpcErrorCode::InvalidRequest);
|
||||
assert_eq!(invalid.kind, RpcErrorKind::InvalidParams);
|
||||
assert_eq!(invalid.message, "jsonrpc batch request must not be empty");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Transport-neutral routing between request shapes and handler outputs.
|
||||
|
||||
use crate::{
|
||||
handlers::{RpcHandlerContext, RpcHandlerRegistry},
|
||||
jsonrpc::{JsonRpcNotification, JsonRpcRequest, JsonRpcResponse},
|
||||
model::{RpcError, RpcMeta, RpcResultEnvelope, RpcValue},
|
||||
xmlrpc::{
|
||||
XmlRpcFault, XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, rpc_value_to_xmlrpc,
|
||||
xmlrpc_value_to_rpc,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// Transport-neutral inbound request variants accepted by the router.
|
||||
pub enum RpcDispatchRequest {
|
||||
/// `JSON-RPC` request expecting a response.
|
||||
Json(JsonRpcRequest),
|
||||
/// `JSON-RPC` notification with no response body.
|
||||
JsonNotification(JsonRpcNotification),
|
||||
/// `XML-RPC` method call expecting a response.
|
||||
Xml(XmlRpcMethodCall),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// Transport-neutral outbound response variants produced by the router.
|
||||
pub enum RpcDispatchResult {
|
||||
/// `JSON-RPC` response payload.
|
||||
Json(JsonRpcResponse),
|
||||
/// `XML-RPC` response payload.
|
||||
Xml(XmlRpcMethodResponse),
|
||||
/// No response should be emitted.
|
||||
Empty,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
/// Shared router that normalizes transport-specific requests into handler calls.
|
||||
pub struct RpcRouter {
|
||||
/// Shared handler registry reused across `JSON-RPC` and `XML-RPC` dispatch paths.
|
||||
handlers: RpcHandlerRegistry,
|
||||
}
|
||||
|
||||
impl RpcRouter {
|
||||
#[must_use]
|
||||
/// Creates a router with the default handler registry.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
handlers: RpcHandlerRegistry,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns the shared handler registry.
|
||||
pub const fn handlers(&self) -> &RpcHandlerRegistry {
|
||||
&self.handlers
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns a mutable handler registry reference.
|
||||
pub const fn handlers_mut(&mut self) -> &mut RpcHandlerRegistry {
|
||||
&mut self.handlers
|
||||
}
|
||||
|
||||
/// Dispatches a transport-neutral request through the handler registry.
|
||||
pub fn dispatch(
|
||||
&mut self,
|
||||
request: RpcDispatchRequest,
|
||||
ctx: RpcHandlerContext,
|
||||
) -> RpcDispatchResult {
|
||||
match request {
|
||||
RpcDispatchRequest::Json(request) => {
|
||||
let request_id = request.id.clone();
|
||||
let envelope = self.handlers.handle_json(request, ctx);
|
||||
RpcDispatchResult::Json(envelope.into_response(request_id))
|
||||
}
|
||||
RpcDispatchRequest::JsonNotification(notification) => {
|
||||
self.handlers.handle_notification(notification, ctx);
|
||||
RpcDispatchResult::Empty
|
||||
}
|
||||
RpcDispatchRequest::Xml(request) => RpcDispatchResult::Xml(
|
||||
self.handlers
|
||||
.handle_json(json_request_from_xmlrpc(request), ctx)
|
||||
.into_xml_response(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcResultEnvelope {
|
||||
#[must_use]
|
||||
/// Converts an envelope into a `JSON-RPC` response with the provided id.
|
||||
pub fn into_response(self, id: Option<crate::jsonrpc::JsonRpcId>) -> JsonRpcResponse {
|
||||
let (result, error) = self.normalize_for_response();
|
||||
JsonRpcResponse {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id,
|
||||
result,
|
||||
error,
|
||||
meta: RpcMeta::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Converts an envelope into an `XML-RPC` method response.
|
||||
pub fn into_xml_response(self) -> XmlRpcMethodResponse {
|
||||
let (result, error) = self.normalize_for_response();
|
||||
match error {
|
||||
Some(error) => {
|
||||
let message = error.message.clone();
|
||||
XmlRpcMethodResponse {
|
||||
value: None,
|
||||
fault: Some(XmlRpcFault {
|
||||
code: error.xml_fault_code(),
|
||||
message,
|
||||
error: Some(error),
|
||||
}),
|
||||
meta: RpcMeta::default(),
|
||||
}
|
||||
}
|
||||
None => XmlRpcMethodResponse {
|
||||
value: Some(rpc_value_to_xmlrpc(
|
||||
result.map_or(RpcValue::Null, |result| result),
|
||||
)),
|
||||
fault: None,
|
||||
meta: RpcMeta::default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalizes the envelope so transport encoders always see either a result or an error.
|
||||
fn normalize_for_response(self) -> (Option<RpcValue>, Option<RpcError>) {
|
||||
match (self.result, self.error) {
|
||||
(_, Some(error)) => (None, Some(error)),
|
||||
(Some(result), None) => (Some(result), None),
|
||||
(None, None) => (Some(RpcValue::Null), None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a normalized error into an envelope with no successful result payload.
|
||||
impl From<RpcError> for RpcResultEnvelope {
|
||||
fn from(error: RpcError) -> Self {
|
||||
Self {
|
||||
result: None,
|
||||
error: Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-expresses an `XML-RPC` method call as the equivalent `JSON-RPC` request shape.
|
||||
fn json_request_from_xmlrpc(request: XmlRpcMethodCall) -> JsonRpcRequest {
|
||||
JsonRpcRequest {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id: None,
|
||||
method: request.method_name,
|
||||
params: request
|
||||
.params
|
||||
.into_iter()
|
||||
.map(|XmlRpcParam { value }| xmlrpc_value_to_rpc(value))
|
||||
.collect(),
|
||||
meta: request.meta,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Unit tests for the transport-neutral RPC router.
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
jsonrpc::JsonRpcId,
|
||||
model::{RpcAuthContext, RpcErrorCode, RpcErrorKind},
|
||||
xmlrpc::XmlRpcValue,
|
||||
};
|
||||
|
||||
/// Builds a default handler context for router tests.
|
||||
fn ctx() -> RpcHandlerContext {
|
||||
RpcHandlerContext {
|
||||
auth: RpcAuthContext::default(),
|
||||
meta: RpcMeta::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that an empty envelope becomes a `null` `JSON-RPC` result.
|
||||
fn json_response_normalizes_empty_envelope_to_null_result() {
|
||||
let response = RpcResultEnvelope {
|
||||
result: None,
|
||||
error: None,
|
||||
}
|
||||
.into_response(Some(JsonRpcId::Number(7)));
|
||||
|
||||
assert_eq!(response.id, Some(JsonRpcId::Number(7)));
|
||||
assert_eq!(response.result, Some(RpcValue::Null));
|
||||
assert!(response.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that error payloads take precedence over successful results.
|
||||
fn json_response_prefers_error_over_result() {
|
||||
let response = RpcResultEnvelope {
|
||||
result: Some(RpcValue::String("ignored".to_owned())),
|
||||
error: Some(RpcError::invalid_params("bad params")),
|
||||
}
|
||||
.into_response(Some(JsonRpcId::String("req-1".to_owned())));
|
||||
|
||||
assert_eq!(response.id, Some(JsonRpcId::String("req-1".to_owned())));
|
||||
assert!(response.result.is_none());
|
||||
let error = response.error.expect("error should win");
|
||||
assert_eq!(error.code, RpcErrorCode::InvalidParams);
|
||||
assert_eq!(error.kind, RpcErrorKind::InvalidParams);
|
||||
assert_eq!(error.message, "bad params");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that an empty envelope becomes an `XML-RPC` nil value.
|
||||
fn xml_response_normalizes_empty_envelope_to_nil_value() {
|
||||
let response = RpcResultEnvelope {
|
||||
result: None,
|
||||
error: None,
|
||||
}
|
||||
.into_xml_response();
|
||||
|
||||
assert!(response.fault.is_none());
|
||||
assert_eq!(response.value, Some(XmlRpcValue::Nil));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that unknown `XML-RPC` methods reuse the shared fault shape.
|
||||
fn xml_dispatch_uses_shared_fault_shape_for_unknown_method() {
|
||||
let mut router = RpcRouter::new();
|
||||
let response = match router.dispatch(
|
||||
RpcDispatchRequest::Xml(XmlRpcMethodCall {
|
||||
method_name: "aria2.notFound".to_owned(),
|
||||
params: Vec::new(),
|
||||
meta: RpcMeta::default(),
|
||||
}),
|
||||
ctx(),
|
||||
) {
|
||||
RpcDispatchResult::Xml(response) => response,
|
||||
other => panic!("unexpected dispatch result: {other:?}"),
|
||||
};
|
||||
|
||||
assert!(response.value.is_none());
|
||||
let fault = response.fault.expect("fault expected");
|
||||
assert_eq!(fault.code, 1);
|
||||
assert_eq!(fault.message, "Method not found: aria2.notFound");
|
||||
assert_eq!(
|
||||
fault.error,
|
||||
Some(RpcError::unknown_method("aria2.notFound"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that `XML-RPC` dispatch reuses the shared JSON-backed handler results.
|
||||
fn xml_dispatch_reuses_json_handler_results_for_list_methods() {
|
||||
let mut router = RpcRouter::new();
|
||||
let response = match router.dispatch(
|
||||
RpcDispatchRequest::Xml(XmlRpcMethodCall {
|
||||
method_name: "system.listMethods".to_owned(),
|
||||
params: Vec::new(),
|
||||
meta: RpcMeta::default(),
|
||||
}),
|
||||
ctx(),
|
||||
) {
|
||||
RpcDispatchResult::Xml(response) => response,
|
||||
other => panic!("unexpected dispatch result: {other:?}"),
|
||||
};
|
||||
|
||||
assert!(response.fault.is_none());
|
||||
match response.value {
|
||||
Some(XmlRpcValue::Array(items)) => {
|
||||
assert!(!items.is_empty(), "listMethods should not be empty");
|
||||
assert!(items.iter().any(|item| matches!(
|
||||
item,
|
||||
XmlRpcValue::String(name) if name == "system.listMethods"
|
||||
)));
|
||||
}
|
||||
other => panic!("unexpected XML value: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Minimal HTTP and WebSocket server glue for the RPC surface.
|
||||
#![expect(
|
||||
clippy::arithmetic_side_effects,
|
||||
clippy::as_conversions,
|
||||
clippy::case_sensitive_file_extension_comparisons,
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::indexing_slicing,
|
||||
clippy::missing_const_for_fn,
|
||||
clippy::needless_pass_by_value,
|
||||
clippy::question_mark,
|
||||
reason = "the server glue keeps low-level transport code explicit to preserve wire compatibility"
|
||||
)]
|
||||
|
||||
/// Shared runtime configuration and lightweight listener stub types.
|
||||
mod config;
|
||||
/// HTTP request parsing and RPC response shaping helpers.
|
||||
mod http_surface;
|
||||
/// Listener runtime and accepted-connection worker orchestration.
|
||||
mod transport_runtime;
|
||||
/// WebSocket frame dispatch helpers layered over the shared RPC dispatcher.
|
||||
mod websocket_dispatch;
|
||||
/// WebSocket upgrade validation and response shaping helpers.
|
||||
mod websocket_handshake;
|
||||
/// Upgraded WebSocket session runtime and flush-loop helpers.
|
||||
mod websocket_session;
|
||||
/// WebSocket upgrade/runtime facade shared by the listener runtime.
|
||||
mod websocket_surface;
|
||||
/// WebSocket frame codec helpers for upgraded streams.
|
||||
mod websocket_wire;
|
||||
|
||||
use self::config::WEBSOCKET_IDLE_POLL_INTERVAL;
|
||||
pub use self::{
|
||||
config::{RpcListenerStub, RpcServerConfig},
|
||||
transport_runtime::{RpcServerTransport, RpcServerTransportConfig, serve_rpc_listener},
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,56 @@
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
|
||||
/// Idle poll interval used while waiting for queued WebSocket notifications.
|
||||
pub(super) const WEBSOCKET_IDLE_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// Runtime configuration for the RPC HTTP server.
|
||||
pub struct RpcServerConfig {
|
||||
/// Whether JSON-RPC over HTTP is enabled.
|
||||
pub enable_json_rpc: bool,
|
||||
/// Whether XML-RPC over HTTP is enabled.
|
||||
pub enable_xml_rpc: bool,
|
||||
/// Whether JSON-RPC over WebSocket is enabled.
|
||||
pub enable_websocket_rpc: bool,
|
||||
/// Socket address the listener binds to.
|
||||
pub listen_addr: SocketAddr,
|
||||
/// Shared secret token required by protected methods.
|
||||
pub secret_token: Option<String>,
|
||||
/// Allowed CORS origin, if cross-origin requests are permitted.
|
||||
pub allow_origin: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for RpcServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enable_json_rpc: true,
|
||||
enable_xml_rpc: true,
|
||||
enable_websocket_rpc: false,
|
||||
listen_addr: SocketAddr::from(([127, 0, 0, 1], 6800)),
|
||||
secret_token: None,
|
||||
allow_origin: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
/// Minimal listener stub used by tests and integration layers.
|
||||
pub struct RpcListenerStub {
|
||||
/// Bound socket address, if the stub has been bound.
|
||||
pub bound_addr: Option<SocketAddr>,
|
||||
/// Whether the stub is considered active.
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
impl RpcListenerStub {
|
||||
/// Marks the stub as bound to the provided address.
|
||||
pub fn bind(&mut self, addr: SocketAddr) {
|
||||
self.bound_addr = Some(addr);
|
||||
self.active = true;
|
||||
}
|
||||
|
||||
/// Marks the stub as closed.
|
||||
pub fn close(&mut self) {
|
||||
self.active = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
io::{self, Read},
|
||||
net::TcpStream,
|
||||
str,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
InProcessRpcDispatcher, JsonRpcRequest,
|
||||
jsonrpc::{
|
||||
JsonRpcPayload, JsonRpcResponse, jsonrpc_batch_response_to_json, jsonrpc_payload_from_json,
|
||||
jsonrpc_response_to_json,
|
||||
},
|
||||
model::{RpcError, RpcErrorCode, RpcMeta, RpcValue},
|
||||
xmlrpc::{
|
||||
XmlRpcFault, XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, XmlRpcValue,
|
||||
xmlrpc_method_call_from_xml, xmlrpc_method_response_to_xml,
|
||||
},
|
||||
};
|
||||
|
||||
use super::{RpcServerConfig, websocket_surface::websocket_upgrade_response};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Buffered HTTP request model used by the synchronous transport helpers.
|
||||
pub(super) struct HttpRequest {
|
||||
/// Request method token from the HTTP start line.
|
||||
pub(super) method: String,
|
||||
/// Raw request target path from the HTTP start line.
|
||||
pub(super) path: String,
|
||||
/// Lower-cased request headers.
|
||||
pub(super) headers: BTreeMap<String, String>,
|
||||
/// Fully buffered request body bytes.
|
||||
pub(super) body: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Prepared HTTP RPC request after transport-level validation and body parsing.
|
||||
enum PreparedHttpRpcRequest {
|
||||
/// An XML-RPC request body ready for dispatcher execution.
|
||||
Xml(XmlRpcMethodCall),
|
||||
/// A JSON-RPC payload ready for dispatcher execution.
|
||||
Json(JsonRpcPayload),
|
||||
}
|
||||
|
||||
/// Strips any query string or fragment from an inbound request target.
|
||||
pub(super) fn request_path_without_query_or_fragment(path: &str) -> &str {
|
||||
path.split(['?', '#']).next().unwrap_or(path)
|
||||
}
|
||||
|
||||
/// Normalizes supported RPC paths so equivalent HTTP targets share one route key.
|
||||
pub(super) fn normalized_rpc_path(path: &str) -> &str {
|
||||
let path = request_path_without_query_or_fragment(path);
|
||||
if path.len() > 1 {
|
||||
path.trim_end_matches('/')
|
||||
} else {
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
/// Looks up an HTTP header by name using case-insensitive matching.
|
||||
pub(super) fn header_value<'a>(
|
||||
headers: &'a BTreeMap<String, String>,
|
||||
name: &str,
|
||||
) -> Option<&'a str> {
|
||||
headers
|
||||
.iter()
|
||||
.find(|(header_name, _)| header_name.eq_ignore_ascii_case(name))
|
||||
.map(|(_, value)| value.as_str())
|
||||
}
|
||||
|
||||
/// Returns whether an HTTP header exists, ignoring header-name case.
|
||||
pub(super) fn has_header(headers: &BTreeMap<String, String>, name: &str) -> bool {
|
||||
header_value(headers, name).is_some()
|
||||
}
|
||||
|
||||
/// Reads and buffers a single HTTP request from a client stream.
|
||||
pub(super) fn read_http_request(
|
||||
stream: &mut TcpStream,
|
||||
request_timeout: Duration,
|
||||
) -> io::Result<Option<HttpRequest>> {
|
||||
stream.set_read_timeout(Some(request_timeout))?;
|
||||
let mut buffer = Vec::new();
|
||||
let mut chunk = [0_u8; 1024];
|
||||
loop {
|
||||
let read = stream.read(&mut chunk)?;
|
||||
if read == 0 {
|
||||
if buffer.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
break;
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..read]);
|
||||
if buffer.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
if buffer.len() > 1024 * 1024 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"request too large",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let header_end = buffer
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.map(|index| index + 4)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing request headers"))?;
|
||||
let (header_bytes, body_prefix) = buffer.split_at(header_end);
|
||||
let header_text = str::from_utf8(header_bytes)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?;
|
||||
let mut header_lines = header_text.lines();
|
||||
let request_line = header_lines
|
||||
.next()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing request line"))?;
|
||||
let mut request_parts = request_line.split_whitespace();
|
||||
let method = request_parts
|
||||
.next()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing method"))?
|
||||
.to_owned();
|
||||
let path = request_parts
|
||||
.next()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing path"))?
|
||||
.to_owned();
|
||||
let headers = header_lines
|
||||
.filter_map(|line| line.split_once(':'))
|
||||
.map(|(name, value)| (name.trim().to_ascii_lowercase(), value.trim().to_owned()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let content_length = headers
|
||||
.get("content-length")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut body = body_prefix.to_vec();
|
||||
while body.len() < content_length {
|
||||
let read = stream.read(&mut chunk)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
body.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
body.truncate(content_length);
|
||||
|
||||
Ok(Some(HttpRequest {
|
||||
method,
|
||||
path,
|
||||
headers,
|
||||
body,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Builds a minimal HTTP response with the supplied content type and body.
|
||||
pub(super) fn http_response(status: &str, content_type: &str, body: &[u8]) -> Vec<u8> {
|
||||
let mut response = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
)
|
||||
.into_bytes();
|
||||
response.extend_from_slice(body);
|
||||
response
|
||||
}
|
||||
|
||||
/// Builds an empty `204 No Content` HTTP response.
|
||||
pub(super) fn http_no_content_response() -> Vec<u8> {
|
||||
b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec()
|
||||
}
|
||||
|
||||
/// Builds a minimal HTTP response while allowing additional headers to be injected.
|
||||
pub(super) fn http_response_with_headers(
|
||||
status: &str,
|
||||
content_type: Option<&str>,
|
||||
extra_headers: &[(&str, String)],
|
||||
body: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let mut response = format!("HTTP/1.1 {status}\r\n").into_bytes();
|
||||
if let Some(content_type) = content_type {
|
||||
response.extend_from_slice(format!("Content-Type: {content_type}\r\n").as_bytes());
|
||||
}
|
||||
response.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
|
||||
for (name, value) in extra_headers {
|
||||
response.extend_from_slice(format!("{name}: {value}\r\n").as_bytes());
|
||||
}
|
||||
response.extend_from_slice(b"\r\n");
|
||||
response.extend_from_slice(body);
|
||||
response
|
||||
}
|
||||
|
||||
/// Produces the CORS headers required for either a normal RPC response or a preflight reply.
|
||||
pub(super) fn cors_response_headers(
|
||||
config: &RpcServerConfig,
|
||||
requested_headers: Option<&str>,
|
||||
preflight: bool,
|
||||
) -> Vec<(&'static str, String)> {
|
||||
let Some(origin) = &config.allow_origin else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut headers = vec![("Access-Control-Allow-Origin", origin.clone())];
|
||||
if preflight {
|
||||
headers.push((
|
||||
"Access-Control-Allow-Methods",
|
||||
"POST, GET, OPTIONS".to_owned(),
|
||||
));
|
||||
headers.push((
|
||||
"Access-Control-Allow-Headers",
|
||||
requested_headers
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("content-type")
|
||||
.to_owned(),
|
||||
));
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
/// Builds an RPC HTTP response and attaches configured CORS headers.
|
||||
pub(super) fn rpc_http_response(
|
||||
config: &RpcServerConfig,
|
||||
status: &str,
|
||||
content_type: &str,
|
||||
body: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let headers = cors_response_headers(config, None, false);
|
||||
http_response_with_headers(status, Some(content_type), &headers, body)
|
||||
}
|
||||
|
||||
/// Builds an empty RPC HTTP response and attaches configured CORS headers.
|
||||
pub(super) fn rpc_http_no_content_response(
|
||||
config: &RpcServerConfig,
|
||||
requested_headers: Option<&str>,
|
||||
) -> Vec<u8> {
|
||||
let headers = cors_response_headers(config, requested_headers, true);
|
||||
http_response_with_headers("204 No Content", None, &headers, b"")
|
||||
}
|
||||
|
||||
/// Returns whether an HTTP request looks like a WebSocket upgrade handshake for RPC.
|
||||
pub(super) fn is_websocket_upgrade_candidate(request: &HttpRequest) -> bool {
|
||||
request.method.eq_ignore_ascii_case("GET")
|
||||
&& normalized_rpc_path(&request.path) == "/jsonrpc"
|
||||
&& (header_value(&request.headers, "upgrade")
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("websocket"))
|
||||
|| has_header(&request.headers, "sec-websocket-key")
|
||||
|| has_header(&request.headers, "sec-websocket-version")
|
||||
|| header_value(&request.headers, "connection")
|
||||
.is_some_and(|value| contains_ascii_case_insensitive(value, "upgrade")))
|
||||
}
|
||||
|
||||
/// Removes an optional leading `token:` secret from JSON-RPC positional parameters.
|
||||
fn extract_rpc_token(params: &mut Vec<RpcValue>) -> Option<String> {
|
||||
if let Some(RpcValue::String(token)) = params.first()
|
||||
&& let Some(value) = token.strip_prefix("token:")
|
||||
{
|
||||
let value = value.to_owned();
|
||||
params.remove(0);
|
||||
return Some(value);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns whether an RPC method remains callable without the shared secret token.
|
||||
fn rpc_method_skips_secret(method: &str) -> bool {
|
||||
matches!(method, "system.listMethods" | "system.listNotifications")
|
||||
}
|
||||
|
||||
/// Dispatches one JSON-RPC request after applying the shared-secret compatibility rules.
|
||||
pub(super) fn dispatch_json_request(
|
||||
dispatcher: &mut InProcessRpcDispatcher,
|
||||
config: &RpcServerConfig,
|
||||
mut request: JsonRpcRequest,
|
||||
) -> JsonRpcResponse {
|
||||
let provided_token = extract_rpc_token(&mut request.params);
|
||||
if let Some(secret) = &config.secret_token
|
||||
&& !rpc_method_skips_secret(&request.method)
|
||||
&& provided_token.as_deref() != Some(secret.as_str())
|
||||
{
|
||||
return JsonRpcResponse::error(
|
||||
request.id,
|
||||
RpcError::unauthorized("RPC secret required or invalid token"),
|
||||
);
|
||||
}
|
||||
dispatcher.dispatch_json(request)
|
||||
}
|
||||
|
||||
/// Dispatches one XML-RPC request after applying the shared-secret compatibility rules.
|
||||
pub(super) fn dispatch_xml_request(
|
||||
dispatcher: &mut InProcessRpcDispatcher,
|
||||
config: &RpcServerConfig,
|
||||
mut request: XmlRpcMethodCall,
|
||||
) -> XmlRpcMethodResponse {
|
||||
let provided_token = if let Some(XmlRpcParam {
|
||||
value: XmlRpcValue::String(token),
|
||||
}) = request.params.first()
|
||||
&& let Some(value) = token.strip_prefix("token:")
|
||||
{
|
||||
Some(value.to_owned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if provided_token.is_some() {
|
||||
request.params.remove(0);
|
||||
}
|
||||
if let Some(secret) = &config.secret_token
|
||||
&& !rpc_method_skips_secret(&request.method_name)
|
||||
&& provided_token.as_deref() != Some(secret.as_str())
|
||||
{
|
||||
return XmlRpcMethodResponse {
|
||||
value: None,
|
||||
fault: Some(XmlRpcFault {
|
||||
code: 1,
|
||||
message: "RPC secret required or invalid token".to_owned(),
|
||||
error: Some(RpcError::unauthorized(
|
||||
"RPC secret required or invalid token",
|
||||
)),
|
||||
}),
|
||||
meta: RpcMeta::default(),
|
||||
};
|
||||
}
|
||||
dispatcher.dispatch_xml(request)
|
||||
}
|
||||
|
||||
/// Trims a UTF-8 BOM, comments, and processing instructions before XML-RPC sniffing.
|
||||
fn trim_xml_prelude(mut body: &str) -> &str {
|
||||
loop {
|
||||
body = body.trim_start();
|
||||
if let Some(rest) = body.strip_prefix("<?")
|
||||
&& let Some(end) = rest.find("?>")
|
||||
{
|
||||
body = &rest[end + 2..];
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = body.strip_prefix("<!--")
|
||||
&& let Some(end) = rest.find("-->")
|
||||
{
|
||||
body = &rest[end + 3..];
|
||||
continue;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether an HTTP request body should be treated as XML-RPC input.
|
||||
fn looks_like_xmlrpc_request_body(body_text: &str) -> bool {
|
||||
let body = trim_xml_prelude(body_text.trim_start_matches('\u{feff}'));
|
||||
body.starts_with("<methodCall") || body.starts_with("<methodResponse")
|
||||
}
|
||||
|
||||
/// Handles one HTTP RPC request, including JSON-RPC, XML-RPC, CORS, and upgrade paths.
|
||||
#[cfg(test)]
|
||||
pub(super) fn handle_rpc_http_request(
|
||||
dispatcher: &mut InProcessRpcDispatcher,
|
||||
config: &RpcServerConfig,
|
||||
request: HttpRequest,
|
||||
) -> Vec<u8> {
|
||||
match prepare_rpc_http_request(config, request) {
|
||||
Ok(Some(prepared)) => render_rpc_http_dispatch_response(dispatcher, config, prepared),
|
||||
Ok(None) => http_no_content_response(),
|
||||
Err(response) => response,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles one HTTP RPC request while locking the dispatcher only for the actual dispatch path.
|
||||
pub(super) fn handle_rpc_http_request_shared(
|
||||
dispatcher: &Arc<Mutex<InProcessRpcDispatcher>>,
|
||||
config: &RpcServerConfig,
|
||||
request: HttpRequest,
|
||||
) -> io::Result<Vec<u8>> {
|
||||
match prepare_rpc_http_request(config, request) {
|
||||
Ok(Some(prepared)) => {
|
||||
let mut dispatcher = dispatcher
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("rpc dispatcher mutex poisoned"))?;
|
||||
Ok(render_rpc_http_dispatch_response(
|
||||
&mut dispatcher,
|
||||
config,
|
||||
prepared,
|
||||
))
|
||||
}
|
||||
Ok(None) => Ok(http_no_content_response()),
|
||||
Err(response) => Ok(response),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses and validates one HTTP RPC request before any dispatcher locking occurs.
|
||||
fn prepare_rpc_http_request(
|
||||
config: &RpcServerConfig,
|
||||
request: HttpRequest,
|
||||
) -> Result<Option<PreparedHttpRpcRequest>, Vec<u8>> {
|
||||
if is_websocket_upgrade_candidate(&request) {
|
||||
return Err(websocket_upgrade_response(config, &request));
|
||||
}
|
||||
let path = normalized_rpc_path(&request.path);
|
||||
let is_rpc_endpoint = matches!(path, "/jsonrpc" | "/rpc");
|
||||
if request.method.eq_ignore_ascii_case("OPTIONS")
|
||||
&& is_rpc_endpoint
|
||||
&& config.allow_origin.is_some()
|
||||
{
|
||||
return Err(rpc_http_no_content_response(
|
||||
config,
|
||||
header_value(&request.headers, "access-control-request-headers"),
|
||||
));
|
||||
}
|
||||
if request.method != "POST" {
|
||||
return Err(http_response(
|
||||
"405 Method Not Allowed",
|
||||
"text/plain",
|
||||
b"method not allowed",
|
||||
));
|
||||
}
|
||||
let content_type = header_value(&request.headers, "content-type").unwrap_or("");
|
||||
let body_text = String::from_utf8_lossy(&request.body);
|
||||
let normalized_xml_body = trim_xml_prelude(body_text.trim_start_matches('\u{feff}'));
|
||||
let is_xml = contains_ascii_case_insensitive(content_type, "xml")
|
||||
|| path.ends_with(".xml")
|
||||
|| (path == "/rpc" && looks_like_xmlrpc_request_body(&body_text))
|
||||
|| request.body.starts_with(b"<?xml")
|
||||
|| body_text.trim_start().starts_with("<?xml");
|
||||
if is_xml {
|
||||
if !config.enable_xml_rpc {
|
||||
return Err(http_response(
|
||||
"404 Not Found",
|
||||
"text/plain",
|
||||
b"xml-rpc disabled",
|
||||
));
|
||||
}
|
||||
let request = match xmlrpc_method_call_from_xml(normalized_xml_body) {
|
||||
Ok(request) => request,
|
||||
Err(error) => {
|
||||
return Err(http_response(
|
||||
"400 Bad Request",
|
||||
"text/plain",
|
||||
error.as_bytes(),
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(Some(PreparedHttpRpcRequest::Xml(request)))
|
||||
} else {
|
||||
if !config.enable_json_rpc {
|
||||
return Err(http_response(
|
||||
"404 Not Found",
|
||||
"text/plain",
|
||||
b"json-rpc disabled",
|
||||
));
|
||||
}
|
||||
let payload = match jsonrpc_payload_from_json(&body_text) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => {
|
||||
let response = JsonRpcResponse::error(
|
||||
None,
|
||||
RpcError {
|
||||
code: if error.contains("must not be empty") {
|
||||
RpcErrorCode::InvalidRequest
|
||||
} else {
|
||||
RpcErrorCode::ParseError
|
||||
},
|
||||
kind: crate::model::RpcErrorKind::InvalidParams,
|
||||
message: error,
|
||||
},
|
||||
);
|
||||
let body = jsonrpc_response_to_json(&response)
|
||||
.unwrap_or_else(|render_error| format!(r#"{{"error":"{render_error}"}}"#));
|
||||
return Err(rpc_http_response(
|
||||
config,
|
||||
"200 OK",
|
||||
"application/json",
|
||||
body.as_bytes(),
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(Some(PreparedHttpRpcRequest::Json(payload)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether `needle` appears in `haystack`, ignoring ASCII case.
|
||||
pub(super) fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
|
||||
if needle.is_empty() {
|
||||
return true;
|
||||
}
|
||||
haystack
|
||||
.as_bytes()
|
||||
.windows(needle.len())
|
||||
.any(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
|
||||
}
|
||||
|
||||
/// Renders a prepared HTTP RPC request once dispatcher access has been acquired.
|
||||
fn render_rpc_http_dispatch_response(
|
||||
dispatcher: &mut InProcessRpcDispatcher,
|
||||
config: &RpcServerConfig,
|
||||
prepared: PreparedHttpRpcRequest,
|
||||
) -> Vec<u8> {
|
||||
match prepared {
|
||||
PreparedHttpRpcRequest::Xml(request) => {
|
||||
let response = dispatch_xml_request(dispatcher, config, request);
|
||||
let body = xmlrpc_method_response_to_xml(&response);
|
||||
rpc_http_response(config, "200 OK", "text/xml", body.as_bytes())
|
||||
}
|
||||
PreparedHttpRpcRequest::Json(payload) => {
|
||||
render_json_http_dispatch_response(dispatcher, config, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a prepared JSON-RPC HTTP request once dispatcher access has been acquired.
|
||||
fn render_json_http_dispatch_response(
|
||||
dispatcher: &mut InProcessRpcDispatcher,
|
||||
config: &RpcServerConfig,
|
||||
payload: JsonRpcPayload,
|
||||
) -> Vec<u8> {
|
||||
match payload {
|
||||
JsonRpcPayload::Single(request) => {
|
||||
let response = dispatch_json_request(dispatcher, config, request);
|
||||
if response.id.is_none() {
|
||||
return http_no_content_response();
|
||||
}
|
||||
let body = jsonrpc_response_to_json(&response)
|
||||
.unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#));
|
||||
rpc_http_response(config, "200 OK", "application/json", body.as_bytes())
|
||||
}
|
||||
JsonRpcPayload::Batch(items) => {
|
||||
let mut responses = Vec::new();
|
||||
for item in items {
|
||||
match item {
|
||||
Ok(request) => {
|
||||
let response = dispatch_json_request(dispatcher, config, request);
|
||||
if response.id.is_some() {
|
||||
responses.push(response);
|
||||
}
|
||||
}
|
||||
Err(error) => responses.push(JsonRpcResponse::error(
|
||||
None,
|
||||
RpcError {
|
||||
code: RpcErrorCode::InvalidRequest,
|
||||
kind: crate::model::RpcErrorKind::InvalidParams,
|
||||
message: error,
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
let body = jsonrpc_batch_response_to_json(&responses)
|
||||
.unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#));
|
||||
rpc_http_response(config, "200 OK", "application/json", body.as_bytes())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
io::Write,
|
||||
net::{Shutdown, TcpListener, TcpStream},
|
||||
sync::{Arc, Mutex},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use super::{
|
||||
http_surface::{
|
||||
HttpRequest, dispatch_json_request, dispatch_xml_request, handle_rpc_http_request,
|
||||
},
|
||||
websocket_session::flush_websocket_session_queue_shared,
|
||||
websocket_surface::{
|
||||
flush_websocket_session_queue, handle_websocket_rpc_frame,
|
||||
process_websocket_session_frames, read_websocket_frame, serve_upgraded_websocket_session,
|
||||
websocket_frame_from_bytes, websocket_frame_to_bytes,
|
||||
},
|
||||
*,
|
||||
};
|
||||
use crate::{
|
||||
InProcessRpcDispatcher, JsonRpcRequest, RpcMeta, RpcMethod, RpcNotificationEvent,
|
||||
RpcNotificationKind, RpcValue, RpcWebSocketFrame, WebSocketSessionRegistry, XmlRpcMethodCall,
|
||||
XmlRpcParam, XmlRpcValue, jsonrpc::JsonRpcId,
|
||||
};
|
||||
|
||||
mod http_surface;
|
||||
mod websocket_dispatch;
|
||||
mod websocket_handshake;
|
||||
mod websocket_session;
|
||||
|
||||
#[doc(hidden)]
|
||||
fn masked_text_frame_bytes(text: &str) -> Vec<u8> {
|
||||
let payload = text.as_bytes();
|
||||
let mask = [0x11, 0x22, 0x33, 0x44];
|
||||
let masked_payload = payload
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, byte)| byte ^ mask[index % mask.len()])
|
||||
.collect::<Vec<_>>();
|
||||
let payload_len =
|
||||
u8::try_from(payload.len()).expect("test websocket payload should fit in a short frame");
|
||||
let mut frame = vec![0x81, 0x80 | payload_len];
|
||||
frame.extend_from_slice(&mask);
|
||||
frame.extend_from_slice(&masked_payload);
|
||||
frame
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serves_jsonrpc_version_over_http() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
HttpRequest {
|
||||
method: "POST".to_owned(),
|
||||
path: "/jsonrpc".to_owned(),
|
||||
headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]),
|
||||
body: br#"{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]}"#.to_vec(),
|
||||
},
|
||||
);
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("200 OK"));
|
||||
assert!(response.contains("\"version\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_xmlrpc_version_over_http() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
HttpRequest {
|
||||
method: "POST".to_owned(),
|
||||
path: "/rpc".to_owned(),
|
||||
headers: BTreeMap::from([("content-type".to_owned(), "text/xml".to_owned())]),
|
||||
body: br#"<?xml version="1.0"?><methodCall><methodName>aria2.getVersion</methodName><params/></methodCall>"#.to_vec(),
|
||||
},
|
||||
);
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("200 OK"));
|
||||
assert!(response.contains("<methodResponse>"));
|
||||
assert!(response.contains("<name>version</name>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_xmlrpc_with_comments_and_spaced_empty_params_over_http() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig {
|
||||
secret_token: Some("secret".to_owned()),
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
HttpRequest {
|
||||
method: "POST".to_owned(),
|
||||
path: "/rpc".to_owned(),
|
||||
headers: BTreeMap::from([("content-type".to_owned(), "text/xml".to_owned())]),
|
||||
body: br#"<?xml version="1.0"?>
|
||||
<!-- client prelude -->
|
||||
<?client probe?>
|
||||
<methodCall>
|
||||
<methodName>system.listNotifications</methodName>
|
||||
<params />
|
||||
</methodCall>"#
|
||||
.to_vec(),
|
||||
},
|
||||
);
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("200 OK"));
|
||||
assert!(response.contains("aria2.onDownloadStart"));
|
||||
assert!(response.contains("aria2.onDownloadError"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_json_rejects_bad_secret() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = dispatch_json_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig {
|
||||
secret_token: Some("secret".to_owned()),
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
JsonRpcRequest {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id: Some(JsonRpcId::Number(1)),
|
||||
method: RpcMethod::Aria2GetVersion.as_str().to_owned(),
|
||||
params: vec![RpcValue::String("token:wrong".to_owned())],
|
||||
meta: RpcMeta::default(),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(response.error.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_json_allows_system_list_methods_without_secret() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = dispatch_json_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig {
|
||||
secret_token: Some("secret".to_owned()),
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
JsonRpcRequest {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id: Some(JsonRpcId::Number(2)),
|
||||
method: RpcMethod::SystemListMethods.as_str().to_owned(),
|
||||
params: Vec::new(),
|
||||
meta: RpcMeta::default(),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(response.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_xml_allows_system_list_notifications_without_secret() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = dispatch_xml_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig {
|
||||
secret_token: Some("secret".to_owned()),
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
XmlRpcMethodCall {
|
||||
method_name: "system.listNotifications".to_owned(),
|
||||
params: Vec::new(),
|
||||
meta: RpcMeta::default(),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(response.fault.is_none());
|
||||
match response.value {
|
||||
Some(XmlRpcValue::Array(values)) => assert!(!values.is_empty()),
|
||||
other => panic!("unexpected XML-RPC response value: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_xml_rejects_invalid_secret_with_upstream_fault_shape() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = dispatch_xml_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig {
|
||||
secret_token: Some("secret".to_owned()),
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
XmlRpcMethodCall {
|
||||
method_name: "aria2.getVersion".to_owned(),
|
||||
params: vec![XmlRpcParam {
|
||||
value: XmlRpcValue::String("token:wrong".to_owned()),
|
||||
}],
|
||||
meta: RpcMeta::default(),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(response.value.is_none());
|
||||
let fault = response.fault.expect("expected XML-RPC fault");
|
||||
assert_eq!(fault.code, 1);
|
||||
assert_eq!(fault.message, "RPC secret required or invalid token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_jsonrpc_batch_over_http() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
HttpRequest {
|
||||
method: "POST".to_owned(),
|
||||
path: "/jsonrpc".to_owned(),
|
||||
headers: BTreeMap::from([(
|
||||
"content-type".to_owned(),
|
||||
"application/json".to_owned(),
|
||||
)]),
|
||||
body: br#"[{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]},{"jsonrpc":"2.0","id":2,"method":"system.listMethods","params":[]}]"#
|
||||
.to_vec(),
|
||||
},
|
||||
);
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("200 OK"));
|
||||
assert!(response.contains("[{"));
|
||||
assert!(response.contains("\"id\":1"));
|
||||
assert!(response.contains("\"id\":2"));
|
||||
assert!(response.contains("\"version\""));
|
||||
assert!(response.contains("system.listMethods"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_jsonrpc_batch_without_ids_as_invalid_request_errors() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
HttpRequest {
|
||||
method: "POST".to_owned(),
|
||||
path: "/jsonrpc".to_owned(),
|
||||
headers: BTreeMap::from([(
|
||||
"content-type".to_owned(),
|
||||
"application/json".to_owned(),
|
||||
)]),
|
||||
body: br#"[{"jsonrpc":"2.0","method":"aria2.getVersion","params":[]},{"jsonrpc":"2.0","method":"system.listMethods","params":[]}]"#
|
||||
.to_vec(),
|
||||
},
|
||||
);
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("200 OK"));
|
||||
assert!(response.contains("\"id\":null"));
|
||||
assert!(response.contains("\"code\":-32600"));
|
||||
assert!(response.contains("\"message\":\"Invalid Request.\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_jsonrpc_batch_ignores_non_object_members_like_upstream_aria2() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
HttpRequest {
|
||||
method: "POST".to_owned(),
|
||||
path: "/jsonrpc".to_owned(),
|
||||
headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]),
|
||||
body: br#"[{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]},7]"#
|
||||
.to_vec(),
|
||||
},
|
||||
);
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("200 OK"));
|
||||
assert!(response.contains("\"id\":1"));
|
||||
assert!(!response.contains("\"code\":-32600"));
|
||||
assert!(!response.contains("jsonrpc request must be an object"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_empty_jsonrpc_batch_as_empty_array_like_upstream_aria2() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
HttpRequest {
|
||||
method: "POST".to_owned(),
|
||||
path: "/jsonrpc".to_owned(),
|
||||
headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]),
|
||||
body: br"[]".to_vec(),
|
||||
},
|
||||
);
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("200 OK"));
|
||||
assert!(response.ends_with("[]"));
|
||||
assert!(!response.contains("204 No Content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_jsonrpc_batch_with_only_non_object_members_as_empty_array() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
HttpRequest {
|
||||
method: "POST".to_owned(),
|
||||
path: "/jsonrpc".to_owned(),
|
||||
headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]),
|
||||
body: br#"[7,true,"noop"]"#.to_vec(),
|
||||
},
|
||||
);
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("200 OK"));
|
||||
assert!(response.ends_with("[]"));
|
||||
assert!(!response.contains("204 No Content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_jsonrpc_list_notifications_with_upstream_names_over_http() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
HttpRequest {
|
||||
method: "POST".to_owned(),
|
||||
path: "/jsonrpc".to_owned(),
|
||||
headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]),
|
||||
body: br#"{"jsonrpc":"2.0","id":7,"method":"system.listNotifications","params":[]}"#
|
||||
.to_vec(),
|
||||
},
|
||||
);
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("200 OK"));
|
||||
assert!(response.contains("\"id\":7"));
|
||||
assert!(response.contains("aria2.onDownloadStart"));
|
||||
assert!(response.contains("aria2.onDownloadComplete"));
|
||||
assert!(response.contains("aria2.onBtDownloadComplete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_jsonrpc_list_methods_without_secret_over_http() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig {
|
||||
secret_token: Some("secret".to_owned()),
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
HttpRequest {
|
||||
method: "POST".to_owned(),
|
||||
path: "/jsonrpc".to_owned(),
|
||||
headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]),
|
||||
body: br#"{"jsonrpc":"2.0","id":8,"method":"system.listMethods","params":[]}"#.to_vec(),
|
||||
},
|
||||
);
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("200 OK"));
|
||||
assert!(response.contains("\"id\":8"));
|
||||
assert!(response.contains("aria2.addUri"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_single_jsonrpc_request_without_id_as_invalid_request() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
HttpRequest {
|
||||
method: "POST".to_owned(),
|
||||
path: "/jsonrpc".to_owned(),
|
||||
headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]),
|
||||
body: br#"{"jsonrpc":"2.0","method":"aria2.getVersion","params":[]}"#.to_vec(),
|
||||
},
|
||||
);
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("200 OK"));
|
||||
assert!(response.contains("\"id\":null"));
|
||||
assert!(response.contains("\"code\":-32600"));
|
||||
assert!(response.contains("\"message\":\"Invalid Request.\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_jsonrpc_named_params_as_invalid_params_with_same_id() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
HttpRequest {
|
||||
method: "POST".to_owned(),
|
||||
path: "/jsonrpc".to_owned(),
|
||||
headers: BTreeMap::from([(
|
||||
"content-type".to_owned(),
|
||||
"application/json".to_owned(),
|
||||
)]),
|
||||
body: br#"{"jsonrpc":"2.0","id":"named","method":"aria2.getVersion","params":{"foo":"bar"}}"#
|
||||
.to_vec(),
|
||||
},
|
||||
);
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("200 OK"));
|
||||
assert!(response.contains("\"id\":\"named\""));
|
||||
assert!(response.contains("\"code\":-32602"));
|
||||
assert!(response.contains("\"message\":\"Invalid params.\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_xmlrpc_list_notifications_without_secret_over_http() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig {
|
||||
secret_token: Some("secret".to_owned()),
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
HttpRequest {
|
||||
method: "POST".to_owned(),
|
||||
path: "/rpc".to_owned(),
|
||||
headers: BTreeMap::from([("content-type".to_owned(), "text/xml".to_owned())]),
|
||||
body: br#"<?xml version="1.0"?><methodCall><methodName>system.listNotifications</methodName><params/></methodCall>"#
|
||||
.to_vec(),
|
||||
},
|
||||
);
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("200 OK"));
|
||||
assert!(response.contains("aria2.onDownloadStart"));
|
||||
assert!(response.contains("aria2.onBtDownloadComplete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_xmlrpc_on_rpc_path_without_content_type_when_body_is_method_call() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
HttpRequest {
|
||||
method: "POST".to_owned(),
|
||||
path: "/rpc".to_owned(),
|
||||
headers: BTreeMap::new(),
|
||||
body: br"<methodCall><methodName>aria2.getVersion</methodName><params/></methodCall>"
|
||||
.to_vec(),
|
||||
},
|
||||
);
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("200 OK"));
|
||||
assert!(response.contains("<methodResponse>"));
|
||||
assert!(response.contains("<name>version</name>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_xmlrpc_with_case_insensitive_content_type_header_name_and_value() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
HttpRequest {
|
||||
method: "POST".to_owned(),
|
||||
path: "/jsonrpc".to_owned(),
|
||||
headers: BTreeMap::from([(
|
||||
"Content-Type".to_owned(),
|
||||
"Text/XML; charset=utf-8".to_owned(),
|
||||
)]),
|
||||
body: br"<methodCall><methodName>aria2.getVersion</methodName><params/></methodCall>"
|
||||
.to_vec(),
|
||||
},
|
||||
);
|
||||
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("200 OK"));
|
||||
assert!(response.contains("<methodResponse>"));
|
||||
assert!(response.contains("<name>version</name>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_jsonrpc_on_normalized_jsonrpc_path_with_query_and_trailing_slash() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
HttpRequest {
|
||||
method: "POST".to_owned(),
|
||||
path: "/jsonrpc/?tm=1".to_owned(),
|
||||
headers: BTreeMap::from([("content-type".to_owned(), "text/plain".to_owned())]),
|
||||
body: br#"{"jsonrpc":"2.0","id":21,"method":"aria2.getVersion","params":[]}"#.to_vec(),
|
||||
},
|
||||
);
|
||||
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("200 OK"));
|
||||
assert!(response.contains(r#""id":21"#));
|
||||
assert!(response.contains(r#""version""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_xmlrpc_on_normalized_rpc_path_without_content_type_when_body_has_bom_and_comment() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
HttpRequest {
|
||||
method: "POST".to_owned(),
|
||||
path: "/rpc/?view=compat".to_owned(),
|
||||
headers: BTreeMap::new(),
|
||||
body: "\u{feff}<!--probe--><methodCall><methodName>aria2.getVersion</methodName><params/></methodCall>"
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
},
|
||||
);
|
||||
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("200 OK"));
|
||||
assert!(response.contains("<methodResponse>"));
|
||||
assert!(response.contains("<name>version</name>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_cors_preflight_for_jsonrpc_when_allow_origin_is_configured() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig {
|
||||
allow_origin: Some("https://webui.example".to_owned()),
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
HttpRequest {
|
||||
method: "OPTIONS".to_owned(),
|
||||
path: "/jsonrpc?cors=1".to_owned(),
|
||||
headers: BTreeMap::from([
|
||||
("origin".to_owned(), "https://webui.example".to_owned()),
|
||||
(
|
||||
"access-control-request-method".to_owned(),
|
||||
"POST".to_owned(),
|
||||
),
|
||||
(
|
||||
"access-control-request-headers".to_owned(),
|
||||
"content-type,x-requested-with".to_owned(),
|
||||
),
|
||||
]),
|
||||
body: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("204 No Content"));
|
||||
assert!(response.contains("Access-Control-Allow-Origin: https://webui.example"));
|
||||
assert!(response.contains("Access-Control-Allow-Methods: POST, GET, OPTIONS"));
|
||||
assert!(response.contains("Access-Control-Allow-Headers: content-type,x-requested-with"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_allow_origin_header_on_http_jsonrpc_success_responses() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig {
|
||||
allow_origin: Some("https://webui.example".to_owned()),
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
HttpRequest {
|
||||
method: "POST".to_owned(),
|
||||
path: "/jsonrpc".to_owned(),
|
||||
headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]),
|
||||
body: br#"{"jsonrpc":"2.0","id":22,"method":"aria2.getVersion","params":[]}"#.to_vec(),
|
||||
},
|
||||
);
|
||||
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("200 OK"));
|
||||
assert!(response.contains("Access-Control-Allow-Origin: https://webui.example"));
|
||||
assert!(response.contains(r#""id":22"#));
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn websocket_frame_roundtrips_masked_text_payload() {
|
||||
let payload = br#"{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]}"#;
|
||||
let mask = [0x11, 0x22, 0x33, 0x44];
|
||||
let masked_payload = payload
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, byte)| byte ^ mask[index % 4])
|
||||
.collect::<Vec<_>>();
|
||||
let mut frame = vec![0x81, 0x80 | (payload.len() as u8)];
|
||||
frame.extend_from_slice(&mask);
|
||||
frame.extend_from_slice(&masked_payload);
|
||||
|
||||
let parsed = websocket_frame_from_bytes(&frame).expect("masked text frame should parse");
|
||||
assert_eq!(
|
||||
parsed,
|
||||
RpcWebSocketFrame::Text(
|
||||
r#"{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]}"#.to_owned()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_frame_roundtrips_masked_binary_payload() {
|
||||
let payload = br#"{"jsonrpc":"2.0","id":71,"method":"aria2.getVersion","params":[]}"#;
|
||||
let mask = [0x11, 0x22, 0x33, 0x44];
|
||||
let masked_payload = payload
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, byte)| byte ^ mask[index % 4])
|
||||
.collect::<Vec<_>>();
|
||||
let mut frame = vec![0x82, 0x80 | (payload.len() as u8)];
|
||||
frame.extend_from_slice(&mask);
|
||||
frame.extend_from_slice(&masked_payload);
|
||||
|
||||
let parsed = websocket_frame_from_bytes(&frame).expect("masked binary frame should parse");
|
||||
assert_eq!(parsed, RpcWebSocketFrame::Binary(payload.to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_dispatches_text_request_and_returns_text_response() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_websocket_rpc_frame(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
RpcWebSocketFrame::Text(
|
||||
r#"{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]}"#.to_owned(),
|
||||
),
|
||||
);
|
||||
|
||||
match response {
|
||||
Some(RpcWebSocketFrame::Text(text)) => {
|
||||
assert!(text.contains(r#""id":1"#));
|
||||
assert!(text.contains(r#""version""#));
|
||||
}
|
||||
other => panic!("unexpected websocket rpc response: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_dispatches_binary_json_request_and_returns_text_response() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_websocket_rpc_frame(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
RpcWebSocketFrame::Binary(
|
||||
br#"{"jsonrpc":"2.0","id":72,"method":"aria2.getVersion","params":[]}"#.to_vec(),
|
||||
),
|
||||
);
|
||||
|
||||
match response {
|
||||
Some(RpcWebSocketFrame::Text(text)) => {
|
||||
assert!(text.contains(r#""id":72"#));
|
||||
assert!(text.contains(r#""version""#));
|
||||
}
|
||||
other => panic!("unexpected websocket binary rpc response: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_dispatches_system_list_methods_without_secret() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_websocket_rpc_frame(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig {
|
||||
secret_token: Some("secret".to_owned()),
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
RpcWebSocketFrame::Text(
|
||||
r#"{"jsonrpc":"2.0","id":11,"method":"system.listMethods","params":[]}"#.to_owned(),
|
||||
),
|
||||
);
|
||||
|
||||
match response {
|
||||
Some(RpcWebSocketFrame::Text(text)) => {
|
||||
assert!(text.contains(r#""id":11"#));
|
||||
assert!(text.contains("aria2.addUri"));
|
||||
}
|
||||
other => panic!("unexpected websocket system.listMethods response: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_dispatches_protected_method_with_secret_token() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_websocket_rpc_frame(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig {
|
||||
secret_token: Some("secret".to_owned()),
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
RpcWebSocketFrame::Text(
|
||||
r#"{"jsonrpc":"2.0","id":12,"method":"aria2.getVersion","params":["token:secret"]}"#
|
||||
.to_owned(),
|
||||
),
|
||||
);
|
||||
|
||||
match response {
|
||||
Some(RpcWebSocketFrame::Text(text)) => {
|
||||
assert!(text.contains(r#""id":12"#));
|
||||
assert!(text.contains(r#""version""#));
|
||||
assert!(!text.contains(r#""code":-32000"#));
|
||||
}
|
||||
other => panic!("unexpected websocket protected-method response: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_rejects_invalid_secret_with_error_frame() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_websocket_rpc_frame(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig {
|
||||
secret_token: Some("secret".to_owned()),
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
RpcWebSocketFrame::Text(
|
||||
r#"{"jsonrpc":"2.0","id":13,"method":"aria2.getVersion","params":["token:wrong"]}"#
|
||||
.to_owned(),
|
||||
),
|
||||
);
|
||||
|
||||
match response {
|
||||
Some(RpcWebSocketFrame::Text(text)) => {
|
||||
assert!(text.contains(r#""id":13"#));
|
||||
assert!(text.contains(r#""code":-32000"#));
|
||||
assert!(text.contains("RPC secret required or invalid token"));
|
||||
}
|
||||
other => panic!("unexpected websocket invalid-secret response: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_batch_mixes_authorized_and_unauthorized_requests_independently() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_websocket_rpc_frame(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig {
|
||||
secret_token: Some("secret".to_owned()),
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
RpcWebSocketFrame::Text(
|
||||
concat!(
|
||||
r#"[{"jsonrpc":"2.0","id":31,"method":"aria2.getVersion","params":["token:secret"]},"#,
|
||||
r#"{"jsonrpc":"2.0","id":32,"method":"aria2.getVersion","params":["token:wrong"]},"#,
|
||||
r#"{"jsonrpc":"2.0","id":33,"method":"system.listMethods","params":[]}]"#
|
||||
)
|
||||
.to_owned(),
|
||||
),
|
||||
);
|
||||
|
||||
match response {
|
||||
Some(RpcWebSocketFrame::Text(text)) => {
|
||||
assert!(text.starts_with('['));
|
||||
assert!(text.contains(r#""id":31"#));
|
||||
assert!(text.contains(r#""id":32"#));
|
||||
assert!(text.contains(r#""id":33"#));
|
||||
assert!(text.contains(r#""version""#));
|
||||
assert!(text.contains("system.listMethods"));
|
||||
assert!(text.contains(r#""code":-32000"#));
|
||||
assert!(text.contains("RPC secret required or invalid token"));
|
||||
}
|
||||
other => panic!("unexpected websocket batch response: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_batch_ignores_non_object_members_like_upstream_aria2() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_websocket_rpc_frame(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
RpcWebSocketFrame::Text(
|
||||
r#"[{"jsonrpc":"2.0","id":41,"method":"aria2.getVersion","params":[]},7]"#.to_owned(),
|
||||
),
|
||||
);
|
||||
|
||||
match response {
|
||||
Some(RpcWebSocketFrame::Text(text)) => {
|
||||
assert!(text.starts_with('['));
|
||||
assert!(text.contains(r#""id":41"#));
|
||||
assert!(text.contains(r#""version""#));
|
||||
assert!(!text.contains(r#""code":-32600"#));
|
||||
}
|
||||
other => panic!("unexpected websocket mixed batch response: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_empty_batch_returns_empty_array_response() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_websocket_rpc_frame(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
RpcWebSocketFrame::Text("[]".to_owned()),
|
||||
);
|
||||
|
||||
assert_eq!(response, Some(RpcWebSocketFrame::Text("[]".to_owned())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_batch_with_only_non_object_members_returns_empty_array_response() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_websocket_rpc_frame(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
RpcWebSocketFrame::Text(r#"[7,true,"noop"]"#.to_owned()),
|
||||
);
|
||||
|
||||
assert_eq!(response, Some(RpcWebSocketFrame::Text("[]".to_owned())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_request_without_id_returns_invalid_request_frame() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_websocket_rpc_frame(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
RpcWebSocketFrame::Text(
|
||||
r#"{"jsonrpc":"2.0","method":"aria2.getVersion","params":[]}"#.to_owned(),
|
||||
),
|
||||
);
|
||||
|
||||
match response {
|
||||
Some(RpcWebSocketFrame::Text(text)) => {
|
||||
assert!(text.contains(r#""id":null"#));
|
||||
assert!(text.contains(r#""code":-32600"#));
|
||||
assert!(text.contains(r#""message":"Invalid Request.""#));
|
||||
}
|
||||
other => panic!("unexpected websocket invalid-request response: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_ping_returns_pong() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_websocket_rpc_frame(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
RpcWebSocketFrame::Ping(vec![1, 2, 3, 4]),
|
||||
);
|
||||
|
||||
assert_eq!(response, Some(RpcWebSocketFrame::Pong(vec![1, 2, 3, 4])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_response_frame_serializes_as_text_opcode() {
|
||||
let bytes = websocket_frame_to_bytes(&RpcWebSocketFrame::Text("ok".to_owned()));
|
||||
assert_eq!(bytes, vec![0x81, 0x02, b'o', b'k']);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serves_websocket_upgrade_handshake_on_jsonrpc_path() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig {
|
||||
enable_websocket_rpc: true,
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
HttpRequest {
|
||||
method: "GET".to_owned(),
|
||||
path: "/jsonrpc".to_owned(),
|
||||
headers: BTreeMap::from([
|
||||
("upgrade".to_owned(), "websocket".to_owned()),
|
||||
("connection".to_owned(), "Upgrade".to_owned()),
|
||||
(
|
||||
"sec-websocket-key".to_owned(),
|
||||
"dGhlIHNhbXBsZSBub25jZQ==".to_owned(),
|
||||
),
|
||||
("sec-websocket-version".to_owned(), "13".to_owned()),
|
||||
]),
|
||||
body: Vec::new(),
|
||||
},
|
||||
);
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("101 Switching Protocols"));
|
||||
assert!(response.contains("Upgrade: websocket"));
|
||||
assert!(response.contains("Connection: Upgrade"));
|
||||
assert!(response.contains("Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_websocket_upgrade_with_case_insensitive_header_names_and_values() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig {
|
||||
enable_websocket_rpc: true,
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
HttpRequest {
|
||||
method: "GET".to_owned(),
|
||||
path: "/jsonrpc".to_owned(),
|
||||
headers: BTreeMap::from([
|
||||
("Upgrade".to_owned(), "WebSocket".to_owned()),
|
||||
("Connection".to_owned(), "keep-alive, Upgrade".to_owned()),
|
||||
(
|
||||
"Sec-WebSocket-Key".to_owned(),
|
||||
"dGhlIHNhbXBsZSBub25jZQ==".to_owned(),
|
||||
),
|
||||
("Sec-WebSocket-Version".to_owned(), "13".to_owned()),
|
||||
]),
|
||||
body: Vec::new(),
|
||||
},
|
||||
);
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("101 Switching Protocols"));
|
||||
assert!(response.contains("Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_upgrade_does_not_echo_subprotocol_header() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig {
|
||||
enable_websocket_rpc: true,
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
HttpRequest {
|
||||
method: "GET".to_owned(),
|
||||
path: "/jsonrpc".to_owned(),
|
||||
headers: BTreeMap::from([
|
||||
("upgrade".to_owned(), "websocket".to_owned()),
|
||||
("connection".to_owned(), "keep-alive, Upgrade".to_owned()),
|
||||
(
|
||||
"sec-websocket-key".to_owned(),
|
||||
"dGhlIHNhbXBsZSBub25jZQ==".to_owned(),
|
||||
),
|
||||
("sec-websocket-version".to_owned(), "13".to_owned()),
|
||||
(
|
||||
"sec-websocket-protocol".to_owned(),
|
||||
"graphql-ws, jsonrpc".to_owned(),
|
||||
),
|
||||
]),
|
||||
body: Vec::new(),
|
||||
},
|
||||
);
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("101 Switching Protocols"));
|
||||
assert!(!response.contains("Sec-WebSocket-Protocol:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_websocket_upgrade_when_disabled() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
HttpRequest {
|
||||
method: "GET".to_owned(),
|
||||
path: "/jsonrpc".to_owned(),
|
||||
headers: BTreeMap::from([
|
||||
("upgrade".to_owned(), "websocket".to_owned()),
|
||||
("connection".to_owned(), "Upgrade".to_owned()),
|
||||
(
|
||||
"sec-websocket-key".to_owned(),
|
||||
"dGhlIHNhbXBsZSBub25jZQ==".to_owned(),
|
||||
),
|
||||
("sec-websocket-version".to_owned(), "13".to_owned()),
|
||||
]),
|
||||
body: Vec::new(),
|
||||
},
|
||||
);
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("404 Not Found"));
|
||||
assert!(response.contains("websocket-rpc disabled"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_websocket_upgrade_with_missing_key() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig {
|
||||
enable_websocket_rpc: true,
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
HttpRequest {
|
||||
method: "GET".to_owned(),
|
||||
path: "/jsonrpc".to_owned(),
|
||||
headers: BTreeMap::from([
|
||||
("upgrade".to_owned(), "websocket".to_owned()),
|
||||
("connection".to_owned(), "Upgrade".to_owned()),
|
||||
("sec-websocket-version".to_owned(), "13".to_owned()),
|
||||
]),
|
||||
body: Vec::new(),
|
||||
},
|
||||
);
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("400 Bad Request"));
|
||||
assert!(response.contains("missing sec-websocket-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_websocket_upgrade_with_unsupported_version_using_426() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig {
|
||||
enable_websocket_rpc: true,
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
HttpRequest {
|
||||
method: "GET".to_owned(),
|
||||
path: "/jsonrpc".to_owned(),
|
||||
headers: BTreeMap::from([
|
||||
("upgrade".to_owned(), "websocket".to_owned()),
|
||||
("connection".to_owned(), "Upgrade".to_owned()),
|
||||
(
|
||||
"sec-websocket-key".to_owned(),
|
||||
"dGhlIHNhbXBsZSBub25jZQ==".to_owned(),
|
||||
),
|
||||
("sec-websocket-version".to_owned(), "12".to_owned()),
|
||||
]),
|
||||
body: Vec::new(),
|
||||
},
|
||||
);
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("426 Upgrade Required"));
|
||||
assert!(response.contains("Sec-WebSocket-Version: 13"));
|
||||
assert!(response.contains("unsupported websocket version"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_websocket_upgrade_handshake_on_normalized_jsonrpc_path() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let response = handle_rpc_http_request(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig {
|
||||
enable_websocket_rpc: true,
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
HttpRequest {
|
||||
method: "GET".to_owned(),
|
||||
path: "/jsonrpc/?transport=ws".to_owned(),
|
||||
headers: BTreeMap::from([
|
||||
("upgrade".to_owned(), "websocket".to_owned()),
|
||||
("connection".to_owned(), "keep-alive, Upgrade".to_owned()),
|
||||
(
|
||||
"sec-websocket-key".to_owned(),
|
||||
"dGhlIHNhbXBsZSBub25jZQ==".to_owned(),
|
||||
),
|
||||
("sec-websocket-version".to_owned(), "13".to_owned()),
|
||||
]),
|
||||
body: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
let response = String::from_utf8(response).expect("utf8 response");
|
||||
assert!(response.contains("101 Switching Protocols"));
|
||||
assert!(response.contains("Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo="));
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn websocket_session_processes_multiple_requests_until_close() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let responses = process_websocket_session_frames(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
vec![
|
||||
RpcWebSocketFrame::Text(
|
||||
r#"{"jsonrpc":"2.0","id":1,"method":"aria2.getVersion","params":[]}"#.to_owned(),
|
||||
),
|
||||
RpcWebSocketFrame::Ping(vec![9, 8, 7]),
|
||||
RpcWebSocketFrame::Text(
|
||||
r#"{"jsonrpc":"2.0","id":2,"method":"system.listMethods","params":[]}"#.to_owned(),
|
||||
),
|
||||
RpcWebSocketFrame::Close,
|
||||
RpcWebSocketFrame::Text(
|
||||
r#"{"jsonrpc":"2.0","id":3,"method":"aria2.getVersion","params":[]}"#.to_owned(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(responses.len(), 4);
|
||||
match &responses[0] {
|
||||
RpcWebSocketFrame::Text(text) => {
|
||||
assert!(text.contains(r#""id":1"#));
|
||||
assert!(text.contains(r#""version""#));
|
||||
}
|
||||
other => panic!("unexpected first websocket response: {other:?}"),
|
||||
}
|
||||
assert_eq!(responses[1], RpcWebSocketFrame::Pong(vec![9, 8, 7]));
|
||||
match &responses[2] {
|
||||
RpcWebSocketFrame::Text(text) => {
|
||||
assert!(text.contains(r#""id":2"#));
|
||||
assert!(text.contains("system.listMethods"));
|
||||
}
|
||||
other => panic!("unexpected third websocket response: {other:?}"),
|
||||
}
|
||||
assert_eq!(responses[3], RpcWebSocketFrame::Close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_session_returns_invalid_request_for_missing_id_and_continues() {
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
let responses = process_websocket_session_frames(
|
||||
&mut dispatcher,
|
||||
&RpcServerConfig::default(),
|
||||
vec![
|
||||
RpcWebSocketFrame::Text(
|
||||
r#"{"jsonrpc":"2.0","method":"aria2.getVersion","params":[]}"#.to_owned(),
|
||||
),
|
||||
RpcWebSocketFrame::Text(
|
||||
r#"{"jsonrpc":"2.0","id":4,"method":"aria2.getVersion","params":[]}"#.to_owned(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(responses.len(), 2);
|
||||
match &responses[0] {
|
||||
RpcWebSocketFrame::Text(text) => {
|
||||
assert!(text.contains(r#""id":null"#));
|
||||
assert!(text.contains(r#""code":-32600"#));
|
||||
}
|
||||
other => panic!("unexpected websocket invalid-request response: {other:?}"),
|
||||
}
|
||||
match &responses[1] {
|
||||
RpcWebSocketFrame::Text(text) => assert!(text.contains(r#""id":4"#)),
|
||||
other => panic!("unexpected websocket response after invalid request: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_websocket_session_queue_writes_all_pending_frames_in_order() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener should bind");
|
||||
let addr = listener
|
||||
.local_addr()
|
||||
.expect("listener address should resolve");
|
||||
let writer = thread::spawn(move || {
|
||||
let mut stream = TcpStream::connect(addr).expect("client should connect");
|
||||
let mut sessions = WebSocketSessionRegistry::default();
|
||||
sessions.connect("sess-1");
|
||||
sessions.queue_event_for_all(&RpcNotificationEvent {
|
||||
kind: RpcNotificationKind::DownloadStarted,
|
||||
method: String::new(),
|
||||
gid: Some("abc".to_owned()),
|
||||
payload: None,
|
||||
meta: RpcMeta::default(),
|
||||
});
|
||||
sessions.queue_frame_for_session("sess-1", RpcWebSocketFrame::Ping(vec![7, 8, 9]));
|
||||
flush_websocket_session_queue(&mut stream, &mut sessions, "sess-1")
|
||||
.expect("queued websocket frames should flush");
|
||||
assert_eq!(sessions.pending_count("sess-1"), Some(0));
|
||||
});
|
||||
|
||||
let (mut accepted, _) = listener.accept().expect("server side should accept");
|
||||
let first = read_websocket_frame(&mut accepted)
|
||||
.expect("first frame should read")
|
||||
.expect("first frame should exist");
|
||||
let second = read_websocket_frame(&mut accepted)
|
||||
.expect("second frame should read")
|
||||
.expect("second frame should exist");
|
||||
|
||||
writer.join().expect("writer thread should join");
|
||||
match first {
|
||||
RpcWebSocketFrame::Text(text) => {
|
||||
assert!(text.contains("aria2.onDownloadStart"));
|
||||
assert!(text.contains(r#""gid":"abc""#));
|
||||
}
|
||||
other => panic!("unexpected first flushed frame: {other:?}"),
|
||||
}
|
||||
assert_eq!(second, RpcWebSocketFrame::Ping(vec![7, 8, 9]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_websocket_session_queue_shared_writes_pending_frames_without_borrowing_registry() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener should bind");
|
||||
let addr = listener
|
||||
.local_addr()
|
||||
.expect("listener address should resolve");
|
||||
let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default()));
|
||||
|
||||
let writer_sessions = Arc::clone(&sessions);
|
||||
let writer = thread::spawn(move || {
|
||||
let mut stream = TcpStream::connect(addr).expect("client should connect");
|
||||
{
|
||||
let mut sessions = writer_sessions
|
||||
.lock()
|
||||
.expect("sessions lock should succeed");
|
||||
sessions.connect("sess-1");
|
||||
sessions.queue_frame_for_session("sess-1", RpcWebSocketFrame::Ping(vec![1]));
|
||||
sessions.queue_frame_for_session("sess-1", RpcWebSocketFrame::Ping(vec![2]));
|
||||
}
|
||||
flush_websocket_session_queue_shared(&mut stream, &writer_sessions, "sess-1")
|
||||
.expect("shared queued websocket frames should flush");
|
||||
assert_eq!(
|
||||
writer_sessions
|
||||
.lock()
|
||||
.expect("sessions lock should succeed")
|
||||
.pending_count("sess-1"),
|
||||
Some(0)
|
||||
);
|
||||
});
|
||||
|
||||
let (mut accepted, _) = listener.accept().expect("server side should accept");
|
||||
let first = read_websocket_frame(&mut accepted)
|
||||
.expect("first shared frame should read")
|
||||
.expect("first shared frame should exist");
|
||||
let second = read_websocket_frame(&mut accepted)
|
||||
.expect("second shared frame should read")
|
||||
.expect("second shared frame should exist");
|
||||
|
||||
writer.join().expect("writer thread should join");
|
||||
assert_eq!(first, RpcWebSocketFrame::Ping(vec![1]));
|
||||
assert_eq!(second, RpcWebSocketFrame::Ping(vec![2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgraded_websocket_session_processes_masked_request_then_flushes_notification() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener should bind");
|
||||
let addr = listener
|
||||
.local_addr()
|
||||
.expect("listener address should resolve");
|
||||
let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default()));
|
||||
|
||||
let server_sessions = Arc::clone(&sessions);
|
||||
let server = thread::spawn(move || {
|
||||
let (stream, _) = listener
|
||||
.accept()
|
||||
.expect("server should accept websocket peer");
|
||||
serve_upgraded_websocket_session(
|
||||
stream,
|
||||
&RpcServerConfig {
|
||||
enable_websocket_rpc: true,
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
Arc::new(Mutex::new(InProcessRpcDispatcher::new())),
|
||||
server_sessions,
|
||||
"sess-live-request".to_owned(),
|
||||
)
|
||||
.expect("websocket session should complete cleanly");
|
||||
});
|
||||
|
||||
let mut client = TcpStream::connect(addr).expect("client should connect");
|
||||
client
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.expect("client read timeout should configure");
|
||||
|
||||
for _ in 0..20 {
|
||||
if sessions
|
||||
.lock()
|
||||
.expect("sessions lock should succeed")
|
||||
.contains("sess-live-request")
|
||||
{
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
|
||||
let request = masked_text_frame_bytes(
|
||||
r#"{"jsonrpc":"2.0","id":51,"method":"aria2.getVersion","params":[]}"#,
|
||||
);
|
||||
client
|
||||
.write_all(&request)
|
||||
.expect("client websocket request should write");
|
||||
client
|
||||
.flush()
|
||||
.expect("client websocket request should flush");
|
||||
|
||||
let response = read_websocket_frame(&mut client)
|
||||
.expect("client should receive websocket RPC response")
|
||||
.expect("response frame should exist");
|
||||
match response {
|
||||
RpcWebSocketFrame::Text(text) => {
|
||||
assert!(text.contains(r#""id":51"#));
|
||||
assert!(text.contains(r#""version""#));
|
||||
}
|
||||
other => panic!("unexpected websocket response frame: {other:?}"),
|
||||
}
|
||||
|
||||
sessions
|
||||
.lock()
|
||||
.expect("sessions lock should succeed")
|
||||
.queue_event_for_all(&RpcNotificationEvent {
|
||||
kind: RpcNotificationKind::DownloadComplete,
|
||||
method: String::new(),
|
||||
gid: Some("after-request".to_owned()),
|
||||
payload: None,
|
||||
meta: RpcMeta::default(),
|
||||
});
|
||||
|
||||
let notification = read_websocket_frame(&mut client)
|
||||
.expect("client should receive websocket notification after request")
|
||||
.expect("notification frame should exist");
|
||||
match notification {
|
||||
RpcWebSocketFrame::Text(text) => {
|
||||
assert!(text.contains("aria2.onDownloadComplete"));
|
||||
assert!(text.contains(r#""gid":"after-request""#));
|
||||
}
|
||||
other => panic!("unexpected websocket notification frame: {other:?}"),
|
||||
}
|
||||
|
||||
client
|
||||
.shutdown(Shutdown::Both)
|
||||
.expect("client shutdown should succeed");
|
||||
drop(client);
|
||||
server.join().expect("server thread should join");
|
||||
assert!(
|
||||
!sessions
|
||||
.lock()
|
||||
.expect("sessions lock should succeed")
|
||||
.contains("sess-live-request")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgraded_websocket_session_flushes_runtime_events_while_client_is_idle() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener should bind");
|
||||
let addr = listener
|
||||
.local_addr()
|
||||
.expect("listener address should resolve");
|
||||
let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default()));
|
||||
|
||||
let server_sessions = Arc::clone(&sessions);
|
||||
let server = thread::spawn(move || {
|
||||
let (stream, _) = listener
|
||||
.accept()
|
||||
.expect("server should accept websocket peer");
|
||||
serve_upgraded_websocket_session(
|
||||
stream,
|
||||
&RpcServerConfig {
|
||||
enable_websocket_rpc: true,
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
Arc::new(Mutex::new(InProcessRpcDispatcher::new())),
|
||||
server_sessions,
|
||||
"sess-live".to_owned(),
|
||||
)
|
||||
.expect("websocket session should complete cleanly");
|
||||
});
|
||||
|
||||
let mut client = TcpStream::connect(addr).expect("client should connect");
|
||||
client
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.expect("client read timeout should configure");
|
||||
|
||||
for _ in 0..20 {
|
||||
if sessions
|
||||
.lock()
|
||||
.expect("sessions lock should succeed")
|
||||
.contains("sess-live")
|
||||
{
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
|
||||
sessions
|
||||
.lock()
|
||||
.expect("sessions lock should succeed")
|
||||
.queue_event_for_all(&RpcNotificationEvent {
|
||||
kind: RpcNotificationKind::DownloadStarted,
|
||||
method: String::new(),
|
||||
gid: Some("idle-gid".to_owned()),
|
||||
payload: None,
|
||||
meta: RpcMeta::default(),
|
||||
});
|
||||
|
||||
let frame = read_websocket_frame(&mut client)
|
||||
.expect("idle client should receive queued websocket notification")
|
||||
.expect("notification frame should exist");
|
||||
match frame {
|
||||
RpcWebSocketFrame::Text(text) => {
|
||||
assert!(text.contains("aria2.onDownloadStart"));
|
||||
assert!(text.contains(r#""gid":"idle-gid""#));
|
||||
}
|
||||
other => panic!("unexpected idle websocket frame: {other:?}"),
|
||||
}
|
||||
|
||||
client
|
||||
.shutdown(Shutdown::Both)
|
||||
.expect("client shutdown should succeed");
|
||||
drop(client);
|
||||
server.join().expect("server thread should join");
|
||||
assert!(
|
||||
!sessions
|
||||
.lock()
|
||||
.expect("sessions lock should succeed")
|
||||
.contains("sess-live")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
use std::{
|
||||
io::{self, Write},
|
||||
net::{TcpListener, TcpStream},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
mpsc::{self, Receiver, Sender, TryRecvError},
|
||||
},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
InProcessRpcDispatcher,
|
||||
websocket::{RuntimeEventWebSocketBridge, WebSocketSessionRegistry},
|
||||
};
|
||||
|
||||
use super::{
|
||||
RpcServerConfig,
|
||||
http_surface::{
|
||||
handle_rpc_http_request_shared, is_websocket_upgrade_candidate, read_http_request,
|
||||
},
|
||||
websocket_surface::serve_upgraded_websocket_session,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// Transport-level tuning knobs for the RPC listener.
|
||||
pub struct RpcServerTransportConfig {
|
||||
/// Maximum simultaneously active connection workers allowed by the listener.
|
||||
pub max_connections: usize,
|
||||
/// Idle keep-alive window in seconds.
|
||||
pub keep_alive_secs: u64,
|
||||
/// Per-request timeout in seconds.
|
||||
pub request_timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for RpcServerTransportConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_connections: 64,
|
||||
keep_alive_secs: 30,
|
||||
request_timeout_secs: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcServerTransportConfig {
|
||||
/// Returns the effective per-request timeout, clamped away from zero.
|
||||
fn request_timeout(self) -> Duration {
|
||||
Duration::from_secs(self.request_timeout_secs.max(1))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// Lightweight transport state exposed for tests and integration wiring.
|
||||
pub struct RpcServerTransport {
|
||||
/// Static transport configuration.
|
||||
pub config: RpcServerTransportConfig,
|
||||
/// Number of accepted connections observed so far.
|
||||
pub accepted_connections: usize,
|
||||
}
|
||||
|
||||
impl RpcServerTransport {
|
||||
#[must_use]
|
||||
/// Creates a new transport state wrapper from the provided configuration.
|
||||
pub fn new(config: RpcServerTransportConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
accepted_connections: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves RPC requests for the provided configuration and dispatcher.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when listener setup, socket accept, or transport I/O fails.
|
||||
pub fn serve_rpc_listener(
|
||||
listener: TcpListener,
|
||||
config: RpcServerConfig,
|
||||
dispatcher: Arc<Mutex<InProcessRpcDispatcher>>,
|
||||
) -> io::Result<()> {
|
||||
RpcListenerRuntime::new(
|
||||
listener,
|
||||
config,
|
||||
RpcServerTransport::new(RpcServerTransportConfig::default()),
|
||||
dispatcher,
|
||||
)?
|
||||
.serve()
|
||||
}
|
||||
|
||||
/// Listener runtime that bounds accepted connection workers and WebSocket session state.
|
||||
struct RpcListenerRuntime {
|
||||
/// Bound TCP listener used to accept RPC connections.
|
||||
listener: TcpListener,
|
||||
/// Shared RPC server configuration applied to each accepted connection.
|
||||
config: RpcServerConfig,
|
||||
/// Transport-level counters and tuning knobs for the listener loop.
|
||||
transport: RpcServerTransport,
|
||||
/// Shared in-process RPC dispatcher used by HTTP and WebSocket requests.
|
||||
dispatcher: Arc<Mutex<InProcessRpcDispatcher>>,
|
||||
/// Shared WebSocket session registry bridged from runtime events.
|
||||
websocket_sessions: Arc<Mutex<WebSocketSessionRegistry>>,
|
||||
/// Monotonic identifier source for upgraded WebSocket sessions.
|
||||
next_websocket_session_id: u64,
|
||||
/// Number of connection workers currently running.
|
||||
active_connection_workers: usize,
|
||||
/// Receive side of worker completion notifications.
|
||||
worker_results_rx: Receiver<io::Result<()>>,
|
||||
/// Send side cloned into worker threads for completion notifications.
|
||||
worker_results_tx: Sender<io::Result<()>>,
|
||||
}
|
||||
|
||||
impl RpcListenerRuntime {
|
||||
/// Builds a nonblocking listener runtime and attaches the runtime event bridge.
|
||||
fn new(
|
||||
listener: TcpListener,
|
||||
config: RpcServerConfig,
|
||||
transport: RpcServerTransport,
|
||||
dispatcher: Arc<Mutex<InProcessRpcDispatcher>>,
|
||||
) -> io::Result<Self> {
|
||||
listener.set_nonblocking(true)?;
|
||||
let websocket_sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default()));
|
||||
let (worker_results_tx, worker_results_rx) = mpsc::channel();
|
||||
{
|
||||
let mut dispatcher = dispatcher
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("rpc dispatcher mutex poisoned"))?;
|
||||
dispatcher.register_runtime_listener(RuntimeEventWebSocketBridge::new(Arc::clone(
|
||||
&websocket_sessions,
|
||||
)));
|
||||
}
|
||||
Ok(Self {
|
||||
listener,
|
||||
config,
|
||||
transport,
|
||||
dispatcher,
|
||||
websocket_sessions,
|
||||
next_websocket_session_id: 1,
|
||||
active_connection_workers: 0,
|
||||
worker_results_rx,
|
||||
worker_results_tx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serves accepted connections until the listener returns a terminal I/O error.
|
||||
fn serve(mut self) -> io::Result<()> {
|
||||
loop {
|
||||
self.drain_completed_connection_workers()?;
|
||||
if self.active_connection_workers >= self.connection_worker_limit() {
|
||||
self.wait_for_connection_capacity()?;
|
||||
continue;
|
||||
}
|
||||
match self.listener.accept() {
|
||||
Ok((stream, _addr)) => self.spawn_ready_connection_worker(stream),
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a worker thread for one accepted connection and tracks its completion.
|
||||
fn spawn_ready_connection_worker(&mut self, stream: TcpStream) {
|
||||
self.transport.accepted_connections = self.transport.accepted_connections.saturating_add(1);
|
||||
self.active_connection_workers = self.active_connection_workers.saturating_add(1);
|
||||
let session_id = format!("ws-{}", self.next_websocket_session_id);
|
||||
self.next_websocket_session_id = self.next_websocket_session_id.saturating_add(1);
|
||||
let request_timeout = self.transport.config.request_timeout();
|
||||
let config = self.config.clone();
|
||||
let dispatcher = Arc::clone(&self.dispatcher);
|
||||
let websocket_sessions = Arc::clone(&self.websocket_sessions);
|
||||
let worker_results_tx = self.worker_results_tx.clone();
|
||||
thread::spawn(move || {
|
||||
let result = serve_ready_connection(
|
||||
stream,
|
||||
request_timeout,
|
||||
&config,
|
||||
dispatcher,
|
||||
websocket_sessions,
|
||||
session_id,
|
||||
);
|
||||
let _ = worker_results_tx.send(result);
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns the effective maximum number of concurrent connection workers.
|
||||
fn connection_worker_limit(&self) -> usize {
|
||||
self.transport.config.max_connections.max(1)
|
||||
}
|
||||
|
||||
/// Drains all currently completed worker results and propagates terminal errors.
|
||||
fn drain_completed_connection_workers(&mut self) -> io::Result<()> {
|
||||
loop {
|
||||
match self.worker_results_rx.try_recv() {
|
||||
Ok(result) => {
|
||||
self.active_connection_workers =
|
||||
self.active_connection_workers.saturating_sub(1);
|
||||
result?;
|
||||
}
|
||||
Err(TryRecvError::Empty) => return Ok(()),
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
if self.active_connection_workers == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(io::Error::other("rpc worker result channel disconnected"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits briefly until at least one connection-worker slot becomes available.
|
||||
fn wait_for_connection_capacity(&mut self) -> io::Result<()> {
|
||||
match self
|
||||
.worker_results_rx
|
||||
.recv_timeout(Duration::from_millis(25))
|
||||
{
|
||||
Ok(result) => {
|
||||
self.active_connection_workers = self.active_connection_workers.saturating_sub(1);
|
||||
result?;
|
||||
self.drain_completed_connection_workers()
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => Ok(()),
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
if self.active_connection_workers == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
Err(io::Error::other("rpc worker result channel disconnected"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn serve_until_accept_count(mut self, accept_limit: usize) -> io::Result<()> {
|
||||
loop {
|
||||
self.drain_completed_connection_workers()?;
|
||||
if self.transport.accepted_connections >= accept_limit {
|
||||
if self.active_connection_workers == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
self.wait_for_connection_capacity()?;
|
||||
continue;
|
||||
}
|
||||
if self.active_connection_workers >= self.connection_worker_limit() {
|
||||
self.wait_for_connection_capacity()?;
|
||||
continue;
|
||||
}
|
||||
match self.listener.accept() {
|
||||
Ok((stream, _addr)) => self.spawn_ready_connection_worker(stream),
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves one already-accepted TCP connection through HTTP or WebSocket RPC handling.
|
||||
fn serve_ready_connection(
|
||||
mut stream: TcpStream,
|
||||
request_timeout: Duration,
|
||||
config: &RpcServerConfig,
|
||||
dispatcher: Arc<Mutex<InProcessRpcDispatcher>>,
|
||||
websocket_sessions: Arc<Mutex<WebSocketSessionRegistry>>,
|
||||
session_id: String,
|
||||
) -> io::Result<()> {
|
||||
// The listener runs in nonblocking mode so accept loops can poll capacity,
|
||||
// but worker-owned client streams should use blocking IO with timeouts.
|
||||
stream.set_nonblocking(false)?;
|
||||
stream.set_write_timeout(Some(request_timeout))?;
|
||||
if let Some(request) = read_http_request(&mut stream, request_timeout)? {
|
||||
let websocket_candidate = is_websocket_upgrade_candidate(&request);
|
||||
let response = handle_rpc_http_request_shared(&dispatcher, config, request)?;
|
||||
let _ = stream.write_all(&response);
|
||||
let _ = stream.flush();
|
||||
|
||||
if websocket_candidate && response.starts_with(b"HTTP/1.1 101 ") {
|
||||
serve_upgraded_websocket_session(
|
||||
stream,
|
||||
config,
|
||||
dispatcher,
|
||||
websocket_sessions,
|
||||
session_id,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
io::{self, Read},
|
||||
net::Shutdown,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn http_header_end(buffer: &[u8]) -> Option<usize> {
|
||||
buffer
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.map(|position| position + 4)
|
||||
}
|
||||
|
||||
fn expected_http_response_len(buffer: &[u8]) -> Option<usize> {
|
||||
let header_end = http_header_end(buffer)?;
|
||||
let headers = std::str::from_utf8(&buffer[..header_end])
|
||||
.expect("http response headers should be valid utf8");
|
||||
let content_length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
if name.eq_ignore_ascii_case("Content-Length") {
|
||||
return Some(
|
||||
value
|
||||
.trim()
|
||||
.parse::<usize>()
|
||||
.expect("content-length should be a valid usize"),
|
||||
);
|
||||
}
|
||||
None
|
||||
})
|
||||
.unwrap_or(0);
|
||||
Some(header_end + content_length)
|
||||
}
|
||||
|
||||
fn read_http_response(stream: &mut TcpStream) -> String {
|
||||
let mut buffer = Vec::new();
|
||||
let mut chunk = [0_u8; 512];
|
||||
loop {
|
||||
match stream.read(&mut chunk) {
|
||||
Ok(0) => break,
|
||||
Ok(read) => {
|
||||
buffer.extend_from_slice(&chunk[..read]);
|
||||
if let Some(expected_len) = expected_http_response_len(&buffer)
|
||||
&& buffer.len() >= expected_len
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::ConnectionReset => {
|
||||
if let Some(expected_len) = expected_http_response_len(&buffer)
|
||||
&& buffer.len() >= expected_len
|
||||
{
|
||||
break;
|
||||
}
|
||||
if http_header_end(&buffer).is_some() {
|
||||
break;
|
||||
}
|
||||
panic!(
|
||||
"http response reset before completion: {error}; partial={}",
|
||||
String::from_utf8_lossy(&buffer)
|
||||
);
|
||||
}
|
||||
Err(error) => panic!("http response should be readable: {error}"),
|
||||
}
|
||||
}
|
||||
String::from_utf8(buffer).expect("response should be valid utf8")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_websocket_worker_does_not_block_a_second_http_rpc_client() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener should bind");
|
||||
let addr = listener
|
||||
.local_addr()
|
||||
.expect("listener address should resolve");
|
||||
let runtime = RpcListenerRuntime::new(
|
||||
listener,
|
||||
RpcServerConfig {
|
||||
enable_websocket_rpc: true,
|
||||
..RpcServerConfig::default()
|
||||
},
|
||||
RpcServerTransport::new(RpcServerTransportConfig {
|
||||
max_connections: 2,
|
||||
..RpcServerTransportConfig::default()
|
||||
}),
|
||||
Arc::new(Mutex::new(InProcessRpcDispatcher::new())),
|
||||
)
|
||||
.expect("listener runtime should initialize");
|
||||
|
||||
let server = thread::spawn(move || {
|
||||
runtime
|
||||
.serve_until_accept_count(2)
|
||||
.expect("listener runtime should serve two clients");
|
||||
});
|
||||
|
||||
let mut websocket_client =
|
||||
TcpStream::connect(addr).expect("websocket client should connect");
|
||||
websocket_client
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.expect("websocket client timeout should configure");
|
||||
websocket_client
|
||||
.write_all(
|
||||
concat!(
|
||||
"GET /jsonrpc HTTP/1.1\r\n",
|
||||
"Host: 127.0.0.1\r\n",
|
||||
"Upgrade: websocket\r\n",
|
||||
"Connection: Upgrade\r\n",
|
||||
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n",
|
||||
"Sec-WebSocket-Version: 13\r\n",
|
||||
"\r\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("websocket upgrade request should write");
|
||||
websocket_client
|
||||
.flush()
|
||||
.expect("websocket upgrade request should flush");
|
||||
let websocket_handshake = read_http_response(&mut websocket_client);
|
||||
assert!(
|
||||
websocket_handshake.starts_with("HTTP/1.1 101 "),
|
||||
"unexpected websocket handshake: {websocket_handshake:?}"
|
||||
);
|
||||
|
||||
let mut http_client = TcpStream::connect(addr).expect("http client should connect");
|
||||
http_client
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.expect("http client timeout should configure");
|
||||
let jsonrpc_body = r#"{"jsonrpc":"2.0","id":7,"method":"aria2.getVersion","params":[]}"#;
|
||||
let http_request = format!(
|
||||
concat!(
|
||||
"POST /jsonrpc HTTP/1.1\r\n",
|
||||
"Host: 127.0.0.1\r\n",
|
||||
"Content-Type: application/json\r\n",
|
||||
"Content-Length: {}\r\n",
|
||||
"\r\n",
|
||||
"{}"
|
||||
),
|
||||
jsonrpc_body.len(),
|
||||
jsonrpc_body
|
||||
);
|
||||
http_client
|
||||
.write_all(http_request.as_bytes())
|
||||
.expect("jsonrpc request should write");
|
||||
http_client.flush().expect("jsonrpc request should flush");
|
||||
let http_response = read_http_response(&mut http_client);
|
||||
assert!(
|
||||
http_response.contains("200 OK"),
|
||||
"unexpected http status response: {http_response:?}"
|
||||
);
|
||||
assert!(
|
||||
http_response.contains(r#""id":7"#),
|
||||
"missing jsonrpc id in response: {http_response:?}"
|
||||
);
|
||||
assert!(
|
||||
http_response.contains(r#""version""#),
|
||||
"missing version payload in response: {http_response:?}"
|
||||
);
|
||||
|
||||
http_client
|
||||
.shutdown(Shutdown::Both)
|
||||
.expect("http client shutdown should succeed");
|
||||
websocket_client
|
||||
.shutdown(Shutdown::Both)
|
||||
.expect("websocket client shutdown should succeed");
|
||||
drop(http_client);
|
||||
drop(websocket_client);
|
||||
server.join().expect("server thread should join");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use std::{
|
||||
io,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
InProcessRpcDispatcher,
|
||||
jsonrpc::{
|
||||
JsonRpcPayload, JsonRpcResponse, jsonrpc_batch_response_to_json, jsonrpc_payload_from_json,
|
||||
jsonrpc_response_to_json,
|
||||
},
|
||||
model::{RpcError, RpcErrorCode},
|
||||
websocket::RpcWebSocketFrame,
|
||||
};
|
||||
|
||||
use super::{RpcServerConfig, http_surface::dispatch_json_request};
|
||||
|
||||
/// Converts a WebSocket parse failure into the JSON-RPC error frame expected by clients.
|
||||
fn websocket_parse_error_response(message: String) -> RpcWebSocketFrame {
|
||||
let response = JsonRpcResponse::error(
|
||||
None,
|
||||
RpcError {
|
||||
code: if message.contains("must not be empty") {
|
||||
RpcErrorCode::InvalidRequest
|
||||
} else {
|
||||
RpcErrorCode::ParseError
|
||||
},
|
||||
kind: crate::model::RpcErrorKind::InvalidParams,
|
||||
message,
|
||||
},
|
||||
);
|
||||
let body = jsonrpc_response_to_json(&response)
|
||||
.unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#));
|
||||
RpcWebSocketFrame::Text(body)
|
||||
}
|
||||
|
||||
/// Dispatches a text WebSocket frame as a JSON-RPC request or batch payload.
|
||||
#[cfg(test)]
|
||||
fn dispatch_websocket_text_frame(
|
||||
dispatcher: &mut InProcessRpcDispatcher,
|
||||
config: &RpcServerConfig,
|
||||
text: &str,
|
||||
) -> Option<RpcWebSocketFrame> {
|
||||
match parse_websocket_jsonrpc_payload(text) {
|
||||
Ok(payload) => dispatch_websocket_payload(dispatcher, config, payload),
|
||||
Err(error) => Some(websocket_parse_error_response(error)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a WebSocket JSON-RPC payload before any dispatcher locking occurs.
|
||||
fn parse_websocket_jsonrpc_payload(text: &str) -> Result<JsonRpcPayload, String> {
|
||||
jsonrpc_payload_from_json(text)
|
||||
}
|
||||
|
||||
/// Dispatches a previously parsed WebSocket JSON-RPC payload.
|
||||
fn dispatch_websocket_payload(
|
||||
dispatcher: &mut InProcessRpcDispatcher,
|
||||
config: &RpcServerConfig,
|
||||
payload: JsonRpcPayload,
|
||||
) -> Option<RpcWebSocketFrame> {
|
||||
match payload {
|
||||
JsonRpcPayload::Single(request) => {
|
||||
let response = dispatch_json_request(dispatcher, config, request);
|
||||
if response.id.is_none() {
|
||||
return None;
|
||||
}
|
||||
let body = jsonrpc_response_to_json(&response)
|
||||
.unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#));
|
||||
Some(RpcWebSocketFrame::Text(body))
|
||||
}
|
||||
JsonRpcPayload::Batch(items) => {
|
||||
let mut responses = Vec::new();
|
||||
for item in items {
|
||||
match item {
|
||||
Ok(request) => {
|
||||
let response = dispatch_json_request(dispatcher, config, request);
|
||||
if response.id.is_some() {
|
||||
responses.push(response);
|
||||
}
|
||||
}
|
||||
Err(error) => responses.push(JsonRpcResponse::error(
|
||||
None,
|
||||
RpcError {
|
||||
code: RpcErrorCode::InvalidRequest,
|
||||
kind: crate::model::RpcErrorKind::InvalidParams,
|
||||
message: error,
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
let body = jsonrpc_batch_response_to_json(&responses)
|
||||
.unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#));
|
||||
Some(RpcWebSocketFrame::Text(body))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes a decoded WebSocket frame through shared dispatcher access while keeping
|
||||
/// control-frame and parse-error handling outside the dispatcher mutex.
|
||||
pub(super) fn handle_websocket_rpc_frame_shared(
|
||||
dispatcher: &Arc<Mutex<InProcessRpcDispatcher>>,
|
||||
config: &RpcServerConfig,
|
||||
frame: RpcWebSocketFrame,
|
||||
) -> io::Result<Option<RpcWebSocketFrame>> {
|
||||
match frame {
|
||||
RpcWebSocketFrame::Text(text) => {
|
||||
dispatch_websocket_text_frame_shared(dispatcher, config, &text)
|
||||
}
|
||||
RpcWebSocketFrame::Binary(payload) => match String::from_utf8(payload) {
|
||||
Ok(text) => dispatch_websocket_text_frame_shared(dispatcher, config, &text),
|
||||
Err(error) => Ok(Some(websocket_parse_error_response(error.to_string()))),
|
||||
},
|
||||
RpcWebSocketFrame::Ping(payload) => Ok(Some(RpcWebSocketFrame::Pong(payload))),
|
||||
RpcWebSocketFrame::Pong(_) => Ok(None),
|
||||
RpcWebSocketFrame::Close => Ok(Some(RpcWebSocketFrame::Close)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatches a text WebSocket frame while locking the dispatcher only for the
|
||||
/// actual JSON-RPC execution path.
|
||||
fn dispatch_websocket_text_frame_shared(
|
||||
dispatcher: &Arc<Mutex<InProcessRpcDispatcher>>,
|
||||
config: &RpcServerConfig,
|
||||
text: &str,
|
||||
) -> io::Result<Option<RpcWebSocketFrame>> {
|
||||
let payload = match parse_websocket_jsonrpc_payload(text) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => return Ok(Some(websocket_parse_error_response(error))),
|
||||
};
|
||||
let mut dispatcher = dispatcher
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("rpc dispatcher mutex poisoned"))?;
|
||||
Ok(dispatch_websocket_payload(&mut dispatcher, config, payload))
|
||||
}
|
||||
|
||||
/// Routes a decoded WebSocket frame through the RPC dispatch rules.
|
||||
#[cfg(test)]
|
||||
pub(super) fn handle_websocket_rpc_frame(
|
||||
dispatcher: &mut InProcessRpcDispatcher,
|
||||
config: &RpcServerConfig,
|
||||
frame: RpcWebSocketFrame,
|
||||
) -> Option<RpcWebSocketFrame> {
|
||||
match frame {
|
||||
RpcWebSocketFrame::Text(text) => dispatch_websocket_text_frame(dispatcher, config, &text),
|
||||
RpcWebSocketFrame::Binary(payload) => match String::from_utf8(payload) {
|
||||
Ok(text) => dispatch_websocket_text_frame(dispatcher, config, &text),
|
||||
Err(error) => Some(websocket_parse_error_response(error.to_string())),
|
||||
},
|
||||
RpcWebSocketFrame::Ping(payload) => Some(RpcWebSocketFrame::Pong(payload)),
|
||||
RpcWebSocketFrame::Pong(_) => None,
|
||||
RpcWebSocketFrame::Close => Some(RpcWebSocketFrame::Close),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Processes a sequence of test-only frames until one requests that the session close.
|
||||
pub(super) fn process_websocket_session_frames(
|
||||
dispatcher: &mut InProcessRpcDispatcher,
|
||||
config: &RpcServerConfig,
|
||||
frames: impl IntoIterator<Item = RpcWebSocketFrame>,
|
||||
) -> Vec<RpcWebSocketFrame> {
|
||||
let mut responses = Vec::new();
|
||||
for frame in frames {
|
||||
let should_close = matches!(frame, RpcWebSocketFrame::Close);
|
||||
if let Some(response) = handle_websocket_rpc_frame(dispatcher, config, frame) {
|
||||
let response_is_close = matches!(response, RpcWebSocketFrame::Close);
|
||||
responses.push(response);
|
||||
if response_is_close {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if should_close {
|
||||
break;
|
||||
}
|
||||
}
|
||||
responses
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use base64::Engine;
|
||||
use sha1::{Digest, Sha1};
|
||||
|
||||
use super::{
|
||||
RpcServerConfig,
|
||||
http_surface::{
|
||||
HttpRequest, contains_ascii_case_insensitive, header_value, http_response,
|
||||
http_response_with_headers, normalized_rpc_path,
|
||||
},
|
||||
};
|
||||
|
||||
/// Computes the `Sec-WebSocket-Accept` value for a client-provided handshake key.
|
||||
fn websocket_accept_value(key: &str) -> String {
|
||||
let mut sha1 = Sha1::new();
|
||||
sha1.update(key.as_bytes());
|
||||
sha1.update(b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
|
||||
let digest = sha1.finalize();
|
||||
base64::engine::general_purpose::STANDARD.encode(digest)
|
||||
}
|
||||
|
||||
/// Builds the HTTP upgrade response for a successful RPC WebSocket handshake.
|
||||
pub(super) fn websocket_upgrade_response(
|
||||
config: &RpcServerConfig,
|
||||
request: &HttpRequest,
|
||||
) -> Vec<u8> {
|
||||
if !config.enable_websocket_rpc {
|
||||
return http_response("404 Not Found", "text/plain", b"websocket-rpc disabled");
|
||||
}
|
||||
if normalized_rpc_path(&request.path) != "/jsonrpc" {
|
||||
return http_response("404 Not Found", "text/plain", b"unknown websocket path");
|
||||
}
|
||||
if !request.method.eq_ignore_ascii_case("GET") {
|
||||
return http_response(
|
||||
"405 Method Not Allowed",
|
||||
"text/plain",
|
||||
b"websocket upgrade requires GET",
|
||||
);
|
||||
}
|
||||
let Some(key) = header_value(&request.headers, "sec-websocket-key") else {
|
||||
return http_response(
|
||||
"400 Bad Request",
|
||||
"text/plain",
|
||||
b"missing sec-websocket-key",
|
||||
);
|
||||
};
|
||||
if !header_value(&request.headers, "upgrade")
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("websocket"))
|
||||
{
|
||||
return http_response(
|
||||
"400 Bad Request",
|
||||
"text/plain",
|
||||
b"missing websocket upgrade header",
|
||||
);
|
||||
}
|
||||
if !header_value(&request.headers, "connection")
|
||||
.is_some_and(|value| contains_ascii_case_insensitive(value, "upgrade"))
|
||||
{
|
||||
return http_response(
|
||||
"400 Bad Request",
|
||||
"text/plain",
|
||||
b"missing connection upgrade header",
|
||||
);
|
||||
}
|
||||
if header_value(&request.headers, "sec-websocket-version")
|
||||
.is_none_or(|value| value.trim() != "13")
|
||||
{
|
||||
return http_response_with_headers(
|
||||
"426 Upgrade Required",
|
||||
Some("text/plain"),
|
||||
&[("Sec-WebSocket-Version", "13".to_owned())],
|
||||
b"unsupported websocket version",
|
||||
);
|
||||
}
|
||||
|
||||
let mut headers = vec![
|
||||
("Upgrade", "websocket".to_owned()),
|
||||
("Connection", "Upgrade".to_owned()),
|
||||
("Sec-WebSocket-Accept", websocket_accept_value(key)),
|
||||
];
|
||||
if let Some(origin) = &config.allow_origin {
|
||||
headers.push(("Access-Control-Allow-Origin", origin.clone()));
|
||||
}
|
||||
http_response_with_headers("101 Switching Protocols", None, &headers, b"")
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use std::{
|
||||
io::{self, Write},
|
||||
net::TcpStream,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
InProcessRpcDispatcher,
|
||||
websocket::{RpcWebSocketFrame, WebSocketSessionRegistry},
|
||||
};
|
||||
|
||||
use super::{
|
||||
RpcServerConfig, WEBSOCKET_IDLE_POLL_INTERVAL,
|
||||
websocket_dispatch::handle_websocket_rpc_frame_shared,
|
||||
websocket_wire::{read_websocket_frame, websocket_frame_to_bytes},
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
/// Flushes any queued outbound frames for a WebSocket session to the client stream.
|
||||
pub(super) fn flush_websocket_session_queue(
|
||||
stream: &mut TcpStream,
|
||||
sessions: &mut WebSocketSessionRegistry,
|
||||
session_id: &str,
|
||||
) -> io::Result<usize> {
|
||||
let mut flushed = 0usize;
|
||||
while let Some(frame) = sessions.pop_frame(session_id) {
|
||||
let payload = websocket_frame_to_bytes(&frame);
|
||||
stream.write_all(&payload)?;
|
||||
stream.flush()?;
|
||||
flushed += 1;
|
||||
}
|
||||
Ok(flushed)
|
||||
}
|
||||
|
||||
/// Flushes queued outbound frames without holding the shared session mutex across socket IO.
|
||||
pub(super) fn flush_websocket_session_queue_shared(
|
||||
stream: &mut TcpStream,
|
||||
sessions: &Arc<Mutex<WebSocketSessionRegistry>>,
|
||||
session_id: &str,
|
||||
) -> io::Result<usize> {
|
||||
let frames = {
|
||||
let mut sessions = sessions
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("websocket session mutex poisoned"))?;
|
||||
sessions.drain_session_frames(session_id)
|
||||
};
|
||||
let mut flushed = 0usize;
|
||||
for frame in frames {
|
||||
let payload = websocket_frame_to_bytes(&frame);
|
||||
stream.write_all(&payload)?;
|
||||
stream.flush()?;
|
||||
flushed += 1;
|
||||
}
|
||||
Ok(flushed)
|
||||
}
|
||||
|
||||
/// Runs the request and notification loop for a successfully upgraded RPC WebSocket session.
|
||||
pub(super) fn serve_upgraded_websocket_session(
|
||||
mut stream: TcpStream,
|
||||
config: &RpcServerConfig,
|
||||
dispatcher: Arc<Mutex<InProcessRpcDispatcher>>,
|
||||
sessions: Arc<Mutex<WebSocketSessionRegistry>>,
|
||||
session_id: String,
|
||||
) -> io::Result<()> {
|
||||
stream.set_read_timeout(Some(WEBSOCKET_IDLE_POLL_INTERVAL))?;
|
||||
if let Ok(mut sessions) = sessions.lock() {
|
||||
sessions.connect(session_id.clone());
|
||||
}
|
||||
|
||||
let result = loop {
|
||||
let _ = flush_websocket_session_queue_shared(&mut stream, &sessions, &session_id);
|
||||
|
||||
let frame = match read_websocket_frame(&mut stream) {
|
||||
Ok(frame) => frame,
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock
|
||||
) =>
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Err(error) => break Err(error),
|
||||
};
|
||||
|
||||
let Some(frame) = frame else {
|
||||
break Ok(());
|
||||
};
|
||||
|
||||
let response_frame = handle_websocket_rpc_frame_shared(&dispatcher, config, frame)?;
|
||||
if let Some(frame) = response_frame {
|
||||
let should_close = matches!(frame, RpcWebSocketFrame::Close);
|
||||
let payload = websocket_frame_to_bytes(&frame);
|
||||
stream.write_all(&payload)?;
|
||||
stream.flush()?;
|
||||
let _ = flush_websocket_session_queue_shared(&mut stream, &sessions, &session_id);
|
||||
if should_close {
|
||||
break Ok(());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let _ = flush_websocket_session_queue_shared(&mut stream, &sessions, &session_id);
|
||||
if let Ok(mut sessions) = sessions.lock() {
|
||||
sessions.disconnect(&session_id);
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! WebSocket RPC surface facade.
|
||||
//!
|
||||
//! The HTTP server and tests historically imported the WebSocket helpers from
|
||||
//! this module. Keep that boundary stable while the concrete responsibilities
|
||||
//! live in narrower sibling modules.
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) use super::{
|
||||
websocket_dispatch::{handle_websocket_rpc_frame, process_websocket_session_frames},
|
||||
websocket_session::flush_websocket_session_queue,
|
||||
websocket_wire::{read_websocket_frame, websocket_frame_from_bytes, websocket_frame_to_bytes},
|
||||
};
|
||||
pub(super) use super::{
|
||||
websocket_handshake::websocket_upgrade_response,
|
||||
websocket_session::serve_upgraded_websocket_session,
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
use std::{
|
||||
io::{self, Read},
|
||||
net::TcpStream,
|
||||
};
|
||||
|
||||
use crate::websocket::RpcWebSocketFrame;
|
||||
|
||||
/// Encodes an internal WebSocket frame into wire bytes.
|
||||
pub(super) fn websocket_frame_to_bytes(frame: &RpcWebSocketFrame) -> Vec<u8> {
|
||||
let (opcode, payload): (u8, Vec<u8>) = match frame {
|
||||
RpcWebSocketFrame::Text(text) => (0x1, text.as_bytes().to_vec()),
|
||||
RpcWebSocketFrame::Binary(payload) => (0x2, payload.clone()),
|
||||
RpcWebSocketFrame::Ping(payload) => (0x9, payload.clone()),
|
||||
RpcWebSocketFrame::Pong(payload) => (0xA, payload.clone()),
|
||||
RpcWebSocketFrame::Close => (0x8, Vec::new()),
|
||||
};
|
||||
|
||||
let mut bytes = Vec::with_capacity(payload.len() + 10);
|
||||
bytes.push(0x80 | opcode);
|
||||
let payload_len = payload.len();
|
||||
if payload_len <= 125 {
|
||||
bytes.push(payload_len as u8);
|
||||
} else if u16::try_from(payload_len).is_ok() {
|
||||
bytes.push(126);
|
||||
bytes.extend_from_slice(&(payload_len as u16).to_be_bytes());
|
||||
} else {
|
||||
bytes.push(127);
|
||||
bytes.extend_from_slice(&(payload_len as u64).to_be_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(&payload);
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Decodes a complete WebSocket frame from wire bytes.
|
||||
pub(super) fn websocket_frame_from_bytes(bytes: &[u8]) -> Result<RpcWebSocketFrame, String> {
|
||||
if bytes.len() < 2 {
|
||||
return Err("websocket frame too short".to_owned());
|
||||
}
|
||||
let fin = bytes[0] & 0x80 != 0;
|
||||
if !fin {
|
||||
return Err("fragmented websocket frames are unsupported".to_owned());
|
||||
}
|
||||
let opcode = bytes[0] & 0x0F;
|
||||
let masked = bytes[1] & 0x80 != 0;
|
||||
let mut payload_len = usize::from(bytes[1] & 0x7F);
|
||||
let mut offset = 2usize;
|
||||
if payload_len == 126 {
|
||||
if bytes.len() < offset + 2 {
|
||||
return Err("truncated websocket extended length".to_owned());
|
||||
}
|
||||
payload_len = usize::from(u16::from_be_bytes([bytes[offset], bytes[offset + 1]]));
|
||||
offset += 2;
|
||||
} else if payload_len == 127 {
|
||||
if bytes.len() < offset + 8 {
|
||||
return Err("truncated websocket extended length".to_owned());
|
||||
}
|
||||
payload_len = u64::from_be_bytes([
|
||||
bytes[offset],
|
||||
bytes[offset + 1],
|
||||
bytes[offset + 2],
|
||||
bytes[offset + 3],
|
||||
bytes[offset + 4],
|
||||
bytes[offset + 5],
|
||||
bytes[offset + 6],
|
||||
bytes[offset + 7],
|
||||
])
|
||||
.try_into()
|
||||
.map_err(|_| "websocket frame too large".to_owned())?;
|
||||
offset += 8;
|
||||
}
|
||||
|
||||
let mask = if masked {
|
||||
if bytes.len() < offset + 4 {
|
||||
return Err("truncated websocket mask".to_owned());
|
||||
}
|
||||
let mask = [
|
||||
bytes[offset],
|
||||
bytes[offset + 1],
|
||||
bytes[offset + 2],
|
||||
bytes[offset + 3],
|
||||
];
|
||||
offset += 4;
|
||||
Some(mask)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if bytes.len() < offset + payload_len {
|
||||
return Err("truncated websocket payload".to_owned());
|
||||
}
|
||||
let mut payload = bytes[offset..offset + payload_len].to_vec();
|
||||
if let Some(mask) = mask {
|
||||
for (index, byte) in payload.iter_mut().enumerate() {
|
||||
*byte ^= mask[index % 4];
|
||||
}
|
||||
}
|
||||
|
||||
match opcode {
|
||||
0x1 => String::from_utf8(payload)
|
||||
.map(RpcWebSocketFrame::Text)
|
||||
.map_err(|error| error.to_string()),
|
||||
0x2 => Ok(RpcWebSocketFrame::Binary(payload)),
|
||||
0x8 => Ok(RpcWebSocketFrame::Close),
|
||||
0x9 => Ok(RpcWebSocketFrame::Ping(payload)),
|
||||
0xA => Ok(RpcWebSocketFrame::Pong(payload)),
|
||||
_ => Err(format!("unsupported websocket opcode: {opcode}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads an exact byte count while treating clean EOF as `false` rather than an error.
|
||||
fn read_exact_or_eof(stream: &mut TcpStream, buffer: &mut [u8]) -> io::Result<bool> {
|
||||
let mut offset = 0usize;
|
||||
while offset < buffer.len() {
|
||||
let read = stream.read(&mut buffer[offset..])?;
|
||||
if read == 0 {
|
||||
if offset == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"truncated websocket frame",
|
||||
));
|
||||
}
|
||||
offset += read;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Reads one WebSocket frame from the upgraded TCP stream.
|
||||
pub(super) fn read_websocket_frame(
|
||||
stream: &mut TcpStream,
|
||||
) -> io::Result<Option<RpcWebSocketFrame>> {
|
||||
let mut header = [0_u8; 2];
|
||||
if !read_exact_or_eof(stream, &mut header)? {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut frame = header.to_vec();
|
||||
let payload_len_marker = usize::from(header[1] & 0x7F);
|
||||
if payload_len_marker == 126 {
|
||||
let mut extended = [0_u8; 2];
|
||||
read_exact_or_eof(stream, &mut extended)?;
|
||||
frame.extend_from_slice(&extended);
|
||||
} else if payload_len_marker == 127 {
|
||||
let mut extended = [0_u8; 8];
|
||||
read_exact_or_eof(stream, &mut extended)?;
|
||||
frame.extend_from_slice(&extended);
|
||||
}
|
||||
|
||||
if header[1] & 0x80 != 0 {
|
||||
let mut mask = [0_u8; 4];
|
||||
read_exact_or_eof(stream, &mut mask)?;
|
||||
frame.extend_from_slice(&mask);
|
||||
}
|
||||
|
||||
let payload_len = if payload_len_marker <= 125 {
|
||||
payload_len_marker
|
||||
} else if payload_len_marker == 126 {
|
||||
usize::from(u16::from_be_bytes([frame[2], frame[3]]))
|
||||
} else {
|
||||
u64::from_be_bytes([
|
||||
frame[2], frame[3], frame[4], frame[5], frame[6], frame[7], frame[8], frame[9],
|
||||
])
|
||||
.try_into()
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "websocket frame too large"))?
|
||||
};
|
||||
|
||||
let mut payload = vec![0_u8; payload_len];
|
||||
if payload_len > 0 {
|
||||
read_exact_or_eof(stream, &mut payload)?;
|
||||
frame.extend_from_slice(&payload);
|
||||
}
|
||||
|
||||
websocket_frame_from_bytes(&frame)
|
||||
.map(Some)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Session and token state used by the RPC transports.
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
use crate::model::RpcAuthContext;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// Authentication token issued to an RPC client session.
|
||||
pub struct RpcAuthToken {
|
||||
/// Opaque token value presented by the client.
|
||||
pub value: String,
|
||||
/// Time at which the token was issued.
|
||||
pub issued_at: SystemTime,
|
||||
/// Optional validity window for the token.
|
||||
pub expires_in: Option<Duration>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// Snapshot of a connected RPC session.
|
||||
pub struct RpcSessionInfo {
|
||||
/// Stable session identifier.
|
||||
pub session_id: String,
|
||||
/// Remote peer address, if the transport exposes it.
|
||||
pub peer_addr: Option<String>,
|
||||
/// Authentication state bound to the session.
|
||||
pub auth: RpcAuthContext,
|
||||
/// Time at which the session was created.
|
||||
pub created_at: SystemTime,
|
||||
/// Most recent activity timestamp.
|
||||
pub last_seen_at: SystemTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// Complete RPC session state, including token material.
|
||||
pub struct RpcSession {
|
||||
/// Session metadata visible to callers.
|
||||
pub info: RpcSessionInfo,
|
||||
/// Current authentication token, if one has been issued.
|
||||
pub token: Option<RpcAuthToken>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
/// In-memory store for active RPC sessions.
|
||||
pub struct RpcSessionStore {
|
||||
/// Session records keyed by their stable session identifier.
|
||||
sessions: BTreeMap<String, RpcSession>,
|
||||
}
|
||||
|
||||
impl RpcSessionStore {
|
||||
/// Inserts or replaces a session keyed by its session identifier.
|
||||
pub fn insert(&mut self, session: RpcSession) {
|
||||
self.sessions
|
||||
.insert(session.info.session_id.clone(), session);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns a shared reference to a session by identifier.
|
||||
pub fn get(&self, session_id: &str) -> Option<&RpcSession> {
|
||||
self.sessions.get(session_id)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns a mutable reference to a session by identifier.
|
||||
pub fn get_mut(&mut self, session_id: &str) -> Option<&mut RpcSession> {
|
||||
self.sessions.get_mut(session_id)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Removes and returns a session by identifier.
|
||||
pub fn remove(&mut self, session_id: &str) -> Option<RpcSession> {
|
||||
self.sessions.remove(session_id)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns the number of stored sessions.
|
||||
pub fn len(&self) -> usize {
|
||||
self.sessions.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns whether the session store contains no sessions.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.sessions.is_empty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! WebSocket notification fan-out and framing helpers.
|
||||
#![expect(
|
||||
clippy::redundant_pub_crate,
|
||||
reason = "the websocket surface keeps transport-specific names explicit for protocol parity"
|
||||
)]
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use aria2_rust_pro_core::{EventListener, RuntimeEvent};
|
||||
|
||||
/// Runtime-event bridge from core notifications into WebSocket queues.
|
||||
mod bridge;
|
||||
/// WebSocket notification event and frame model.
|
||||
mod notification;
|
||||
/// WebSocket subscription and connected-session registries.
|
||||
mod registry;
|
||||
|
||||
#[cfg(test)]
|
||||
/// WebSocket notification and session queue tests.
|
||||
mod tests;
|
||||
|
||||
pub use self::notification::{RpcNotificationEvent, RpcNotificationKind, RpcWebSocketFrame};
|
||||
pub use self::registry::{
|
||||
WebSocketNotificationRegistry, WebSocketSessionRegistry, WebSocketSessionState,
|
||||
WebSocketSubscription,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Runtime event listener that forwards core download events into WebSocket session queues.
|
||||
pub(super) struct RuntimeEventWebSocketBridge(
|
||||
/// Internal bridge implementation kept within the WebSocket facade.
|
||||
bridge::RuntimeEventWebSocketBridge,
|
||||
);
|
||||
|
||||
impl RuntimeEventWebSocketBridge {
|
||||
/// Builds a bridge backed by the shared WebSocket session registry.
|
||||
pub(super) const fn new(sessions: Arc<Mutex<WebSocketSessionRegistry>>) -> Self {
|
||||
Self(bridge::RuntimeEventWebSocketBridge::new(sessions))
|
||||
}
|
||||
}
|
||||
|
||||
impl EventListener for RuntimeEventWebSocketBridge {
|
||||
fn on_event(&mut self, event: &RuntimeEvent) {
|
||||
self.0.on_event(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use aria2_rust_pro_core::{EventListener, RuntimeEvent, RuntimeEventKind};
|
||||
|
||||
use crate::model::RpcMeta;
|
||||
|
||||
use super::{
|
||||
RpcNotificationKind, notification::RpcNotificationEvent, registry::WebSocketSessionRegistry,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Runtime event listener that forwards core download events into WebSocket session queues.
|
||||
pub(super) struct RuntimeEventWebSocketBridge {
|
||||
/// Shared session registry updated whenever a compatible runtime event is observed.
|
||||
sessions: Arc<Mutex<WebSocketSessionRegistry>>,
|
||||
}
|
||||
|
||||
impl RuntimeEventWebSocketBridge {
|
||||
/// Builds a bridge backed by the shared WebSocket session registry.
|
||||
pub(super) const fn new(sessions: Arc<Mutex<WebSocketSessionRegistry>>) -> Self {
|
||||
Self { sessions }
|
||||
}
|
||||
}
|
||||
|
||||
impl EventListener for RuntimeEventWebSocketBridge {
|
||||
fn on_event(&mut self, event: &RuntimeEvent) {
|
||||
let Some(kind) = runtime_event_to_rpc_notification_kind(event.kind) else {
|
||||
return;
|
||||
};
|
||||
let gid = event.gid.map(|gid| gid.to_string());
|
||||
let rpc_event = RpcNotificationEvent {
|
||||
kind,
|
||||
method: String::new(),
|
||||
gid,
|
||||
payload: None,
|
||||
meta: RpcMeta::default(),
|
||||
};
|
||||
if let Ok(mut sessions) = self.sessions.lock() {
|
||||
sessions.queue_broadcast_frame(rpc_event.to_websocket_frame());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps core runtime events onto the subset of WebSocket notifications exposed over RPC.
|
||||
const fn runtime_event_to_rpc_notification_kind(
|
||||
kind: RuntimeEventKind,
|
||||
) -> Option<RpcNotificationKind> {
|
||||
match kind {
|
||||
RuntimeEventKind::DownloadAdded
|
||||
| RuntimeEventKind::DownloadResumed
|
||||
| RuntimeEventKind::OptionChanged
|
||||
| RuntimeEventKind::SessionSaving
|
||||
| RuntimeEventKind::SessionSaved
|
||||
| RuntimeEventKind::ShutdownRequested
|
||||
| RuntimeEventKind::ForceShutdownRequested
|
||||
| RuntimeEventKind::SchedulerTick
|
||||
| RuntimeEventKind::StatisticsUpdated
|
||||
| RuntimeEventKind::PieceUpdated => None,
|
||||
RuntimeEventKind::DownloadStarted => Some(RpcNotificationKind::DownloadStarted),
|
||||
RuntimeEventKind::DownloadPaused => Some(RpcNotificationKind::DownloadPaused),
|
||||
RuntimeEventKind::DownloadRemoved => Some(RpcNotificationKind::DownloadStopped),
|
||||
RuntimeEventKind::DownloadCompleted => Some(RpcNotificationKind::DownloadComplete),
|
||||
RuntimeEventKind::DownloadErrored => Some(RpcNotificationKind::DownloadError),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::model::{RpcMeta, RpcValue};
|
||||
|
||||
/// Maximum queued outbound WebSocket frames retained per connected session.
|
||||
pub(super) const MAX_PENDING_WEBSOCKET_FRAMES_PER_SESSION: usize = 256;
|
||||
/// Maximum bridged runtime-event broadcast frames retained before session fan-out.
|
||||
pub(super) const MAX_PENDING_WEBSOCKET_BROADCAST_FRAMES: usize = 1024;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
/// Notification kinds that can be bridged onto WebSocket sessions.
|
||||
pub enum RpcNotificationKind {
|
||||
/// A download transitioned into the active state.
|
||||
DownloadStarted,
|
||||
/// A download was paused.
|
||||
DownloadPaused,
|
||||
/// A download was stopped or removed.
|
||||
DownloadStopped,
|
||||
/// A download completed successfully.
|
||||
DownloadComplete,
|
||||
/// A download ended in error.
|
||||
DownloadError,
|
||||
/// A download was removed.
|
||||
DownloadRemoved,
|
||||
/// A download entered the waiting queue.
|
||||
DownloadWaiting,
|
||||
/// A waiting download became active again.
|
||||
DownloadActive,
|
||||
/// A `BitTorrent` download completed.
|
||||
DownloadBtDownloadComplete,
|
||||
/// Synthetic notification for version polling.
|
||||
SystemVersion,
|
||||
/// Synthetic notification for method-list polling.
|
||||
SystemListMethods,
|
||||
/// Synthetic notification for notification-list polling.
|
||||
SystemListNotifications,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// Notification payload bridged to WebSocket clients.
|
||||
pub struct RpcNotificationEvent {
|
||||
/// Logical notification kind.
|
||||
pub kind: RpcNotificationKind,
|
||||
/// Explicit method name override, if present.
|
||||
pub method: String,
|
||||
/// Download gid associated with the notification, if any.
|
||||
pub gid: Option<String>,
|
||||
/// Additional notification payload.
|
||||
pub payload: Option<RpcValue>,
|
||||
/// Supplemental metadata.
|
||||
pub meta: RpcMeta,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// Minimal WebSocket frame shapes used by the RPC server.
|
||||
pub enum RpcWebSocketFrame {
|
||||
/// UTF-8 text frame.
|
||||
Text(String),
|
||||
/// Binary frame carrying JSON-RPC bytes.
|
||||
Binary(Vec<u8>),
|
||||
/// Ping control frame.
|
||||
Ping(Vec<u8>),
|
||||
/// Pong control frame.
|
||||
Pong(Vec<u8>),
|
||||
/// Close control frame.
|
||||
Close,
|
||||
}
|
||||
|
||||
impl RpcNotificationKind {
|
||||
/// Returns the canonical aria2-compatible method name for this notification.
|
||||
#[must_use]
|
||||
pub const fn method_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::DownloadStarted | Self::DownloadActive => "aria2.onDownloadStart",
|
||||
Self::DownloadPaused | Self::DownloadWaiting => "aria2.onDownloadPause",
|
||||
Self::DownloadStopped | Self::DownloadRemoved => "aria2.onDownloadStop",
|
||||
Self::DownloadComplete => "aria2.onDownloadComplete",
|
||||
Self::DownloadError => "aria2.onDownloadError",
|
||||
Self::DownloadBtDownloadComplete => "aria2.onBtDownloadComplete",
|
||||
Self::SystemVersion => "aria2.getVersion",
|
||||
Self::SystemListMethods => "system.listMethods",
|
||||
Self::SystemListNotifications => "system.listNotifications",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcNotificationEvent {
|
||||
#[must_use]
|
||||
/// Returns the effective WebSocket method name for the event.
|
||||
pub fn websocket_method_name(&self) -> &str {
|
||||
if self.method.is_empty() {
|
||||
self.kind.method_name()
|
||||
} else {
|
||||
&self.method
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Converts the event into a text WebSocket frame.
|
||||
pub fn to_websocket_frame(&self) -> RpcWebSocketFrame {
|
||||
RpcWebSocketFrame::Text(self.to_websocket_json())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Renders the event into a JSON-RPC notification string.
|
||||
pub fn to_websocket_json(&self) -> String {
|
||||
let method = self.websocket_method_name();
|
||||
let params = websocket_notification_params(self.gid.as_deref(), self.payload.as_ref());
|
||||
let params = rpc_value_to_json(&RpcValue::Array(params));
|
||||
format!(
|
||||
"{{\"jsonrpc\":\"2.0\",\"method\":\"{}\",\"params\":{}}}",
|
||||
escape_json(method),
|
||||
params
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the JSON-RPC `params` array for a bridged WebSocket notification event.
|
||||
pub(super) fn websocket_notification_params(
|
||||
gid: Option<&str>,
|
||||
payload: Option<&RpcValue>,
|
||||
) -> Vec<RpcValue> {
|
||||
if let Some(RpcValue::Array(items)) = payload {
|
||||
return items.clone();
|
||||
}
|
||||
|
||||
let mut event_spec = BTreeMap::new();
|
||||
if let Some(gid) = gid {
|
||||
event_spec.insert("gid".to_owned(), RpcValue::String(gid.to_owned()));
|
||||
}
|
||||
|
||||
if let Some(RpcValue::Object(map)) = payload {
|
||||
for (key, value) in map {
|
||||
event_spec.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
vec![RpcValue::Object(event_spec)]
|
||||
}
|
||||
|
||||
/// Renders a transport-neutral RPC value into compact JSON text for WebSocket frames.
|
||||
pub(super) fn rpc_value_to_json(value: &RpcValue) -> String {
|
||||
match value {
|
||||
RpcValue::Null => "null".to_owned(),
|
||||
RpcValue::Bool(value) => value.to_string(),
|
||||
RpcValue::Number(value) => value.to_string(),
|
||||
RpcValue::String(value) => format!("\"{}\"", escape_json(value)),
|
||||
RpcValue::Array(values) => {
|
||||
let items = values
|
||||
.iter()
|
||||
.map(rpc_value_to_json)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!("[{items}]")
|
||||
}
|
||||
RpcValue::Object(map) => {
|
||||
let members = map
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
format!("\"{}\":{}", escape_json(key), rpc_value_to_json(value))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!("{{{members}}}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Escapes a string for safe embedding in generated JSON text.
|
||||
pub(super) fn escape_json(value: &str) -> String {
|
||||
let mut escaped = String::with_capacity(value.len());
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'"' => escaped.push_str("\\\""),
|
||||
'\\' => escaped.push_str("\\\\"),
|
||||
'\n' => escaped.push_str("\\n"),
|
||||
'\r' => escaped.push_str("\\r"),
|
||||
'\t' => escaped.push_str("\\t"),
|
||||
'\u{08}' => escaped.push_str("\\b"),
|
||||
'\u{0C}' => escaped.push_str("\\f"),
|
||||
ch if ch.is_control() => {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(escaped, "\\u{:04x}", u32::from(ch));
|
||||
}
|
||||
ch => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
|
||||
use super::notification::{
|
||||
MAX_PENDING_WEBSOCKET_BROADCAST_FRAMES, MAX_PENDING_WEBSOCKET_FRAMES_PER_SESSION,
|
||||
RpcNotificationEvent, RpcNotificationKind, RpcWebSocketFrame,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
/// Subscription descriptor for a WebSocket notification consumer.
|
||||
pub struct WebSocketSubscription {
|
||||
/// Stable subscription identifier.
|
||||
pub id: String,
|
||||
/// Notification kind being subscribed to.
|
||||
pub kind: RpcNotificationKind,
|
||||
/// Client-visible topic string.
|
||||
pub topic: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
/// Registry of active WebSocket notification subscriptions.
|
||||
pub struct WebSocketNotificationRegistry {
|
||||
/// Subscription descriptors keyed by subscription identifier.
|
||||
subscriptions: BTreeMap<String, WebSocketSubscription>,
|
||||
/// Reverse index from notification kind to subscribed identifiers.
|
||||
by_kind: BTreeMap<RpcNotificationKind, BTreeSet<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// Per-session queued WebSocket state.
|
||||
pub struct WebSocketSessionState {
|
||||
/// Stable session identifier.
|
||||
pub id: String,
|
||||
/// Frames waiting to be written to the socket for this session.
|
||||
pending_frames: VecDeque<RpcWebSocketFrame>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
/// Registry of connected WebSocket sessions.
|
||||
pub struct WebSocketSessionRegistry {
|
||||
/// Session entries keyed by their stable session identifier.
|
||||
sessions: BTreeMap<String, WebSocketSessionState>,
|
||||
/// Bounded runtime-event broadcast ingress awaiting session fan-out.
|
||||
pending_broadcast_frames: VecDeque<RpcWebSocketFrame>,
|
||||
}
|
||||
|
||||
impl WebSocketNotificationRegistry {
|
||||
/// Registers a new subscription.
|
||||
pub fn subscribe(&mut self, subscription: WebSocketSubscription) {
|
||||
self.by_kind
|
||||
.entry(subscription.kind)
|
||||
.or_default()
|
||||
.insert(subscription.id.clone());
|
||||
self.subscriptions
|
||||
.insert(subscription.id.clone(), subscription);
|
||||
}
|
||||
|
||||
/// Removes a subscription by identifier.
|
||||
pub fn unsubscribe(&mut self, subscription_id: &str) {
|
||||
if let Some(subscription) = self.subscriptions.remove(subscription_id)
|
||||
&& let Some(ids) = self.by_kind.get_mut(&subscription.kind)
|
||||
{
|
||||
ids.remove(subscription_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns all subscriptions for the provided notification kind.
|
||||
pub fn subscriptions_for(&self, kind: RpcNotificationKind) -> Vec<&WebSocketSubscription> {
|
||||
self.by_kind
|
||||
.get(&kind)
|
||||
.into_iter()
|
||||
.flat_map(|ids| ids.iter())
|
||||
.filter_map(|id| self.subscriptions.get(id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns the number of registered subscriptions.
|
||||
pub fn len(&self) -> usize {
|
||||
self.subscriptions.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns whether the registry contains no subscriptions.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.subscriptions.is_empty()
|
||||
}
|
||||
|
||||
/// Builds a copy-on-write frame list for a single event fan-out.
|
||||
#[must_use]
|
||||
pub fn frames_for_event(
|
||||
&self,
|
||||
event: &RpcNotificationEvent,
|
||||
) -> Vec<(String, RpcWebSocketFrame)> {
|
||||
let frame = event.to_websocket_frame();
|
||||
self.subscriptions_for(event.kind)
|
||||
.into_iter()
|
||||
.map(|subscription| (subscription.id.clone(), frame.clone()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl WebSocketSessionRegistry {
|
||||
/// Connects or reuses a session entry for the provided identifier.
|
||||
pub fn connect(&mut self, session_id: impl Into<String>) {
|
||||
let session_id = session_id.into();
|
||||
self.sessions
|
||||
.entry(session_id.clone())
|
||||
.or_insert_with(|| WebSocketSessionState {
|
||||
id: session_id,
|
||||
pending_frames: VecDeque::new(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Disconnects a session and drops its pending queue.
|
||||
pub fn disconnect(&mut self, session_id: &str) {
|
||||
self.sessions.remove(session_id);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns the number of connected sessions.
|
||||
pub fn len(&self) -> usize {
|
||||
self.sessions.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns whether the registry contains no connected sessions.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.sessions.is_empty()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns whether the registry contains the provided session id.
|
||||
pub fn contains(&self, session_id: &str) -> bool {
|
||||
self.sessions.contains_key(session_id)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns the connected session identifiers in key order.
|
||||
pub fn session_ids(&self) -> Vec<String> {
|
||||
self.sessions.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Queues a notification frame for every connected session.
|
||||
pub fn queue_event_for_all(&mut self, event: &RpcNotificationEvent) {
|
||||
let frame = event.to_websocket_frame();
|
||||
for session in self.sessions.values_mut() {
|
||||
enqueue_session_frame(session, frame.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Queues one bridged broadcast frame for later session fan-out.
|
||||
pub fn queue_broadcast_frame(&mut self, frame: RpcWebSocketFrame) {
|
||||
if self.sessions.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.pending_broadcast_frames.len() >= MAX_PENDING_WEBSOCKET_BROADCAST_FRAMES {
|
||||
let _ = self.pending_broadcast_frames.pop_front();
|
||||
}
|
||||
self.pending_broadcast_frames.push_back(frame);
|
||||
}
|
||||
|
||||
/// Queues a frame for a single session and reports whether it existed.
|
||||
pub fn queue_frame_for_session(&mut self, session_id: &str, frame: RpcWebSocketFrame) -> bool {
|
||||
let Some(session) = self.sessions.get_mut(session_id) else {
|
||||
return false;
|
||||
};
|
||||
enqueue_session_frame(session, frame);
|
||||
true
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns the pending frame count for a session, if it exists.
|
||||
pub fn pending_count(&self, session_id: &str) -> Option<usize> {
|
||||
self.sessions
|
||||
.get(session_id)
|
||||
.map(|session| session.pending_frames.len())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns the pending bridged broadcast frame count.
|
||||
pub fn pending_broadcast_count(&self) -> usize {
|
||||
self.pending_broadcast_frames.len()
|
||||
}
|
||||
|
||||
/// Fans out all pending bridged broadcast frames into the per-session queues.
|
||||
pub fn drain_broadcast_frames_into_sessions(&mut self) {
|
||||
while let Some(frame) = self.pending_broadcast_frames.pop_front() {
|
||||
for session in self.sessions.values_mut() {
|
||||
enqueue_session_frame(session, frame.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drains all queued frames for one session after applying pending broadcast fan-out.
|
||||
pub fn drain_session_frames(&mut self, session_id: &str) -> Vec<RpcWebSocketFrame> {
|
||||
self.drain_broadcast_frames_into_sessions();
|
||||
self.sessions
|
||||
.get_mut(session_id)
|
||||
.map(|session| session.pending_frames.drain(..).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Pops the oldest pending frame for a session.
|
||||
pub fn pop_frame(&mut self, session_id: &str) -> Option<RpcWebSocketFrame> {
|
||||
self.drain_broadcast_frames_into_sessions();
|
||||
self.sessions
|
||||
.get_mut(session_id)
|
||||
.and_then(|session| session.pending_frames.pop_front())
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a frame to the session queue while enforcing the bounded backpressure policy.
|
||||
fn enqueue_session_frame(session: &mut WebSocketSessionState, frame: RpcWebSocketFrame) {
|
||||
if session.pending_frames.len() >= MAX_PENDING_WEBSOCKET_FRAMES_PER_SESSION {
|
||||
let _ = session.pending_frames.pop_front();
|
||||
}
|
||||
session.pending_frames.push_back(frame);
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use aria2_rust_pro_core::{
|
||||
DownloadEngine, DownloadId, EventListener, RuntimeEvent, RuntimeEventKind,
|
||||
};
|
||||
|
||||
use super::notification::{
|
||||
MAX_PENDING_WEBSOCKET_BROADCAST_FRAMES, MAX_PENDING_WEBSOCKET_FRAMES_PER_SESSION,
|
||||
};
|
||||
use super::*;
|
||||
use crate::{InProcessRpcDispatcher, JsonRpcRequest, RpcValue};
|
||||
|
||||
#[test]
|
||||
/// Verifies that notification kinds keep the upstream aria2 WebSocket method names.
|
||||
fn notification_kind_uses_upstream_websocket_method_names() {
|
||||
assert_eq!(
|
||||
RpcNotificationKind::DownloadStarted.method_name(),
|
||||
"aria2.onDownloadStart"
|
||||
);
|
||||
assert_eq!(
|
||||
RpcNotificationKind::DownloadBtDownloadComplete.method_name(),
|
||||
"aria2.onBtDownloadComplete"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that notification events render the upstream `JSON-RPC` notification shape.
|
||||
fn websocket_event_renders_upstream_jsonrpc_notification_shape() {
|
||||
let event = RpcNotificationEvent {
|
||||
kind: RpcNotificationKind::DownloadComplete,
|
||||
method: String::new(),
|
||||
gid: Some("a1b2c3".to_owned()),
|
||||
payload: None,
|
||||
meta: crate::model::RpcMeta::default(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
event.to_websocket_json(),
|
||||
"{\"jsonrpc\":\"2.0\",\"method\":\"aria2.onDownloadComplete\",\"params\":[{\"gid\":\"a1b2c3\"}]}"
|
||||
);
|
||||
assert_eq!(
|
||||
event.to_websocket_frame(),
|
||||
RpcWebSocketFrame::Text(
|
||||
"{\"jsonrpc\":\"2.0\",\"method\":\"aria2.onDownloadComplete\",\"params\":[{\"gid\":\"a1b2c3\"}]}".to_owned()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that object payloads merge with the gid field in notification output.
|
||||
fn websocket_event_merges_gid_with_object_payload() {
|
||||
let event = RpcNotificationEvent {
|
||||
kind: RpcNotificationKind::DownloadError,
|
||||
method: String::new(),
|
||||
gid: Some("deadbeef".to_owned()),
|
||||
payload: Some(RpcValue::Object(BTreeMap::from([(
|
||||
"status".to_owned(),
|
||||
RpcValue::String("error".to_owned()),
|
||||
)]))),
|
||||
meta: crate::model::RpcMeta::default(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
event.to_websocket_json(),
|
||||
"{\"jsonrpc\":\"2.0\",\"method\":\"aria2.onDownloadError\",\"params\":[{\"gid\":\"deadbeef\",\"status\":\"error\"}]}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that one notification frame is emitted for each matching subscription.
|
||||
fn registry_emits_one_frame_per_matching_subscription() {
|
||||
let mut registry = WebSocketNotificationRegistry::default();
|
||||
registry.subscribe(WebSocketSubscription {
|
||||
id: "sub-a".to_owned(),
|
||||
kind: RpcNotificationKind::DownloadStarted,
|
||||
topic: "aria2.onDownloadStart".to_owned(),
|
||||
});
|
||||
registry.subscribe(WebSocketSubscription {
|
||||
id: "sub-b".to_owned(),
|
||||
kind: RpcNotificationKind::DownloadStarted,
|
||||
topic: "aria2.onDownloadStart".to_owned(),
|
||||
});
|
||||
registry.subscribe(WebSocketSubscription {
|
||||
id: "sub-c".to_owned(),
|
||||
kind: RpcNotificationKind::DownloadComplete,
|
||||
topic: "aria2.onDownloadComplete".to_owned(),
|
||||
});
|
||||
|
||||
let event = RpcNotificationEvent {
|
||||
kind: RpcNotificationKind::DownloadStarted,
|
||||
method: String::new(),
|
||||
gid: Some("feedface".to_owned()),
|
||||
payload: None,
|
||||
meta: crate::model::RpcMeta::default(),
|
||||
};
|
||||
|
||||
let frames = registry.frames_for_event(&event);
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert!(frames.iter().any(|(id, _)| id == "sub-a"));
|
||||
assert!(frames.iter().any(|(id, _)| id == "sub-b"));
|
||||
assert!(
|
||||
frames
|
||||
.iter()
|
||||
.all(|(_, frame)| matches!(frame, RpcWebSocketFrame::Text(_)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that broadcast queueing fans notification frames out to every connected session.
|
||||
fn session_registry_broadcasts_notification_frames_to_all_sessions() {
|
||||
let mut registry = WebSocketSessionRegistry::default();
|
||||
registry.connect("sess-a");
|
||||
registry.connect("sess-b");
|
||||
|
||||
let event = RpcNotificationEvent {
|
||||
kind: RpcNotificationKind::DownloadComplete,
|
||||
method: String::new(),
|
||||
gid: Some("abc123".to_owned()),
|
||||
payload: None,
|
||||
meta: crate::model::RpcMeta::default(),
|
||||
};
|
||||
|
||||
registry.queue_event_for_all(&event);
|
||||
|
||||
assert_eq!(registry.pending_count("sess-a"), Some(1));
|
||||
assert_eq!(registry.pending_count("sess-b"), Some(1));
|
||||
assert_eq!(
|
||||
registry.pop_frame("sess-a"),
|
||||
Some(RpcWebSocketFrame::Text(
|
||||
"{\"jsonrpc\":\"2.0\",\"method\":\"aria2.onDownloadComplete\",\"params\":[{\"gid\":\"abc123\"}]}".to_owned()
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
registry.pop_frame("sess-b"),
|
||||
Some(RpcWebSocketFrame::Text(
|
||||
"{\"jsonrpc\":\"2.0\",\"method\":\"aria2.onDownloadComplete\",\"params\":[{\"gid\":\"abc123\"}]}".to_owned()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that bridged broadcast ingress stays bounded and drops the oldest frame first.
|
||||
fn session_registry_enforces_bounded_broadcast_ingress_backpressure() {
|
||||
let mut registry = WebSocketSessionRegistry::default();
|
||||
registry.connect("sess-a");
|
||||
|
||||
for index in 0..(MAX_PENDING_WEBSOCKET_BROADCAST_FRAMES + 8) {
|
||||
registry.queue_broadcast_frame(RpcWebSocketFrame::Ping(vec![
|
||||
u8::try_from(index % 256).expect("byte should fit"),
|
||||
]));
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
registry.pending_broadcast_count(),
|
||||
MAX_PENDING_WEBSOCKET_BROADCAST_FRAMES
|
||||
);
|
||||
assert_eq!(
|
||||
registry.pop_frame("sess-a"),
|
||||
Some(RpcWebSocketFrame::Ping(vec![8])),
|
||||
"oldest bridged broadcast frames should be dropped first once the ingress limit is reached"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that bridged broadcast ingress fans out only when a session drain occurs.
|
||||
fn session_registry_drains_broadcast_ingress_into_connected_sessions() {
|
||||
let mut registry = WebSocketSessionRegistry::default();
|
||||
registry.connect("sess-a");
|
||||
registry.connect("sess-b");
|
||||
|
||||
registry.queue_broadcast_frame(RpcWebSocketFrame::Ping(vec![4, 2]));
|
||||
|
||||
assert_eq!(registry.pending_count("sess-a"), Some(0));
|
||||
assert_eq!(registry.pending_count("sess-b"), Some(0));
|
||||
assert_eq!(registry.pending_broadcast_count(), 1);
|
||||
|
||||
let sess_a_frames = registry.drain_session_frames("sess-a");
|
||||
assert_eq!(registry.pending_broadcast_count(), 0);
|
||||
assert_eq!(sess_a_frames, vec![RpcWebSocketFrame::Ping(vec![4, 2])]);
|
||||
assert_eq!(
|
||||
registry.pop_frame("sess-b"),
|
||||
Some(RpcWebSocketFrame::Ping(vec![4, 2]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies targeted queueing, missing-session rejection, and disconnect behavior.
|
||||
fn session_registry_supports_targeted_queue_and_disconnect() {
|
||||
let mut registry = WebSocketSessionRegistry::default();
|
||||
registry.connect("sess-a");
|
||||
registry.connect("sess-b");
|
||||
|
||||
assert!(registry.queue_frame_for_session("sess-a", RpcWebSocketFrame::Ping(vec![1, 2, 3]),));
|
||||
assert!(!registry.queue_frame_for_session("sess-missing", RpcWebSocketFrame::Ping(vec![9]),));
|
||||
|
||||
assert_eq!(registry.pending_count("sess-a"), Some(1));
|
||||
assert_eq!(registry.pending_count("sess-b"), Some(0));
|
||||
assert_eq!(
|
||||
registry.pop_frame("sess-a"),
|
||||
Some(RpcWebSocketFrame::Ping(vec![1, 2, 3]))
|
||||
);
|
||||
|
||||
registry.disconnect("sess-b");
|
||||
assert!(!registry.contains("sess-b"));
|
||||
assert_eq!(registry.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that the per-session queue is bounded and drops the oldest frame under pressure.
|
||||
fn session_registry_enforces_bounded_pending_frame_backpressure() {
|
||||
let mut registry = WebSocketSessionRegistry::default();
|
||||
registry.connect("sess-a");
|
||||
|
||||
for index in 0..(MAX_PENDING_WEBSOCKET_FRAMES_PER_SESSION + 8) {
|
||||
assert!(registry.queue_frame_for_session(
|
||||
"sess-a",
|
||||
RpcWebSocketFrame::Ping(vec![u8::try_from(index % 256).expect("byte should fit")]),
|
||||
));
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
registry.pending_count("sess-a"),
|
||||
Some(MAX_PENDING_WEBSOCKET_FRAMES_PER_SESSION)
|
||||
);
|
||||
assert_eq!(
|
||||
registry.pop_frame("sess-a"),
|
||||
Some(RpcWebSocketFrame::Ping(vec![8])),
|
||||
"oldest queued frames should be dropped first once the per-session limit is reached"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that the runtime-event bridge queues real completion events into session frames.
|
||||
fn runtime_event_bridge_queues_real_download_events_into_sessions() {
|
||||
let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default()));
|
||||
sessions
|
||||
.lock()
|
||||
.expect("sessions lock should succeed")
|
||||
.connect("sess-a");
|
||||
let mut bridge = RuntimeEventWebSocketBridge::new(Arc::clone(&sessions));
|
||||
|
||||
bridge.on_event(
|
||||
&RuntimeEvent::new(RuntimeEventKind::DownloadCompleted).with_gid(DownloadId::new(0x2a)),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
sessions
|
||||
.lock()
|
||||
.expect("sessions lock should succeed")
|
||||
.pending_broadcast_count(),
|
||||
1
|
||||
);
|
||||
let frame = sessions
|
||||
.lock()
|
||||
.expect("sessions lock should succeed")
|
||||
.pop_frame("sess-a")
|
||||
.expect("download completion should queue a notification frame");
|
||||
match frame {
|
||||
RpcWebSocketFrame::Text(text) => {
|
||||
assert!(text.contains("aria2.onDownloadComplete"));
|
||||
assert!(text.contains(r#""gid":"000000000000002a""#));
|
||||
}
|
||||
other => panic!("unexpected bridged notification frame: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that `addUri` registration alone does not emit a start notification.
|
||||
fn runtime_event_bridge_does_not_treat_add_uri_as_download_start() {
|
||||
let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default()));
|
||||
sessions
|
||||
.lock()
|
||||
.expect("sessions lock should succeed")
|
||||
.connect("sess-a");
|
||||
let bridge = RuntimeEventWebSocketBridge::new(Arc::clone(&sessions));
|
||||
let mut dispatcher = InProcessRpcDispatcher::new();
|
||||
dispatcher.register_runtime_listener(bridge);
|
||||
|
||||
let response = dispatcher.dispatch_json(JsonRpcRequest {
|
||||
jsonrpc: Some("2.0".to_owned()),
|
||||
id: Some(crate::jsonrpc::JsonRpcId::Number(1)),
|
||||
method: "aria2.addUri".to_owned(),
|
||||
params: vec![RpcValue::String("https://example.org/file.iso".to_owned())],
|
||||
meta: crate::model::RpcMeta::default(),
|
||||
});
|
||||
assert!(response.error.is_none());
|
||||
|
||||
assert!(
|
||||
sessions
|
||||
.lock()
|
||||
.expect("sessions lock should succeed")
|
||||
.pop_frame("sess-a")
|
||||
.is_none(),
|
||||
"addUri should not emit aria2.onDownloadStart before the download actually starts"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that start notifications only appear after the scheduler activates the download.
|
||||
fn runtime_event_bridge_emits_start_only_when_scheduler_activates_download() {
|
||||
let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default()));
|
||||
sessions
|
||||
.lock()
|
||||
.expect("sessions lock should succeed")
|
||||
.connect("sess-a");
|
||||
|
||||
let mut engine = DownloadEngine::new();
|
||||
engine.register_listener(RuntimeEventWebSocketBridge::new(Arc::clone(&sessions)));
|
||||
let gid = engine.add_uri("https://example.org/file.iso").gid();
|
||||
|
||||
assert!(
|
||||
sessions
|
||||
.lock()
|
||||
.expect("sessions lock should succeed")
|
||||
.pop_frame("sess-a")
|
||||
.is_none(),
|
||||
"registration alone should not emit a start notification"
|
||||
);
|
||||
|
||||
let _ = engine.schedule_once();
|
||||
|
||||
let frame = sessions
|
||||
.lock()
|
||||
.expect("sessions lock should succeed")
|
||||
.pop_frame("sess-a")
|
||||
.expect("scheduler activation should emit start notification");
|
||||
match frame {
|
||||
RpcWebSocketFrame::Text(text) => {
|
||||
assert!(text.contains("aria2.onDownloadStart"));
|
||||
assert!(text.contains(&format!(r#""gid":"{gid}""#)));
|
||||
}
|
||||
other => panic!("unexpected frame: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that resumed downloads emit start only after they become active again.
|
||||
fn runtime_event_bridge_does_not_emit_start_until_resumed_download_is_active_again() {
|
||||
let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default()));
|
||||
sessions
|
||||
.lock()
|
||||
.expect("sessions lock should succeed")
|
||||
.connect("sess-a");
|
||||
|
||||
let mut engine = DownloadEngine::new();
|
||||
engine.register_listener(RuntimeEventWebSocketBridge::new(Arc::clone(&sessions)));
|
||||
let gid = engine.add_uri("https://example.org/file.iso").gid();
|
||||
let _ = engine.schedule_once();
|
||||
|
||||
let _ = sessions
|
||||
.lock()
|
||||
.expect("sessions lock should succeed")
|
||||
.pop_frame("sess-a");
|
||||
|
||||
engine.pause(gid).expect("pause should succeed");
|
||||
let pause = sessions
|
||||
.lock()
|
||||
.expect("sessions lock should succeed")
|
||||
.pop_frame("sess-a")
|
||||
.expect("pause should emit pause notification");
|
||||
match pause {
|
||||
RpcWebSocketFrame::Text(text) => {
|
||||
assert!(text.contains("aria2.onDownloadPause"));
|
||||
}
|
||||
other => panic!("unexpected pause frame: {other:?}"),
|
||||
}
|
||||
|
||||
engine
|
||||
.resume(gid)
|
||||
.expect("resume should move download back to waiting");
|
||||
assert!(
|
||||
sessions
|
||||
.lock()
|
||||
.expect("sessions lock should succeed")
|
||||
.pop_frame("sess-a")
|
||||
.is_none(),
|
||||
"resume should not emit start notification before reactivation"
|
||||
);
|
||||
|
||||
let _ = engine.schedule_once();
|
||||
|
||||
let frame = sessions
|
||||
.lock()
|
||||
.expect("sessions lock should succeed")
|
||||
.pop_frame("sess-a")
|
||||
.expect("reactivation should emit start notification");
|
||||
match frame {
|
||||
RpcWebSocketFrame::Text(text) => {
|
||||
assert!(text.contains("aria2.onDownloadStart"));
|
||||
assert!(text.contains(&format!(r#""gid":"{gid}""#)));
|
||||
}
|
||||
other => panic!("unexpected reactivation frame: {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! XML-RPC request and response types plus parser and renderer helpers.
|
||||
#![expect(
|
||||
clippy::arithmetic_side_effects,
|
||||
clippy::indexing_slicing,
|
||||
reason = "the XML-RPC surface keeps transport terminology explicit for compatibility parity"
|
||||
)]
|
||||
|
||||
/// XML-RPC parser and renderer entrypoints.
|
||||
mod codec;
|
||||
/// Conversion helpers between XML-RPC and transport-neutral RPC values.
|
||||
mod convert;
|
||||
/// XML-RPC request, response, and value model types.
|
||||
mod model;
|
||||
/// Minimal XML token scanner used by the codec parser.
|
||||
mod scanner;
|
||||
|
||||
#[cfg(test)]
|
||||
/// XML-RPC parser, renderer, and conversion tests.
|
||||
mod tests;
|
||||
|
||||
pub use self::codec::{
|
||||
xmlrpc_method_call_from_xml, xmlrpc_method_call_to_xml, xmlrpc_method_response_from_xml,
|
||||
xmlrpc_method_response_to_xml,
|
||||
};
|
||||
pub use self::convert::{rpc_value_to_xmlrpc, xmlrpc_value_to_rpc};
|
||||
pub use self::model::{
|
||||
XmlRpcFault, XmlRpcMember, XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, XmlRpcValue,
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
use crate::model::RpcMeta;
|
||||
|
||||
use super::{
|
||||
convert::{xmlrpc_fault_code, xmlrpc_fault_string},
|
||||
model::XmlRpcFault,
|
||||
};
|
||||
use super::{
|
||||
model::{XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, XmlRpcValue},
|
||||
scanner::XmlScanner,
|
||||
};
|
||||
|
||||
#[must_use]
|
||||
/// Renders an XML-RPC method call to a compact XML document.
|
||||
pub fn xmlrpc_method_call_to_xml(call: &XmlRpcMethodCall) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("<?xml version=\"1.0\"?>");
|
||||
out.push_str("<methodCall>");
|
||||
out.push_str("<methodName>");
|
||||
out.push_str(&escape_xml_text(&call.method_name));
|
||||
out.push_str("</methodName>");
|
||||
out.push_str("<params>");
|
||||
for param in &call.params {
|
||||
out.push_str("<param>");
|
||||
out.push_str(&xmlrpc_value_to_xml(¶m.value));
|
||||
out.push_str("</param>");
|
||||
}
|
||||
out.push_str("</params>");
|
||||
out.push_str("</methodCall>");
|
||||
out
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Renders an XML-RPC method response to a compact XML document.
|
||||
pub fn xmlrpc_method_response_to_xml(response: &XmlRpcMethodResponse) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("<?xml version=\"1.0\"?>");
|
||||
out.push_str("<methodResponse>");
|
||||
if let Some(fault) = &response.fault {
|
||||
out.push_str("<fault>");
|
||||
out.push_str("<value><struct>");
|
||||
out.push_str("<member><name>faultCode</name>");
|
||||
out.push_str(&xmlrpc_value_to_xml(&XmlRpcValue::Int(fault.code)));
|
||||
out.push_str("</member>");
|
||||
out.push_str("<member><name>faultString</name>");
|
||||
out.push_str(&xmlrpc_value_to_xml(&XmlRpcValue::String(
|
||||
fault.message.clone(),
|
||||
)));
|
||||
out.push_str("</member>");
|
||||
out.push_str("</struct></value>");
|
||||
out.push_str("</fault>");
|
||||
} else if let Some(value) = &response.value {
|
||||
out.push_str("<params><param>");
|
||||
out.push_str(&xmlrpc_value_to_xml(value));
|
||||
out.push_str("</param></params>");
|
||||
} else {
|
||||
out.push_str("<params/>");
|
||||
}
|
||||
out.push_str("</methodResponse>");
|
||||
out
|
||||
}
|
||||
|
||||
/// Parses an XML-RPC method call from raw XML text.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `xml` is not a well-formed XML-RPC method call.
|
||||
pub fn xmlrpc_method_call_from_xml(xml: &str) -> Result<XmlRpcMethodCall, String> {
|
||||
let mut p = XmlScanner::new(xml);
|
||||
p.skip_xml_decl_and_ws();
|
||||
p.expect_open("methodCall")?;
|
||||
let method_name = p.read_text_tag("methodName")?;
|
||||
let mut params = Vec::new();
|
||||
if p.peek_open("params") {
|
||||
p.expect_open("params")?;
|
||||
while p.peek_open("param") {
|
||||
p.expect_open("param")?;
|
||||
let value = p.read_value_tag()?;
|
||||
p.expect_close("param")?;
|
||||
params.push(XmlRpcParam { value });
|
||||
}
|
||||
p.expect_close("params")?;
|
||||
} else if p.peek_self_closing("params") {
|
||||
p.expect_self_closing("params")?;
|
||||
}
|
||||
p.expect_close("methodCall")?;
|
||||
p.skip_ws();
|
||||
if !p.is_eof() {
|
||||
return Err("trailing XML after methodCall".to_string());
|
||||
}
|
||||
Ok(XmlRpcMethodCall {
|
||||
method_name,
|
||||
params,
|
||||
meta: RpcMeta::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses an XML-RPC method response from raw XML text.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `xml` is not a well-formed XML-RPC method response.
|
||||
pub fn xmlrpc_method_response_from_xml(xml: &str) -> Result<XmlRpcMethodResponse, String> {
|
||||
let mut p = XmlScanner::new(xml);
|
||||
p.skip_xml_decl_and_ws();
|
||||
p.expect_open("methodResponse")?;
|
||||
let mut value = None;
|
||||
let mut fault = None;
|
||||
if p.peek_open("fault") {
|
||||
p.expect_open("fault")?;
|
||||
let fault_value = p.read_value_tag()?;
|
||||
p.expect_close("fault")?;
|
||||
let XmlRpcValue::Struct(members) = fault_value else {
|
||||
return Err("fault value must be struct".to_string());
|
||||
};
|
||||
let code = members
|
||||
.iter()
|
||||
.find(|m| m.name == "faultCode")
|
||||
.and_then(|m| xmlrpc_fault_code(&m.value))
|
||||
.ok_or_else(|| "missing faultCode".to_string())?;
|
||||
let message = members
|
||||
.iter()
|
||||
.find(|m| m.name == "faultString")
|
||||
.and_then(|m| xmlrpc_fault_string(&m.value))
|
||||
.ok_or_else(|| "missing faultString".to_string())?;
|
||||
fault = Some(XmlRpcFault {
|
||||
code,
|
||||
message,
|
||||
error: None,
|
||||
});
|
||||
} else if p.peek_open("params") {
|
||||
p.expect_open("params")?;
|
||||
if p.peek_open("param") {
|
||||
p.expect_open("param")?;
|
||||
value = Some(p.read_value_tag()?);
|
||||
p.expect_close("param")?;
|
||||
}
|
||||
p.expect_close("params")?;
|
||||
} else if p.peek_self_closing("params") {
|
||||
p.expect_self_closing("params")?;
|
||||
}
|
||||
p.expect_close("methodResponse")?;
|
||||
p.skip_ws();
|
||||
if !p.is_eof() {
|
||||
return Err("trailing XML after methodResponse".to_string());
|
||||
}
|
||||
Ok(XmlRpcMethodResponse {
|
||||
value,
|
||||
fault,
|
||||
meta: RpcMeta::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Renders a single XML-RPC value into a compact `<value>...</value>` fragment.
|
||||
fn xmlrpc_value_to_xml(value: &XmlRpcValue) -> String {
|
||||
match value {
|
||||
XmlRpcValue::Nil => "<value><nil/></value>".to_string(),
|
||||
XmlRpcValue::Bool(v) => format!("<value><boolean>{}</boolean></value>", i32::from(*v)),
|
||||
XmlRpcValue::Int(v) => format!("<value><int>{v}</int></value>"),
|
||||
XmlRpcValue::String(v) => format!("<value><string>{}</string></value>", escape_xml_text(v)),
|
||||
XmlRpcValue::Double(v) => format!("<value><double>{v}</double></value>"),
|
||||
XmlRpcValue::DateTime(v) => {
|
||||
format!(
|
||||
"<value><dateTime.iso8601>{}</dateTime.iso8601></value>",
|
||||
escape_xml_text(v)
|
||||
)
|
||||
}
|
||||
XmlRpcValue::Base64(v) => format!("<value><base64>{}</base64></value>", base64_encode(v)),
|
||||
XmlRpcValue::Array(values) => {
|
||||
let mut out = String::from("<value><array><data>");
|
||||
for item in values {
|
||||
out.push_str(&xmlrpc_value_to_xml(item));
|
||||
}
|
||||
out.push_str("</data></array></value>");
|
||||
out
|
||||
}
|
||||
XmlRpcValue::Struct(members) => {
|
||||
let mut out = String::from("<value><struct>");
|
||||
for member in members {
|
||||
out.push_str("<member><name>");
|
||||
out.push_str(&escape_xml_text(&member.name));
|
||||
out.push_str("</name>");
|
||||
out.push_str(&xmlrpc_value_to_xml(&member.value));
|
||||
out.push_str("</member>");
|
||||
}
|
||||
out.push_str("</struct></value>");
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Escapes text content for safe inclusion inside XML element bodies.
|
||||
pub(super) fn escape_xml_text(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
for ch in input.chars() {
|
||||
match ch {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Encodes raw bytes using the XML-RPC base64 scalar format.
|
||||
pub(super) fn base64_encode(bytes: &[u8]) -> String {
|
||||
use base64::Engine;
|
||||
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::model::RpcValue;
|
||||
|
||||
use super::{
|
||||
codec::base64_encode,
|
||||
model::{XmlRpcMember, XmlRpcValue},
|
||||
};
|
||||
|
||||
#[must_use]
|
||||
/// Converts a transport-neutral RPC value into its XML-RPC representation.
|
||||
pub fn rpc_value_to_xmlrpc(value: RpcValue) -> XmlRpcValue {
|
||||
match value {
|
||||
RpcValue::Null => XmlRpcValue::Nil,
|
||||
RpcValue::Bool(v) => XmlRpcValue::Bool(v),
|
||||
RpcValue::Number(v) => {
|
||||
i32::try_from(v).map_or_else(|_| XmlRpcValue::String(v.to_string()), XmlRpcValue::Int)
|
||||
}
|
||||
RpcValue::String(v) => XmlRpcValue::String(v),
|
||||
RpcValue::Array(values) => {
|
||||
XmlRpcValue::Array(values.into_iter().map(rpc_value_to_xmlrpc).collect())
|
||||
}
|
||||
RpcValue::Object(map) => XmlRpcValue::Struct(
|
||||
map.into_iter()
|
||||
.map(|(name, value)| XmlRpcMember {
|
||||
name,
|
||||
value: rpc_value_to_xmlrpc(value),
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Converts an XML-RPC value into the transport-neutral RPC representation.
|
||||
pub fn xmlrpc_value_to_rpc(value: XmlRpcValue) -> RpcValue {
|
||||
match value {
|
||||
XmlRpcValue::Nil => RpcValue::Null,
|
||||
XmlRpcValue::Bool(v) => RpcValue::Bool(v),
|
||||
XmlRpcValue::Int(v) => RpcValue::Number(i64::from(v)),
|
||||
XmlRpcValue::String(v) | XmlRpcValue::DateTime(v) => RpcValue::String(v),
|
||||
XmlRpcValue::Double(v) => RpcValue::String(v.to_string()),
|
||||
XmlRpcValue::Base64(v) => RpcValue::String(base64_encode(&v)),
|
||||
XmlRpcValue::Array(values) => {
|
||||
RpcValue::Array(values.into_iter().map(xmlrpc_value_to_rpc).collect())
|
||||
}
|
||||
XmlRpcValue::Struct(members) => {
|
||||
let mut map = BTreeMap::new();
|
||||
for member in members {
|
||||
map.insert(member.name, xmlrpc_value_to_rpc(member.value));
|
||||
}
|
||||
RpcValue::Object(map)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts an XML-RPC fault code from the normalized fault struct member value.
|
||||
pub(super) fn xmlrpc_fault_code(value: &XmlRpcValue) -> Option<i32> {
|
||||
match value {
|
||||
XmlRpcValue::Int(v) => Some(*v),
|
||||
XmlRpcValue::String(v) => v.trim().parse::<i32>().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts an XML-RPC fault string from the normalized fault struct member value.
|
||||
pub(super) fn xmlrpc_fault_string(value: &XmlRpcValue) -> Option<String> {
|
||||
match value {
|
||||
XmlRpcValue::Nil => Some(String::new()),
|
||||
XmlRpcValue::Bool(v) => Some(if *v { "1" } else { "0" }.to_string()),
|
||||
XmlRpcValue::Int(v) => Some(v.to_string()),
|
||||
XmlRpcValue::String(v) | XmlRpcValue::DateTime(v) => Some(v.clone()),
|
||||
XmlRpcValue::Double(v) => Some(v.to_string()),
|
||||
XmlRpcValue::Base64(v) => Some(base64_encode(v)),
|
||||
XmlRpcValue::Array(_) | XmlRpcValue::Struct(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use crate::model::{RpcError, RpcMeta};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// XML-RPC value representation used by the compatibility layer.
|
||||
pub enum XmlRpcValue {
|
||||
/// 32-bit signed integer.
|
||||
Int(i32),
|
||||
/// Boolean scalar.
|
||||
Bool(bool),
|
||||
/// UTF-8 string scalar.
|
||||
String(String),
|
||||
/// Floating-point scalar.
|
||||
Double(f64),
|
||||
/// ISO 8601 timestamp string.
|
||||
DateTime(String),
|
||||
/// Base64-encoded binary payload.
|
||||
Base64(Vec<u8>),
|
||||
/// Ordered list of nested values.
|
||||
Array(Vec<Self>),
|
||||
/// Structured list of named members.
|
||||
Struct(Vec<XmlRpcMember>),
|
||||
/// Nil extension value.
|
||||
Nil,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// Named member within an XML-RPC struct value.
|
||||
pub struct XmlRpcMember {
|
||||
/// Member name.
|
||||
pub name: String,
|
||||
/// Member value.
|
||||
pub value: XmlRpcValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// Positional XML-RPC parameter wrapper.
|
||||
pub struct XmlRpcParam {
|
||||
/// Parameter value.
|
||||
pub value: XmlRpcValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// XML-RPC method call payload.
|
||||
pub struct XmlRpcMethodCall {
|
||||
/// Requested method name.
|
||||
pub method_name: String,
|
||||
/// Positional parameters.
|
||||
pub params: Vec<XmlRpcParam>,
|
||||
/// Supplemental request metadata.
|
||||
pub meta: RpcMeta,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// XML-RPC fault payload.
|
||||
pub struct XmlRpcFault {
|
||||
/// Fault code returned on the wire.
|
||||
pub code: i32,
|
||||
/// Human-readable fault message.
|
||||
pub message: String,
|
||||
/// Normalized backing error, if one exists.
|
||||
pub error: Option<RpcError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// XML-RPC method response payload.
|
||||
pub struct XmlRpcMethodResponse {
|
||||
/// Successful result value, if any.
|
||||
pub value: Option<XmlRpcValue>,
|
||||
/// Fault payload, if the request failed.
|
||||
pub fault: Option<XmlRpcFault>,
|
||||
/// Supplemental response metadata.
|
||||
pub meta: RpcMeta,
|
||||
}
|
||||
|
||||
impl XmlRpcMethodResponse {
|
||||
#[must_use]
|
||||
/// Builds a successful XML-RPC response containing the provided value.
|
||||
pub fn success(value: XmlRpcValue) -> Self {
|
||||
Self {
|
||||
value: Some(value),
|
||||
fault: None,
|
||||
meta: RpcMeta::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
use super::model::{XmlRpcMember, XmlRpcValue};
|
||||
|
||||
/// Small stateful scanner for the permissive XML-RPC parser.
|
||||
pub(super) struct XmlScanner<'a> {
|
||||
/// Entire XML document being parsed.
|
||||
input: &'a str,
|
||||
/// Current byte position within `input`.
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
/// Kind of XML opening tag encountered by the scanner.
|
||||
enum TagKind {
|
||||
/// Standard opening tag with a matching close tag later in the stream.
|
||||
Open,
|
||||
/// Self-closing tag with no body content.
|
||||
SelfClosing,
|
||||
}
|
||||
|
||||
impl<'a> XmlScanner<'a> {
|
||||
/// Creates a scanner positioned at the beginning of the input document.
|
||||
pub(super) const fn new(input: &'a str) -> Self {
|
||||
Self { input, pos: 0 }
|
||||
}
|
||||
|
||||
/// Returns whether the scanner has consumed the entire input string.
|
||||
pub(super) const fn is_eof(&self) -> bool {
|
||||
self.pos >= self.input.len()
|
||||
}
|
||||
|
||||
/// Returns the remaining unparsed input slice.
|
||||
fn rest(&self) -> &'a str {
|
||||
&self.input[self.pos..]
|
||||
}
|
||||
|
||||
/// Skips XML whitespace plus comments and processing instructions.
|
||||
pub(super) fn skip_ws(&mut self) {
|
||||
loop {
|
||||
let start = self.pos;
|
||||
while let Some(ch) = self.rest().chars().next() {
|
||||
if ch.is_whitespace() {
|
||||
self.pos += ch.len_utf8();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if self.rest().starts_with("<!--") {
|
||||
if let Some(end) = self.rest().find("-->") {
|
||||
self.pos += end + 3;
|
||||
continue;
|
||||
}
|
||||
self.pos = self.input.len();
|
||||
} else if self.rest().starts_with("<?") {
|
||||
if let Some(end) = self.rest().find("?>") {
|
||||
self.pos += end + 2;
|
||||
continue;
|
||||
}
|
||||
self.pos = self.input.len();
|
||||
}
|
||||
if self.pos == start {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Skips the optional XML declaration and any surrounding whitespace.
|
||||
pub(super) fn skip_xml_decl_and_ws(&mut self) {
|
||||
self.skip_ws();
|
||||
}
|
||||
|
||||
/// Returns whether the next non-whitespace token is an opening tag for `tag`.
|
||||
pub(super) fn peek_open(&mut self, tag: &str) -> bool {
|
||||
self.skip_ws();
|
||||
matches!(self.match_open_tag(tag), Some(TagKind::Open))
|
||||
}
|
||||
|
||||
/// Returns whether the next non-whitespace token is a self-closing tag for `tag`.
|
||||
pub(super) fn peek_self_closing(&mut self, tag: &str) -> bool {
|
||||
self.skip_ws();
|
||||
matches!(self.match_open_tag(tag), Some(TagKind::SelfClosing))
|
||||
}
|
||||
|
||||
/// Consumes an opening tag and errors when another token appears instead.
|
||||
pub(super) fn expect_open(&mut self, tag: &str) -> Result<(), String> {
|
||||
self.skip_ws();
|
||||
match self.consume_open_tag(tag) {
|
||||
Some(TagKind::Open) => Ok(()),
|
||||
_ => Err(format!("expected opening tag <{tag}>")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumes a closing tag and errors when it is absent.
|
||||
pub(super) fn expect_close(&mut self, tag: &str) -> Result<(), String> {
|
||||
self.skip_ws();
|
||||
if let Some(len) = self.match_close_tag(tag) {
|
||||
self.pos += len;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("expected closing tag </{tag}>"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumes a self-closing tag and errors when it is absent.
|
||||
pub(super) fn expect_self_closing(&mut self, tag: &str) -> Result<(), String> {
|
||||
self.skip_ws();
|
||||
match self.consume_open_tag(tag) {
|
||||
Some(TagKind::SelfClosing) => Ok(()),
|
||||
_ => Err(format!("expected self-closing tag <{tag}/>")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a text-only tag body, accepting an empty self-closing form.
|
||||
pub(super) fn read_text_tag(&mut self, tag: &str) -> Result<String, String> {
|
||||
if self.peek_self_closing(tag) {
|
||||
self.expect_self_closing(tag)?;
|
||||
return Ok(String::new());
|
||||
}
|
||||
self.expect_open(tag)?;
|
||||
let text = self.read_text_until_close(tag)?;
|
||||
self.expect_close(tag)?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// Reads a text-only tag body and trims surrounding whitespace.
|
||||
fn read_trimmed_text_tag(&mut self, tag: &str) -> Result<String, String> {
|
||||
Ok(self.read_text_tag(tag)?.trim().to_string())
|
||||
}
|
||||
|
||||
/// Reads decoded text until the matching closing tag is encountered.
|
||||
fn read_text_until_close(&mut self, tag: &str) -> Result<String, String> {
|
||||
let mut out = String::new();
|
||||
loop {
|
||||
if self.match_close_tag(tag).is_some() {
|
||||
return Ok(out);
|
||||
}
|
||||
let rest = self.rest();
|
||||
if rest.is_empty() {
|
||||
return Err(format!("missing closing tag </{tag}>"));
|
||||
}
|
||||
if rest.starts_with("<![CDATA[") {
|
||||
let end = rest
|
||||
.find("]]>")
|
||||
.ok_or_else(|| "unterminated CDATA section".to_string())?;
|
||||
out.push_str(&rest[9..end]);
|
||||
self.pos += end + 3;
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with("<!--") {
|
||||
let end = rest
|
||||
.find("-->")
|
||||
.ok_or_else(|| "unterminated XML comment".to_string())?;
|
||||
self.pos += end + 3;
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with("<?") {
|
||||
let end = rest
|
||||
.find("?>")
|
||||
.ok_or_else(|| "unterminated XML processing instruction".to_string())?;
|
||||
self.pos += end + 2;
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with('<') {
|
||||
return Err(format!("unexpected nested tag inside <{tag}> text"));
|
||||
}
|
||||
let next = rest.find('<').unwrap_or(rest.len());
|
||||
out.push_str(&unescape_xml_text(&rest[..next])?);
|
||||
self.pos += next;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the next non-whitespace token is either opening form for `tag`.
|
||||
fn next_is_value_tag(&mut self, tag: &str) -> bool {
|
||||
self.peek_open(tag) || self.peek_self_closing(tag)
|
||||
}
|
||||
|
||||
/// Reads a trimmed integer tag body into an i32 value.
|
||||
fn read_i32_tag(&mut self, tag: &str) -> Result<i32, String> {
|
||||
self.read_trimmed_text_tag(tag)?
|
||||
.parse::<i32>()
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Reads a large integer tag, preserving out-of-range values as strings.
|
||||
fn read_lossy_i32_or_string_tag(&mut self, tag: &str) -> Result<XmlRpcValue, String> {
|
||||
let raw = self.read_trimmed_text_tag(tag)?;
|
||||
raw.parse::<i64>()
|
||||
.map(|value| {
|
||||
i32::try_from(value).map_or_else(|_| XmlRpcValue::String(raw), XmlRpcValue::Int)
|
||||
})
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Reads a complete XML-RPC `<value>` element into the normalized value tree.
|
||||
pub(super) fn read_value_tag(&mut self) -> Result<XmlRpcValue, String> {
|
||||
if self.peek_self_closing("value") {
|
||||
self.expect_self_closing("value")?;
|
||||
return Ok(XmlRpcValue::String(String::new()));
|
||||
}
|
||||
self.expect_open("value")?;
|
||||
self.skip_ws();
|
||||
let value = if self.next_is_value_tag("int") {
|
||||
XmlRpcValue::Int(self.read_i32_tag("int")?)
|
||||
} else if self.next_is_value_tag("i4") {
|
||||
XmlRpcValue::Int(self.read_i32_tag("i4")?)
|
||||
} else if self.next_is_value_tag("i8") {
|
||||
self.read_lossy_i32_or_string_tag("i8")?
|
||||
} else if self.next_is_value_tag("biginteger") {
|
||||
self.read_lossy_i32_or_string_tag("biginteger")?
|
||||
} else if self.next_is_value_tag("boolean") {
|
||||
let raw = self.read_trimmed_text_tag("boolean")?;
|
||||
match raw.as_str() {
|
||||
"1" | "true" => XmlRpcValue::Bool(true),
|
||||
"0" | "false" => XmlRpcValue::Bool(false),
|
||||
_ => return Err("invalid boolean value".to_string()),
|
||||
}
|
||||
} else if self.match_open_tag("string").is_some() {
|
||||
XmlRpcValue::String(self.read_text_tag("string")?)
|
||||
} else if self.next_is_value_tag("double") {
|
||||
XmlRpcValue::Double(
|
||||
self.read_trimmed_text_tag("double")?
|
||||
.parse::<f64>()
|
||||
.map_err(|error| error.to_string())?,
|
||||
)
|
||||
} else if self.next_is_value_tag("dateTime.iso8601") {
|
||||
XmlRpcValue::DateTime(self.read_trimmed_text_tag("dateTime.iso8601")?)
|
||||
} else if self.next_is_value_tag("base64") {
|
||||
let data = self.read_trimmed_text_tag("base64")?;
|
||||
XmlRpcValue::Base64(base64_decode(&data)?)
|
||||
} else if self.peek_open("array") {
|
||||
self.expect_open("array")?;
|
||||
let mut items = Vec::new();
|
||||
if self.peek_self_closing("data") {
|
||||
self.expect_self_closing("data")?;
|
||||
} else {
|
||||
self.expect_open("data")?;
|
||||
while self.peek_open("value") || self.peek_self_closing("value") {
|
||||
items.push(self.read_value_tag()?);
|
||||
}
|
||||
self.expect_close("data")?;
|
||||
}
|
||||
self.expect_close("array")?;
|
||||
XmlRpcValue::Array(items)
|
||||
} else if self.peek_self_closing("array") {
|
||||
self.expect_self_closing("array")?;
|
||||
XmlRpcValue::Array(Vec::new())
|
||||
} else if self.peek_open("struct") {
|
||||
self.expect_open("struct")?;
|
||||
let mut members = Vec::new();
|
||||
while self.peek_open("member") {
|
||||
self.expect_open("member")?;
|
||||
let name = self.read_text_tag("name")?;
|
||||
let value = self.read_value_tag()?;
|
||||
self.expect_close("member")?;
|
||||
members.push(XmlRpcMember { name, value });
|
||||
}
|
||||
self.expect_close("struct")?;
|
||||
XmlRpcValue::Struct(members)
|
||||
} else if self.peek_self_closing("struct") {
|
||||
self.expect_self_closing("struct")?;
|
||||
XmlRpcValue::Struct(Vec::new())
|
||||
} else if self.peek_self_closing("nil") {
|
||||
self.expect_self_closing("nil")?;
|
||||
XmlRpcValue::Nil
|
||||
} else if self.peek_open("nil") {
|
||||
self.expect_open("nil")?;
|
||||
self.expect_close("nil")?;
|
||||
XmlRpcValue::Nil
|
||||
} else if self.rest().starts_with('<') {
|
||||
return Err("unsupported XML-RPC value type".to_string());
|
||||
} else {
|
||||
XmlRpcValue::String(self.read_text_until_close("value")?)
|
||||
};
|
||||
self.expect_close("value")?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Peeks at the next opening tag kind without consuming it.
|
||||
fn match_open_tag(&self, tag: &str) -> Option<TagKind> {
|
||||
let rest = self.rest();
|
||||
scan_open_tag(rest, tag).map(|(kind, _)| kind)
|
||||
}
|
||||
|
||||
/// Consumes the next opening tag for `tag` and returns its concrete kind.
|
||||
fn consume_open_tag(&mut self, tag: &str) -> Option<TagKind> {
|
||||
let (kind, len) = scan_open_tag(self.rest(), tag)?;
|
||||
self.pos += len;
|
||||
Some(kind)
|
||||
}
|
||||
|
||||
/// Returns the byte length of the next matching closing tag, if present.
|
||||
fn match_close_tag(&self, tag: &str) -> Option<usize> {
|
||||
scan_close_tag(self.rest(), tag)
|
||||
}
|
||||
}
|
||||
|
||||
/// Scans a namespaced-or-plain opening tag and returns its kind plus byte length.
|
||||
fn scan_open_tag(rest: &str, tag: &str) -> Option<(TagKind, usize)> {
|
||||
if !rest.starts_with('<')
|
||||
|| rest.starts_with("</")
|
||||
|| rest.starts_with("<?")
|
||||
|| rest.starts_with("<!")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut idx = 1;
|
||||
while let Some(ch) = rest[idx..].chars().next() {
|
||||
if ch.is_whitespace() || ch == '/' || ch == '>' {
|
||||
break;
|
||||
}
|
||||
idx += ch.len_utf8();
|
||||
}
|
||||
let name = &rest[1..idx];
|
||||
if name.rsplit(':').next()? != tag {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut cursor = idx;
|
||||
let mut quote = None;
|
||||
while let Some(ch) = rest[cursor..].chars().next() {
|
||||
let ch_len = ch.len_utf8();
|
||||
if let Some(active_quote) = quote {
|
||||
if ch == active_quote {
|
||||
quote = None;
|
||||
}
|
||||
cursor += ch_len;
|
||||
continue;
|
||||
}
|
||||
match ch {
|
||||
'"' | '\'' => {
|
||||
quote = Some(ch);
|
||||
cursor += ch_len;
|
||||
}
|
||||
'>' => return Some((TagKind::Open, cursor + ch_len)),
|
||||
'/' if rest[cursor..].starts_with("/>") => {
|
||||
return Some((TagKind::SelfClosing, cursor + 2));
|
||||
}
|
||||
_ => cursor += ch_len,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Scans a namespaced-or-plain closing tag and returns its byte length.
|
||||
fn scan_close_tag(rest: &str, tag: &str) -> Option<usize> {
|
||||
if !rest.starts_with("</") {
|
||||
return None;
|
||||
}
|
||||
let mut idx = 2;
|
||||
while let Some(ch) = rest[idx..].chars().next() {
|
||||
if ch.is_whitespace() || ch == '>' {
|
||||
break;
|
||||
}
|
||||
idx += ch.len_utf8();
|
||||
}
|
||||
let name = &rest[2..idx];
|
||||
if name.rsplit(':').next()? != tag {
|
||||
return None;
|
||||
}
|
||||
while let Some(ch) = rest[idx..].chars().next() {
|
||||
if ch.is_whitespace() {
|
||||
idx += ch.len_utf8();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
rest[idx..].starts_with('>').then_some(idx + 1)
|
||||
}
|
||||
|
||||
/// Decodes XML entities in a text node into their Unicode scalar values.
|
||||
fn unescape_xml_text(input: &str) -> Result<String, String> {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let bytes = input.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'&' {
|
||||
let rem = &input[i..];
|
||||
if rem.starts_with("&") {
|
||||
out.push('&');
|
||||
i += 5;
|
||||
} else if rem.starts_with("<") {
|
||||
out.push('<');
|
||||
i += 4;
|
||||
} else if rem.starts_with(">") {
|
||||
out.push('>');
|
||||
i += 4;
|
||||
} else if rem.starts_with(""") {
|
||||
out.push('"');
|
||||
i += 6;
|
||||
} else if rem.starts_with("'") {
|
||||
out.push('\'');
|
||||
i += 6;
|
||||
} else if rem.starts_with("&#") {
|
||||
let (digits_start, radix) = if rem.starts_with("&#x") || rem.starts_with("&#X") {
|
||||
(3, 16)
|
||||
} else {
|
||||
(2, 10)
|
||||
};
|
||||
let end = rem
|
||||
.find(';')
|
||||
.ok_or_else(|| "invalid numeric XML entity".to_string())?;
|
||||
let digits = &rem[digits_start..end];
|
||||
if digits.is_empty() {
|
||||
return Err("invalid numeric XML entity".to_string());
|
||||
}
|
||||
let codepoint = u32::from_str_radix(digits, radix)
|
||||
.map_err(|_| "invalid numeric XML entity".to_string())?;
|
||||
let ch = char::from_u32(codepoint)
|
||||
.ok_or_else(|| "invalid numeric XML entity".to_string())?;
|
||||
out.push(ch);
|
||||
i += end + 1;
|
||||
} else {
|
||||
return Err("unsupported XML entity".to_string());
|
||||
}
|
||||
} else if let Some(ch) = input[i..].chars().next() {
|
||||
out.push(ch);
|
||||
i += ch.len_utf8();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decodes a base64 value while tolerating insignificant XML whitespace.
|
||||
fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
|
||||
use base64::Engine;
|
||||
|
||||
let compact = input
|
||||
.bytes()
|
||||
.filter(|byte| !byte.is_ascii_whitespace())
|
||||
.collect::<Vec<_>>();
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(compact)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::*;
|
||||
use crate::model::{RpcMeta, RpcValue};
|
||||
|
||||
#[test]
|
||||
fn parses_method_call_with_common_value_types() {
|
||||
let xml = r#"<?xml version="1.0"?>
|
||||
<methodCall>
|
||||
<methodName>system.multicall</methodName>
|
||||
<params>
|
||||
<param><value><int>7</int></value></param>
|
||||
<param><value><boolean>1</boolean></value></param>
|
||||
<param><value><string>aria2</string></value></param>
|
||||
<param><value><double>3.5</double></value></param>
|
||||
<param><value><dateTime.iso8601>20260526T10:11:12</dateTime.iso8601></value></param>
|
||||
<param><value><base64>YXI=</base64></value></param>
|
||||
<param><value><array><data><value><nil/></value><value><string>x</string></value></data></array></value></param>
|
||||
<param><value><struct><member><name>a</name><value><i4>-2</i4></value></member></struct></value></param>
|
||||
</params>
|
||||
</methodCall>"#;
|
||||
|
||||
let call = xmlrpc_method_call_from_xml(xml).expect("parse methodCall");
|
||||
assert_eq!(call.method_name, "system.multicall");
|
||||
assert_eq!(
|
||||
call.params,
|
||||
vec![
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::Int(7),
|
||||
},
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::Bool(true),
|
||||
},
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::String("aria2".to_string()),
|
||||
},
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::Double(3.5),
|
||||
},
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::DateTime("20260526T10:11:12".to_string()),
|
||||
},
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::Base64(b"ar".to_vec()),
|
||||
},
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::Array(vec![
|
||||
XmlRpcValue::Nil,
|
||||
XmlRpcValue::String("x".to_string()),
|
||||
]),
|
||||
},
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::Struct(vec![XmlRpcMember {
|
||||
name: "a".to_string(),
|
||||
value: XmlRpcValue::Int(-2),
|
||||
}]),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_method_response_success_and_fault() {
|
||||
let ok_xml =
|
||||
xmlrpc_method_response_to_xml(&XmlRpcMethodResponse::success(XmlRpcValue::Struct(vec![
|
||||
XmlRpcMember {
|
||||
name: "status".to_string(),
|
||||
value: XmlRpcValue::String("done".to_string()),
|
||||
},
|
||||
])));
|
||||
let ok = xmlrpc_method_response_from_xml(&ok_xml).expect("parse success response");
|
||||
assert_eq!(
|
||||
ok,
|
||||
XmlRpcMethodResponse {
|
||||
value: Some(XmlRpcValue::Struct(vec![XmlRpcMember {
|
||||
name: "status".to_string(),
|
||||
value: XmlRpcValue::String("done".to_string()),
|
||||
}])),
|
||||
fault: None,
|
||||
meta: RpcMeta::default(),
|
||||
}
|
||||
);
|
||||
|
||||
let fault_xml = r#"<?xml version="1.0"?>
|
||||
<methodResponse>
|
||||
<fault>
|
||||
<value>
|
||||
<struct>
|
||||
<member><name>faultCode</name><value><int>4</int></value></member>
|
||||
<member><name>faultString</name><value><string>bad arg</string></value></member>
|
||||
</struct>
|
||||
</value>
|
||||
</fault>
|
||||
</methodResponse>"#;
|
||||
let fault = xmlrpc_method_response_from_xml(fault_xml).expect("parse fault response");
|
||||
assert_eq!(
|
||||
fault,
|
||||
XmlRpcMethodResponse {
|
||||
value: None,
|
||||
fault: Some(XmlRpcFault {
|
||||
code: 4,
|
||||
message: "bad arg".to_string(),
|
||||
error: None,
|
||||
}),
|
||||
meta: RpcMeta::default(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrips_rendered_method_call_and_response() {
|
||||
let call = XmlRpcMethodCall {
|
||||
method_name: "aria2.addUri".to_string(),
|
||||
params: vec![
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::Nil,
|
||||
},
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::Array(vec![XmlRpcValue::Bool(false)]),
|
||||
},
|
||||
],
|
||||
meta: RpcMeta::default(),
|
||||
};
|
||||
let parsed_call = xmlrpc_method_call_from_xml(&xmlrpc_method_call_to_xml(&call))
|
||||
.expect("roundtrip methodCall");
|
||||
assert_eq!(parsed_call, call);
|
||||
|
||||
let response = XmlRpcMethodResponse::success(XmlRpcValue::Array(vec![
|
||||
XmlRpcValue::Int(1),
|
||||
XmlRpcValue::String("ok".to_string()),
|
||||
]));
|
||||
let parsed_response =
|
||||
xmlrpc_method_response_from_xml(&xmlrpc_method_response_to_xml(&response))
|
||||
.expect("roundtrip methodResponse");
|
||||
assert_eq!(parsed_response, response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_nested_rpc_to_xmlrpc() {
|
||||
let mut inner = BTreeMap::new();
|
||||
inner.insert("flag".to_string(), RpcValue::Bool(true));
|
||||
inner.insert(
|
||||
"list".to_string(),
|
||||
RpcValue::Array(vec![RpcValue::Number(7), RpcValue::Null]),
|
||||
);
|
||||
|
||||
let mut root = BTreeMap::new();
|
||||
root.insert("name".to_string(), RpcValue::String("aria".to_string()));
|
||||
root.insert("meta".to_string(), RpcValue::Object(inner));
|
||||
|
||||
let xml = rpc_value_to_xmlrpc(RpcValue::Object(root));
|
||||
assert_eq!(
|
||||
xml,
|
||||
XmlRpcValue::Struct(vec![
|
||||
XmlRpcMember {
|
||||
name: "meta".to_string(),
|
||||
value: XmlRpcValue::Struct(vec![
|
||||
XmlRpcMember {
|
||||
name: "flag".to_string(),
|
||||
value: XmlRpcValue::Bool(true),
|
||||
},
|
||||
XmlRpcMember {
|
||||
name: "list".to_string(),
|
||||
value: XmlRpcValue::Array(vec![XmlRpcValue::Int(7), XmlRpcValue::Nil]),
|
||||
},
|
||||
]),
|
||||
},
|
||||
XmlRpcMember {
|
||||
name: "name".to_string(),
|
||||
value: XmlRpcValue::String("aria".to_string()),
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_nested_xmlrpc_back_to_rpc() {
|
||||
let value = XmlRpcValue::Struct(vec![
|
||||
XmlRpcMember {
|
||||
name: "name".to_string(),
|
||||
value: XmlRpcValue::String("aria2".to_string()),
|
||||
},
|
||||
XmlRpcMember {
|
||||
name: "items".to_string(),
|
||||
value: XmlRpcValue::Array(vec![
|
||||
XmlRpcValue::Int(42),
|
||||
XmlRpcValue::Bool(false),
|
||||
XmlRpcValue::Nil,
|
||||
]),
|
||||
},
|
||||
XmlRpcMember {
|
||||
name: "stamp".to_string(),
|
||||
value: XmlRpcValue::DateTime("2026-01-01T00:00:00Z".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
let rpc = xmlrpc_value_to_rpc(value);
|
||||
let expected = RpcValue::Object(BTreeMap::from([
|
||||
(
|
||||
"items".to_string(),
|
||||
RpcValue::Array(vec![
|
||||
RpcValue::Number(42),
|
||||
RpcValue::Bool(false),
|
||||
RpcValue::Null,
|
||||
]),
|
||||
),
|
||||
("name".to_string(), RpcValue::String("aria2".to_string())),
|
||||
(
|
||||
"stamp".to_string(),
|
||||
RpcValue::String("2026-01-01T00:00:00Z".to_string()),
|
||||
),
|
||||
]));
|
||||
assert_eq!(rpc, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_method_call_xml_with_escaped_text() {
|
||||
let call = XmlRpcMethodCall {
|
||||
method_name: "aria2.addUri".to_string(),
|
||||
params: vec![XmlRpcParam {
|
||||
value: XmlRpcValue::Array(vec![XmlRpcValue::String(
|
||||
"https://a.example/?q=1&v=<ok>".to_string(),
|
||||
)]),
|
||||
}],
|
||||
meta: RpcMeta::default(),
|
||||
};
|
||||
|
||||
let xml = xmlrpc_method_call_to_xml(&call);
|
||||
assert!(xml.contains("<methodName>aria2.addUri</methodName>"));
|
||||
assert!(xml.contains("&"));
|
||||
assert!(xml.contains("<ok>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_fault_method_response_xml() {
|
||||
let resp = XmlRpcMethodResponse {
|
||||
value: None,
|
||||
fault: Some(XmlRpcFault {
|
||||
code: 3,
|
||||
message: "bad <arg> & state".to_string(),
|
||||
error: None,
|
||||
}),
|
||||
meta: RpcMeta::default(),
|
||||
};
|
||||
|
||||
let xml = xmlrpc_method_response_to_xml(&resp);
|
||||
assert!(xml.contains("<fault>"));
|
||||
assert!(xml.contains("<name>faultCode</name>"));
|
||||
assert!(xml.contains("<int>3</int>"));
|
||||
assert!(xml.contains("bad <arg> & state"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_aria2_style_multicall_raw_payload() {
|
||||
let xml = r#"<?xml version="1.0"?>
|
||||
<methodCall>
|
||||
<methodName>system.multicall</methodName>
|
||||
<params>
|
||||
<param>
|
||||
<value>
|
||||
<array>
|
||||
<data>
|
||||
<value>
|
||||
<struct>
|
||||
<member><name>methodName</name><value><string>aria2.tellActive</string></value></member>
|
||||
<member><name>params</name><value><array><data></data></array></value></member>
|
||||
</struct>
|
||||
</value>
|
||||
<value>
|
||||
<struct>
|
||||
<member><name>methodName</name><value><string>aria2.tellWaiting</string></value></member>
|
||||
<member><name>params</name><value><array><data><value><int>0</int></value><value><int>10</int></value></data></array></value></member>
|
||||
</struct>
|
||||
</value>
|
||||
</data>
|
||||
</array>
|
||||
</value>
|
||||
</param>
|
||||
</params>
|
||||
</methodCall>"#;
|
||||
|
||||
let call = xmlrpc_method_call_from_xml(xml).expect("parse aria2 multicall");
|
||||
assert_eq!(call.method_name, "system.multicall");
|
||||
assert_eq!(call.params.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_empty_params_self_closing_for_call_and_response() {
|
||||
let call_xml = r#"<?xml version="1.0"?>
|
||||
<methodCall>
|
||||
<methodName>aria2.tellActive</methodName>
|
||||
<params/>
|
||||
</methodCall>"#;
|
||||
let call = xmlrpc_method_call_from_xml(call_xml).expect("parse methodCall with <params/>");
|
||||
assert_eq!(call.method_name, "aria2.tellActive");
|
||||
assert!(call.params.is_empty());
|
||||
|
||||
let resp_xml = r#"<?xml version="1.0"?>
|
||||
<methodResponse>
|
||||
<params/>
|
||||
</methodResponse>"#;
|
||||
let resp =
|
||||
xmlrpc_method_response_from_xml(resp_xml).expect("parse methodResponse with <params/>");
|
||||
assert!(resp.value.is_none());
|
||||
assert!(resp.fault.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_comments_processing_instructions_and_spaced_self_closing_tags() {
|
||||
let call_xml = r#"<?xml version="1.0"?>
|
||||
<!-- aria2-compatible XML-RPC client -->
|
||||
<?client probe?>
|
||||
<methodCall>
|
||||
<methodName>system.listNotifications</methodName>
|
||||
<!-- empty params -->
|
||||
<params />
|
||||
</methodCall>"#;
|
||||
let call = xmlrpc_method_call_from_xml(call_xml).expect("parse decorated methodCall");
|
||||
assert_eq!(call.method_name, "system.listNotifications");
|
||||
assert!(call.params.is_empty());
|
||||
|
||||
let response_xml = r#"<?xml version="1.0"?>
|
||||
<?client probe?>
|
||||
<methodResponse>
|
||||
<!-- empty response -->
|
||||
<params />
|
||||
</methodResponse>"#;
|
||||
let response =
|
||||
xmlrpc_method_response_from_xml(response_xml).expect("parse decorated methodResponse");
|
||||
assert!(response.value.is_none());
|
||||
assert!(response.fault.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_empty_typed_values_and_collections() {
|
||||
let xml = r#"<?xml version="1.0"?>
|
||||
<methodCall>
|
||||
<methodName>aria2.addUri</methodName>
|
||||
<params>
|
||||
<param><value /></param>
|
||||
<param><value><string /></value></param>
|
||||
<param><value><array><data /></array></value></param>
|
||||
<param><value><struct /></value></param>
|
||||
<param><value><nil></nil></value></param>
|
||||
</params>
|
||||
</methodCall>"#;
|
||||
|
||||
let call = xmlrpc_method_call_from_xml(xml).expect("parse empty typed values");
|
||||
assert_eq!(
|
||||
call.params,
|
||||
vec![
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::String(String::new()),
|
||||
},
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::String(String::new()),
|
||||
},
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::Array(Vec::new()),
|
||||
},
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::Struct(Vec::new()),
|
||||
},
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::Nil,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unescapes_numeric_xml_entities_in_strings_and_member_names() {
|
||||
let xml = r#"<?xml version="1.0"?>
|
||||
<methodResponse>
|
||||
<params>
|
||||
<param>
|
||||
<value>
|
||||
<struct>
|
||||
<member>
|
||||
<name>line_break</name>
|
||||
<value><string>alpha beta!</string></value>
|
||||
</member>
|
||||
</struct>
|
||||
</value>
|
||||
</param>
|
||||
</params>
|
||||
</methodResponse>"#;
|
||||
|
||||
let response = xmlrpc_method_response_from_xml(xml).expect("parse numeric XML entities");
|
||||
assert_eq!(
|
||||
response.value,
|
||||
Some(XmlRpcValue::Struct(vec![XmlRpcMember {
|
||||
name: "line_break".to_string(),
|
||||
value: XmlRpcValue::String("alpha\nbeta!".to_string()),
|
||||
}]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_large_rpc_numbers_to_lossless_xmlrpc_strings() {
|
||||
let value = rpc_value_to_xmlrpc(RpcValue::Number(i64::from(i32::MAX) + 1));
|
||||
assert_eq!(value, XmlRpcValue::String("2147483648".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_fault_with_i4_code_and_escaped_message() {
|
||||
let xml = r#"<?xml version="1.0"?>
|
||||
<methodResponse>
|
||||
<fault>
|
||||
<value>
|
||||
<struct>
|
||||
<member><name>faultCode</name><value><i4>1</i4></value></member>
|
||||
<member><name>faultString</name><value><string>Method not found: <aria2.nope></string></value></member>
|
||||
</struct>
|
||||
</value>
|
||||
</fault>
|
||||
</methodResponse>"#;
|
||||
|
||||
let resp = xmlrpc_method_response_from_xml(xml).expect("parse aria2-ish fault");
|
||||
let fault = resp.fault.expect("fault expected");
|
||||
assert_eq!(fault.code, 1);
|
||||
assert_eq!(fault.message, "Method not found: <aria2.nope>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_untyped_value_text_as_string() {
|
||||
let xml = r#"<?xml version="1.0"?>
|
||||
<methodResponse>
|
||||
<params>
|
||||
<param><value>OK</value></param>
|
||||
</params>
|
||||
</methodResponse>"#;
|
||||
let resp = xmlrpc_method_response_from_xml(xml).expect("parse untyped text value");
|
||||
assert_eq!(resp.value, Some(XmlRpcValue::String("OK".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_attribute_decorated_and_prefixed_value_tags() {
|
||||
let xml = r#"<?xml version="1.0"?>
|
||||
<methodCall xmlns:ex="http://ws.apache.org/xmlrpc/namespaces/extensions">
|
||||
<methodName xml:space="preserve">aria2.tellStatus</methodName>
|
||||
<params class="ignored">
|
||||
<param>
|
||||
<value>
|
||||
<string xml:space="preserve">gid-1</string>
|
||||
</value>
|
||||
</param>
|
||||
<param>
|
||||
<value>
|
||||
<ex:nil />
|
||||
</value>
|
||||
</param>
|
||||
<param>
|
||||
<value>
|
||||
<base64 encoding="base64"/>
|
||||
</value>
|
||||
</param>
|
||||
<param>
|
||||
<value>
|
||||
<array role="empty">
|
||||
<data id="slot-1"/>
|
||||
</array>
|
||||
</value>
|
||||
</param>
|
||||
<param>
|
||||
<value>
|
||||
<struct role="empty"/>
|
||||
</value>
|
||||
</param>
|
||||
</params>
|
||||
</methodCall>"#;
|
||||
|
||||
let call = xmlrpc_method_call_from_xml(xml).expect("parse decorated XML-RPC call");
|
||||
assert_eq!(call.method_name, "aria2.tellStatus");
|
||||
assert_eq!(
|
||||
call.params,
|
||||
vec![
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::String("gid-1".to_string()),
|
||||
},
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::Nil,
|
||||
},
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::Base64(Vec::new()),
|
||||
},
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::Array(Vec::new()),
|
||||
},
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::Struct(Vec::new()),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_fault_with_i8_code_and_untyped_fault_string() {
|
||||
let xml = r#"<?xml version="1.0"?>
|
||||
<methodResponse>
|
||||
<fault>
|
||||
<value>
|
||||
<struct>
|
||||
<member><name>faultCode</name><value><i8>1</i8></value></member>
|
||||
<member><name>faultString</name><value>Method not found: aria2.noSuchMethod</value></member>
|
||||
</struct>
|
||||
</value>
|
||||
</fault>
|
||||
</methodResponse>"#;
|
||||
|
||||
let resp = xmlrpc_method_response_from_xml(xml).expect("parse fault with i8 code");
|
||||
assert_eq!(
|
||||
resp.fault,
|
||||
Some(XmlRpcFault {
|
||||
code: 1,
|
||||
message: "Method not found: aria2.noSuchMethod".to_string(),
|
||||
error: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_large_i8_losslessly_as_string_value() {
|
||||
let xml = r#"<?xml version="1.0"?>
|
||||
<methodResponse>
|
||||
<params>
|
||||
<param><value><i8>2147483648</i8></value></param>
|
||||
</params>
|
||||
</methodResponse>"#;
|
||||
|
||||
let resp = xmlrpc_method_response_from_xml(xml).expect("parse large i8 value");
|
||||
assert_eq!(
|
||||
resp.value,
|
||||
Some(XmlRpcValue::String("2147483648".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_cdata_and_mixed_text_inside_string_and_method_name() {
|
||||
let xml = r#"<?xml version="1.0"?>
|
||||
<methodCall>
|
||||
<methodName><![CDATA[system.listMethods]]></methodName>
|
||||
<params>
|
||||
<param>
|
||||
<value>
|
||||
<string><![CDATA[alpha<&>]]><!-- gap --> beta</string>
|
||||
</value>
|
||||
</param>
|
||||
</params>
|
||||
</methodCall>"#;
|
||||
|
||||
let call = xmlrpc_method_call_from_xml(xml).expect("parse CDATA-rich method call");
|
||||
assert_eq!(call.method_name, "system.listMethods");
|
||||
assert_eq!(
|
||||
call.params,
|
||||
vec![XmlRpcParam {
|
||||
value: XmlRpcValue::String("alpha<&>\nbeta".to_string()),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_whitespace_padded_scalar_tags_losslessly() {
|
||||
let xml = r#"<?xml version="1.0"?>
|
||||
<methodResponse xmlns:ex="urn:example">
|
||||
<params>
|
||||
<param>
|
||||
<value>
|
||||
<array>
|
||||
<data>
|
||||
<value><ex:int>
|
||||
7
|
||||
</ex:int></value>
|
||||
<value><i4> -2 </i4></value>
|
||||
<value><i8>
|
||||
2147483647
|
||||
</i8></value>
|
||||
<value><biginteger>
|
||||
2147483648
|
||||
</biginteger></value>
|
||||
<value><double>
|
||||
3.5
|
||||
</double></value>
|
||||
<value><dateTime.iso8601>
|
||||
20260527T12:34:56
|
||||
</dateTime.iso8601></value>
|
||||
</data>
|
||||
</array>
|
||||
</value>
|
||||
</param>
|
||||
</params>
|
||||
</methodResponse>"#;
|
||||
|
||||
let resp = xmlrpc_method_response_from_xml(xml).expect("parse spaced scalar values");
|
||||
assert_eq!(
|
||||
resp.value,
|
||||
Some(XmlRpcValue::Array(vec![
|
||||
XmlRpcValue::Int(7),
|
||||
XmlRpcValue::Int(-2),
|
||||
XmlRpcValue::Int(2_147_483_647),
|
||||
XmlRpcValue::String("2147483648".to_string()),
|
||||
XmlRpcValue::Double(3.5),
|
||||
XmlRpcValue::DateTime("20260527T12:34:56".to_string()),
|
||||
]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_fault_members_from_whitespace_padded_scalar_forms() {
|
||||
let xml = r#"<?xml version="1.0"?>
|
||||
<methodResponse>
|
||||
<fault>
|
||||
<value>
|
||||
<struct>
|
||||
<member><name>faultCode</name><value>
|
||||
12
|
||||
</value></member>
|
||||
<member><name>faultString</name><value><boolean>
|
||||
true
|
||||
</boolean></value></member>
|
||||
</struct>
|
||||
</value>
|
||||
</fault>
|
||||
</methodResponse>"#;
|
||||
|
||||
let resp = xmlrpc_method_response_from_xml(xml).expect("parse padded fault fields");
|
||||
assert_eq!(
|
||||
resp.fault,
|
||||
Some(XmlRpcFault {
|
||||
code: 12,
|
||||
message: "1".to_string(),
|
||||
error: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_renders_aria2_add_uri_method_call_shape() {
|
||||
let call = XmlRpcMethodCall {
|
||||
method_name: "aria2.addUri".to_string(),
|
||||
params: vec![
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::String("token:abc123".to_string()),
|
||||
},
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::Array(vec![XmlRpcValue::String(
|
||||
"https://example.org/file.iso".to_string(),
|
||||
)]),
|
||||
},
|
||||
XmlRpcParam {
|
||||
value: XmlRpcValue::Struct(vec![
|
||||
XmlRpcMember {
|
||||
name: "split".to_string(),
|
||||
value: XmlRpcValue::String("16".to_string()),
|
||||
},
|
||||
XmlRpcMember {
|
||||
name: "max-connection-per-server".to_string(),
|
||||
value: XmlRpcValue::String("16".to_string()),
|
||||
},
|
||||
]),
|
||||
},
|
||||
],
|
||||
meta: RpcMeta::default(),
|
||||
};
|
||||
|
||||
let xml = xmlrpc_method_call_to_xml(&call);
|
||||
let expected = "<?xml version=\"1.0\"?><methodCall><methodName>aria2.addUri</methodName><params><param><value><string>token:abc123</string></value></param><param><value><array><data><value><string>https://example.org/file.iso</string></value></data></array></value></param><param><value><struct><member><name>split</name><value><string>16</string></value></member><member><name>max-connection-per-server</name><value><string>16</string></value></member></struct></value></param></params></methodCall>";
|
||||
assert_eq!(xml, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_renders_xmlrpc_fault_struct_shape() {
|
||||
let response = XmlRpcMethodResponse {
|
||||
value: None,
|
||||
fault: Some(XmlRpcFault {
|
||||
code: 1,
|
||||
message: "Method not found: aria2.notFound".to_string(),
|
||||
error: None,
|
||||
}),
|
||||
meta: RpcMeta::default(),
|
||||
};
|
||||
let xml = xmlrpc_method_response_to_xml(&response);
|
||||
let expected = "<?xml version=\"1.0\"?><methodResponse><fault><value><struct><member><name>faultCode</name><value><int>1</int></value></member><member><name>faultString</name><value><string>Method not found: aria2.notFound</string></value></member></struct></value></fault></methodResponse>";
|
||||
assert_eq!(xml, expected);
|
||||
}
|
||||
Reference in New Issue
Block a user