sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit 97d674470771d6a3526a8f6457c6258d150cc9f3
parent bd7cecfa4b605b051139d833ede561e860299e3a
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 02:25:28 +0000

radrootsd: harden HTTP adapter coverage

- validate JSON-RPC version and response ids
- omit raw HTTP response bodies from status errors
- exercise publish requests through a local HTTP path
- cover adapter success and failure response handling

Diffstat:
Mcrates/sdk/src/adapters/radrootsd.rs | 290+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 284 insertions(+), 6 deletions(-)

diff --git a/crates/sdk/src/adapters/radrootsd.rs b/crates/sdk/src/adapters/radrootsd.rs @@ -394,6 +394,8 @@ impl std::error::Error for RadrootsdError {} #[derive(Debug, Deserialize)] struct JsonRpcEnvelope<T> { + jsonrpc: Option<String>, + id: Option<Value>, result: Option<T>, error: Option<JsonRpcError>, } @@ -445,20 +447,40 @@ pub fn bridge_listing_publish_request_json( } fn http_status_error(status: reqwest::StatusCode, body: &str) -> RadrootsdError { + let body_summary = if body.is_empty() { + "response body empty".to_owned() + } else { + format!("response body omitted ({} bytes)", body.len()) + }; RadrootsdError::Http(format!( "radrootsd returned http {}: {}", status.as_u16(), - body + body_summary )) } -fn decode_jsonrpc_response<R>(method: &str, body: &str) -> Result<R, RadrootsdError> +fn decode_jsonrpc_response<R>( + method: &str, + expected_id: &str, + body: &str, +) -> Result<R, RadrootsdError> where R: DeserializeOwned, { let envelope: JsonRpcEnvelope<R> = serde_json::from_str(body).map_err(|err| { RadrootsdError::MalformedResponse(format!("decode radrootsd {method} response: {err}")) })?; + if envelope.jsonrpc.as_deref() != Some("2.0") { + return Err(RadrootsdError::MalformedResponse(format!( + "radrootsd {method} returned invalid jsonrpc version" + ))); + } + let expected_id_value = Value::String(expected_id.to_owned()); + if envelope.id.as_ref() != Some(&expected_id_value) { + return Err(RadrootsdError::MalformedResponse(format!( + "radrootsd {method} returned mismatched jsonrpc id" + ))); + } match (envelope.result, envelope.error) { (Some(result), None) => Ok(result), (None, Some(error)) => Err(RadrootsdError::JsonRpc(format!( @@ -517,7 +539,7 @@ where return Err(http_status_error(status, body.as_str())); } - decode_jsonrpc_response(method, body.as_str()) + decode_jsonrpc_response(method, request_id, body.as_str()) } #[cfg(test)] @@ -532,6 +554,84 @@ mod tests { RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::thread::JoinHandle; + + struct RecordedHttpRequest { + request_line: String, + headers: Vec<(String, String)>, + body: String, + } + + fn spawn_http_server( + status: &str, + response_body: &str, + ) -> (String, JoinHandle<RecordedHttpRequest>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let endpoint = format!("http://{}/rpc", listener.local_addr().expect("addr")); + let status = status.to_owned(); + let response_body = response_body.to_owned(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + let mut request = Vec::new(); + let mut buffer = [0u8; 1024]; + loop { + let read = stream.read(&mut buffer).expect("read request"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + let headers_end = request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("headers end") + + 4; + let header_text = String::from_utf8_lossy(&request[..headers_end]); + let content_length = header_text + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::<usize>().expect("content length")) + }) + .unwrap_or(0); + while request.len() < headers_end + content_length { + let read = stream.read(&mut buffer).expect("read body"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + } + break; + } + } + let request_text = String::from_utf8_lossy(&request); + let (headers_text, body) = request_text.split_once("\r\n\r\n").expect("request body"); + let mut header_lines = headers_text.lines(); + let request_line = header_lines.next().expect("request line").to_owned(); + let headers = header_lines + .filter_map(|line| { + let (name, value) = line.split_once(':')?; + Some((name.to_ascii_lowercase(), value.trim().to_owned())) + }) + .collect::<Vec<_>>(); + let response = format!( + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{response_body}", + response_body.len() + ); + stream + .write_all(response.as_bytes()) + .expect("write response"); + RecordedHttpRequest { + request_line, + headers, + body: body.to_owned(), + } + }); + (endpoint, handle) + } fn sample_listing() -> RadrootsListing { RadrootsListing { @@ -700,16 +800,20 @@ mod tests { } #[test] - fn http_status_error_reports_status_and_body() { - let error = http_status_error(reqwest::StatusCode::UNAUTHORIZED, "missing token"); + fn http_status_error_omits_raw_body() { + let error = http_status_error(reqwest::StatusCode::UNAUTHORIZED, "missing secret token"); - assert_message(error, "radrootsd returned http 401: missing token"); + let message = error.to_string(); + assert!(message.contains("radrootsd returned http 401")); + assert!(message.contains("response body omitted")); + assert!(!message.contains("missing secret token")); } #[test] fn decode_jsonrpc_response_returns_result() { let response: SdkRadrootsdBridgePublishResponse = decode_jsonrpc_response( "bridge.listing.publish", + "radroots-sdk-listing-publish", r#"{ "jsonrpc": "2.0", "id": "radroots-sdk-listing-publish", @@ -746,6 +850,7 @@ mod tests { fn decode_jsonrpc_response_returns_jsonrpc_error() { let error = decode_jsonrpc_response::<SdkRadrootsdBridgePublishResponse>( "bridge.listing.publish", + "radroots-sdk-listing-publish", r#"{ "jsonrpc": "2.0", "id": "radroots-sdk-listing-publish", @@ -765,7 +870,10 @@ mod tests { fn decode_jsonrpc_response_rejects_result_plus_error() { let error = decode_jsonrpc_response::<serde_json::Value>( "bridge.listing.publish", + "radroots-sdk-listing-publish", r#"{ + "jsonrpc": "2.0", + "id": "radroots-sdk-listing-publish", "result": { "ok": true }, "error": { "code": -32002, "message": "ambiguous response" } }"#, @@ -783,6 +891,7 @@ mod tests { fn decode_jsonrpc_response_rejects_missing_result_and_error() { let error = decode_jsonrpc_response::<serde_json::Value>( "bridge.listing.publish", + "radroots-sdk-listing-publish", r#"{ "jsonrpc": "2.0", "id": "radroots-sdk-listing-publish" }"#, ) .expect_err("error"); @@ -798,6 +907,7 @@ mod tests { fn decode_jsonrpc_response_rejects_malformed_json() { let error = decode_jsonrpc_response::<serde_json::Value>( "bridge.listing.publish", + "radroots-sdk-listing-publish", r#"{ "result": "#, ) .expect_err("error"); @@ -805,4 +915,172 @@ mod tests { assert!(matches!(error, RadrootsdError::MalformedResponse(_))); assert_message(error, "decode radrootsd bridge.listing.publish response"); } + + #[test] + fn decode_jsonrpc_response_rejects_invalid_version() { + let error = decode_jsonrpc_response::<serde_json::Value>( + "bridge.listing.publish", + "radroots-sdk-listing-publish", + r#"{ + "jsonrpc": "1.0", + "id": "radroots-sdk-listing-publish", + "result": { "ok": true } + }"#, + ) + .expect_err("error"); + + assert_message(error, "returned invalid jsonrpc version"); + } + + #[test] + fn decode_jsonrpc_response_rejects_mismatched_id() { + let error = decode_jsonrpc_response::<serde_json::Value>( + "bridge.listing.publish", + "radroots-sdk-listing-publish", + r#"{ + "jsonrpc": "2.0", + "id": "other-id", + "result": { "ok": true } + }"#, + ) + .expect_err("error"); + + assert_message(error, "returned mismatched jsonrpc id"); + } + + #[tokio::test] + async fn publish_listing_uses_http_jsonrpc_request_path() { + let (endpoint, handle) = spawn_http_server( + "200 OK", + r#"{ + "jsonrpc": "2.0", + "id": "radroots-sdk-listing-publish", + "result": { + "deduplicated": true, + "job": { + "job_id": "job-1", + "command": "bridge.listing.publish", + "status": "accepted", + "terminal": false, + "recovered_after_restart": false, + "signer_mode": "bunker", + "signer_session_id": "signer-session-secret", + "event_kind": 30402, + "event_id": "event-1", + "event_addr": "30402:pubkey:d-tag", + "relay_count": 2, + "acknowledged_relay_count": 1 + } + } + }"#, + ); + + let response = publish_listing( + &endpoint, + &RadrootsdAuth::BearerToken("sdk-token".into()), + &sample_listing_publish_request(), + Duration::from_secs(5), + ) + .await + .expect("publish response"); + let request = handle.join().expect("request"); + let body = serde_json::from_str::<Value>(&request.body).expect("body json"); + + assert!(response.deduplicated); + assert_eq!(request.request_line, "POST /rpc HTTP/1.1"); + assert!( + request + .headers + .iter() + .any(|(name, value)| { name == "authorization" && value == "Bearer sdk-token" }) + ); + assert_eq!(body["jsonrpc"], "2.0"); + assert_eq!(body["id"], "radroots-sdk-listing-publish"); + assert_eq!(body["method"], "bridge.listing.publish"); + assert_eq!( + body["params"]["signer_authority"]["provider_signer_session_id"], + "provider-session-secret" + ); + } + + #[tokio::test] + async fn publish_listing_returns_jsonrpc_errors_from_http_path() { + let (endpoint, handle) = spawn_http_server( + "200 OK", + r#"{ + "jsonrpc": "2.0", + "id": "radroots-sdk-listing-publish", + "error": { "code": -32001, "message": "signer unavailable" } + }"#, + ); + + let error = publish_listing( + &endpoint, + &RadrootsdAuth::None, + &sample_listing_publish_request(), + Duration::from_secs(5), + ) + .await + .expect_err("error"); + handle.join().expect("request"); + + assert!(matches!(error, RadrootsdError::JsonRpc(_))); + assert_message(error, "signer unavailable"); + } + + #[tokio::test] + async fn publish_listing_sanitizes_http_status_body() { + let (endpoint, handle) = spawn_http_server("500 Internal Server Error", "secret body"); + + let error = publish_listing( + &endpoint, + &RadrootsdAuth::None, + &sample_listing_publish_request(), + Duration::from_secs(5), + ) + .await + .expect_err("error"); + handle.join().expect("request"); + + let message = error.to_string(); + assert!(message.contains("radrootsd returned http 500")); + assert!(!message.contains("secret body")); + } + + #[tokio::test] + async fn publish_listing_reports_malformed_http_response_body() { + let (endpoint, handle) = spawn_http_server("200 OK", r#"{ "result": "#); + + let error = publish_listing( + &endpoint, + &RadrootsdAuth::None, + &sample_listing_publish_request(), + Duration::from_secs(5), + ) + .await + .expect_err("error"); + handle.join().expect("request"); + + assert!(matches!(error, RadrootsdError::MalformedResponse(_))); + assert_message(error, "decode radrootsd bridge.listing.publish response"); + } + + #[tokio::test] + async fn publish_listing_reports_transport_send_errors() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind unused port"); + let endpoint = format!("http://{}/rpc", listener.local_addr().expect("addr")); + drop(listener); + + let error = publish_listing( + &endpoint, + &RadrootsdAuth::None, + &sample_listing_publish_request(), + Duration::from_millis(250), + ) + .await + .expect_err("error"); + + assert!(matches!(error, RadrootsdError::Http(_))); + assert_message(error, "send radrootsd bridge.listing.publish request"); + } }