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