Files
aria2-rust-pro/crates/aria2-rust-pro-rpc/src/handlers.rs
T

642 lines
23 KiB
Rust

//! 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:?}"),
}
}
}