From 0220320ea4320e71134868b22f33c53ed737ef93 Mon Sep 17 00:00:00 2001 From: spinline Date: Tue, 3 Feb 2026 18:06:55 +0300 Subject: [PATCH] refactor(backend): secure xmlrpc with serde and quick-xml --- backend/Cargo.toml | 2 +- backend/src/xmlrpc.rs | 365 +++++++++++++++++++++++++----------------- 2 files changed, 220 insertions(+), 147 deletions(-) diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 092dc5c..1169999 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -15,7 +15,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } tokio-stream = "0.1" bytes = "1" futures = "0.3" -quick-xml = { version = "0.31", features = ["serialize"] } +quick-xml = { version = "0.31", features = ["serde", "serialize"] } # We might need `tokio-util` for codecs if we implement SCGI manually tokio-util = { version = "0.7", features = ["codec", "io"] } clap = { version = "4.4", features = ["derive"] } diff --git a/backend/src/xmlrpc.rs b/backend/src/xmlrpc.rs index b79e8c1..64d1760 100644 --- a/backend/src/xmlrpc.rs +++ b/backend/src/xmlrpc.rs @@ -1,21 +1,138 @@ use crate::scgi::{send_request, ScgiRequest}; -use quick_xml::events::Event; -use quick_xml::reader::Reader; +use quick_xml::de::from_str; +use quick_xml::se::to_string; +use serde::{Deserialize, Serialize}; +// --- Request Models --- -// Simple helper to build an XML-RPC method call -pub fn build_method_call(method: &str, params: &[&str]) -> String { - let mut xml = String::from("\n\n"); - xml.push_str(&format!("{}\n\n", method)); - for param in params { - xml.push_str("\n"); - } - xml.push_str("\n"); - xml +#[derive(Debug, Serialize)] +#[serde(rename = "methodCall")] +struct MethodCall<'a> { + #[serde(rename = "methodName")] + method_name: &'a str, + params: RequestParams<'a>, } +#[derive(Debug, Serialize)] +struct RequestParams<'a> { + param: Vec>, +} + +#[derive(Debug, Serialize)] +struct RequestParam<'a> { + value: RequestValueInner<'a>, +} + +#[derive(Debug, Serialize)] +struct RequestValueInner<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + string: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + i4: Option, +} + +// --- Response Models for d.multicall2 --- + +#[derive(Debug, Deserialize)] +#[serde(rename = "methodResponse")] +struct MulticallResponse { + params: MulticallResponseParams, +} + +#[derive(Debug, Deserialize)] +struct MulticallResponseParams { + param: MulticallResponseParam, +} + +#[derive(Debug, Deserialize)] +struct MulticallResponseParam { + value: MulticallResponseValueArray, +} + +// Top level array in d.multicall2 response +#[derive(Debug, Deserialize)] +struct MulticallResponseValueArray { + array: MulticallResponseDataOuter, +} + +#[derive(Debug, Deserialize)] +struct MulticallResponseDataOuter { + data: MulticallResponseDataOuterValue, +} + +#[derive(Debug, Deserialize)] +struct MulticallResponseDataOuterValue { + #[serde(rename = "value", default)] + values: Vec, +} + +// Each row in the response +#[derive(Debug, Deserialize)] +struct MulticallRowValue { + array: MulticallResponseDataInner, +} + +#[derive(Debug, Deserialize)] +struct MulticallResponseDataInner { + data: MulticallResponseDataInnerValue, +} + +#[derive(Debug, Deserialize)] +struct MulticallResponseDataInnerValue { + #[serde(rename = "value", default)] + values: Vec, +} + +// Each item in a row (column) +#[derive(Debug, Deserialize)] +struct MulticallItemValue { + #[serde(rename = "string", default)] + string: Option, + #[serde(rename = "i4", default)] + i4: Option, + #[serde(rename = "i8", default)] + i8: Option, +} + +impl MulticallItemValue { + fn to_string_lossy(&self) -> String { + if let Some(s) = &self.string { + s.clone() + } else if let Some(i) = self.i4 { + i.to_string() + } else if let Some(i) = self.i8 { + i.to_string() + } else { + String::new() + } + } +} + +// --- Response Model for simple string --- + +#[derive(Debug, Deserialize)] +#[serde(rename = "methodResponse")] +struct StringResponse { + params: StringResponseParams, +} + +#[derive(Debug, Deserialize)] +struct StringResponseParams { + param: StringResponseParam, +} + +#[derive(Debug, Deserialize)] +struct StringResponseParam { + value: StringResponseValue, +} + +#[derive(Debug, Deserialize)] +struct StringResponseValue { + string: String, +} + +// --- Client Implementation --- + pub struct RtorrentClient { socket_path: String, } @@ -27,155 +144,111 @@ impl RtorrentClient { } } + /// Helper to build and serialize XML-RPC method call + fn build_method_call(&self, method: &str, params: &[&str]) -> Result { + let req_params = RequestParams { + param: params + .iter() + .map(|p| RequestParam { + value: RequestValueInner { + string: Some(p), + i4: None, + }, + }) + .collect(), + }; + + let call = MethodCall { + method_name: method, + params: req_params, + }; + + let xml_body = to_string(&call).map_err(|e| format!("Serialization error: {}", e))?; + Ok(format!("\n{}", xml_body)) + } + pub async fn call(&self, method: &str, params: &[&str]) -> Result { - let xml = build_method_call(method, params); + let xml = self.build_method_call(method, params)?; let req = ScgiRequest::new().body(xml.into_bytes()); - + match send_request(&self.socket_path, req).await { Ok(bytes) => { let s = String::from_utf8_lossy(&bytes).to_string(); Ok(s) } - Err(e) => Err(format!("{:?}", e)), + Err(e) => Err(format!("SCGI Error: {:?}", e)), } } } -// Specialized parser for d.multicall2 response -// Expected structure: -// -// -// HASH -// NAME -// ... -// -// ... -// - pub fn parse_multicall_response(xml: &str) -> Result>, String> { - let mut reader = Reader::from_str(xml); - reader.trim_text(true); + let response: MulticallResponse = + from_str(xml).map_err(|e| format!("XML Parse Error: {}", e))?; - let mut buf = Vec::new(); - let mut results = Vec::new(); - let mut current_row = Vec::new(); - let mut inside_value = false; - let mut current_text = String::new(); + let mut result = Vec::new(); - // Loop through events - // Strategy: We look for inside the outer array. - // The outer array contains values which are arrays (rows). - // Each row array contains values (columns). - - // Simplified logic: flatten all ... content, but respect structure? - // Actually, handling nested arrays properly with a streaming parser is tricky. - // Let's rely on the fact that d.multicall2 returns a 2D array. - // Depth 0: methodResponse/params/param/value/array/data - // Depth 1: value (row) / array / data - // Depth 2: value (col) / type (string/i8/i4) - - // We can count depth. - - let mut array_depth = 0; - - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(ref e)) => { - match e.name().as_ref() { - b"array" => array_depth += 1, - b"value" => inside_value = true, - _ => (), - } - } - Ok(Event::End(ref e)) => { - match e.name().as_ref() { - b"array" => { - array_depth -= 1; - // If we just finished a row (depth 1 which means the inner array of the main list) - if array_depth == 1 { - if !current_row.is_empty() { - results.push(current_row.clone()); - current_row.clear(); - } - } - }, - b"value" => { - inside_value = false; - // If we are at depth 2 (inside a column value) - if array_depth == 2 && !current_text.is_empty() { - current_row.push(current_text.clone()); - current_text.clear(); - } else if array_depth == 2 { - // Empty value or non-text? - // Sometimes values are empty, e.g. empty string - // We should push it if we just closed a value at depth 2 - // But wait, the text event handles the content. - // Logic: If we closed value at depth 2, we push the collected text (which might be empty). - // To handle empty text correctly, we should clear text at Start(value) or use a flag. - if inside_value == false { // we just closed it - current_row.push(current_text.clone()); - current_text.clear(); - } - } - } - _ => (), - } - } - Ok(Event::Text(e)) => { - if inside_value && array_depth == 2 { - current_text = e.unescape().unwrap().into_owned(); - } - } - Ok(Event::Eof) => break, - Err(e) => return Err(format!("Parse error: {:?}", e)), - _ => (), - } - buf.clear(); - } - - Ok(results) -} - -// Parse a simple string response from a method call -// Expected: RESULT -pub fn parse_string_response(xml: &str) -> Result { - let mut reader = Reader::from_str(xml); - reader.trim_text(true); - let mut buf = Vec::new(); - let mut result = String::new(); - let mut inside_string = false; - - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(ref e)) => { - if e.name().as_ref() == b"string" { - inside_string = true; - } - } - Ok(Event::Text(e)) => { - if inside_string { - result = e.unescape().unwrap().into_owned(); - } - } - Ok(Event::End(ref e)) => { - if e.name().as_ref() == b"string" { - // inside_string = false; - // Assuming only one string in the response which matters - break; - } - } - Ok(Event::Eof) => break, - _ => (), - } - } - - if result.is_empty() { - // It might be empty string or we didn't find it. - // If xml contains "fault", we should verify. - if xml.contains("fault") { - return Err("RPC Fault detected".to_string()); + for row in response.params.param.value.array.data.values { + let mut row_vec = Vec::new(); + for item in row.array.data.values { + row_vec.push(item.to_string_lossy()); } + result.push(row_vec); } Ok(result) } + +pub fn parse_string_response(xml: &str) -> Result { + let response: StringResponse = from_str(xml).map_err(|e| format!("XML Parse Error: {}", e))?; + Ok(response.params.param.value.string) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_method_call() { + let client = RtorrentClient::new("dummy"); + let xml = client + .build_method_call("d.multicall2", &["", "main", "d.name="]) + .unwrap(); + + println!("Generated XML: {}", xml); + + assert!(xml.contains("d.multicall2")); + // With struct option serialization, it should produce ... + assert!(xml.contains("main")); + } + + #[test] + fn test_parse_multicall_response() { + let xml = r#" + + + + + + + + +HASH123 +Ubuntu ISO +1024 + + + + + + + + + +"#; + let result = parse_multicall_response(xml).expect("Failed to parse"); + assert_eq!(result.len(), 1); + assert_eq!(result[0][0], "HASH123"); + assert_eq!(result[0][1], "Ubuntu ISO"); + assert_eq!(result[0][2], "1024"); + } +}