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, /// Fully buffered request body bytes. pub(super) body: Vec, } #[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, 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, 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> { 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::>(); let content_length = headers .get("content-length") .and_then(|value| value.parse::().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 { 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 { 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 { 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 { 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 { 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) -> Option { 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("") { body = &rest[end + 2..]; continue; } if let Some(rest) = body.strip_prefix("") { 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(" Vec { 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>, config: &RpcServerConfig, request: HttpRequest, ) -> io::Result> { 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, Vec> { 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" 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 { 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 { 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()) } } }