lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit c0cdadea2712312c5d4ed25c1e55b1217572dafa
parent 1425ebf0c6b13eedb69744215476b2cc8bb17386
Author: triesap <tyson@radroots.org>
Date:   Sun,  9 Aug 2026 16:58:49 +0000

blossom: retain bounded server error evidence

- parse only validated public service error identifiers
- preserve generic SDK failure codes and HTTP status
- propagate server evidence through mobile runtime and UniFFI
- cover bounds, redaction, and FFI schema evolution

Diffstat:
Mcrates/mobile_core/src/runtime/builder.rs | 3++-
Mcrates/mobile_core/src/runtime/sdk.rs | 2++
Mcrates/mobile_ffi/src/dto.rs | 2++
Mcrates/mobile_ffi/tests/runtime_delegation.rs | 3++-
Mcrates/sdk/src/adapters/blossom.rs | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/sdk/src/transport.rs | 29+++++++++++++++++++++++++++--
6 files changed, 176 insertions(+), 10 deletions(-)

diff --git a/crates/mobile_core/src/runtime/builder.rs b/crates/mobile_core/src/runtime/builder.rs @@ -185,12 +185,13 @@ mod tests { .sdk_blossom_evidence() .expect("Blossom evidence") .expect("configured evidence"); - assert_eq!(evidence.schema_version, 1); + assert_eq!(evidence.schema_version, 2); assert_eq!(evidence.state, "configured_unobserved"); assert_eq!(evidence.last_successful_state, "configured_unobserved"); assert_eq!(evidence.transport_security, "development_cleartext"); assert!(evidence.observed_at_unix_ms.is_none()); assert!(evidence.error_code.is_none()); + assert!(evidence.server_error_code.is_none()); assert!( runtime .configure_blossom( diff --git a/crates/mobile_core/src/runtime/sdk.rs b/crates/mobile_core/src/runtime/sdk.rs @@ -61,6 +61,7 @@ pub struct SdkBlossomEvidenceRecord { pub observed_at_unix_ms: Option<u64>, pub http_status: Option<u16>, pub error_code: Option<String>, + pub server_error_code: Option<String>, pub error_phase: Option<String>, pub retryable: bool, pub possible_orphan: bool, @@ -294,6 +295,7 @@ fn sdk_blossom_evidence_record( observed_at_unix_ms: value.observed_at_unix_ms(), http_status: value.http_status(), error_code: value.error_code().map(str::to_owned), + server_error_code: value.server_error_code().map(str::to_owned), error_phase: value .error_phase() .map(blossom_phase_label) diff --git a/crates/mobile_ffi/src/dto.rs b/crates/mobile_ffi/src/dto.rs @@ -1874,6 +1874,7 @@ pub struct FfiBlossomEvidenceRecord { pub observed_at_unix_ms: Option<u64>, pub http_status: Option<u16>, pub error_code: Option<String>, + pub server_error_code: Option<String>, pub error_phase: Option<String>, pub retryable: bool, pub possible_orphan: bool, @@ -1907,6 +1908,7 @@ impl From<SdkBlossomEvidenceRecord> for FfiBlossomEvidenceRecord { observed_at_unix_ms: value.observed_at_unix_ms, http_status: value.http_status, error_code: value.error_code, + server_error_code: value.server_error_code, error_phase: value.error_phase, retryable: value.retryable, possible_orphan: value.possible_orphan, diff --git a/crates/mobile_ffi/tests/runtime_delegation.rs b/crates/mobile_ffi/tests/runtime_delegation.rs @@ -123,13 +123,14 @@ async fn native_boundary_delegates_the_complete_core_surface() { .sdk_blossom_evidence() .expect("Blossom evidence") .expect("configured evidence"); - assert_eq!(evidence.schema_version, 1); + assert_eq!(evidence.schema_version, 2); assert_eq!(evidence.origin, "https://media.example"); assert_eq!(evidence.config_fingerprint, blossom.config_fingerprint); assert_eq!(evidence.state, "configured_unobserved"); assert_eq!(evidence.transport_security, "public_webpki"); assert!(evidence.observed_at_unix_ms.is_none()); assert!(evidence.error_code.is_none()); + assert!(evidence.server_error_code.is_none()); runtime .configure_blossom( FfiBlossomHostKind::Simulator, diff --git a/crates/sdk/src/adapters/blossom.rs b/crates/sdk/src/adapters/blossom.rs @@ -13,6 +13,8 @@ use crate::transport::{ }; const MAX_RESOLVED_ADDRESSES: usize = 32; +const MAX_ERROR_RESPONSE_BYTES: usize = 4_096; +const MAX_SERVER_ERROR_CODE_BYTES: usize = 64; const X_SHA_256: &str = "x-sha-256"; const BLOSSOM_PROBE_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000"; @@ -380,12 +382,14 @@ async fn upload_once( )); } if !matches!(response.status(), StatusCode::OK | StatusCode::CREATED) { - return Err(http_status_error( - response.status(), + return Err(http_status_response_error( + response, BlossomPhase::Upload, true, attempt, - )); + cancellation, + ) + .await); } { let content_type = response @@ -580,12 +584,14 @@ async fn retrieve_once( continue; } if response.status() != StatusCode::OK { - return Err(http_status_error( - response.status(), + return Err(http_status_response_error( + response, BlossomPhase::Retrieval, true, attempt, - )); + cancellation, + ) + .await); } let actual_media_type = response .headers() @@ -910,6 +916,72 @@ fn http_status_error( .with_http_status(status.as_u16()) } +async fn http_status_response_error( + response: reqwest::Response, + phase: BlossomPhase, + possible_orphan: bool, + attempts: u8, + cancellation: &BlossomCancellation, +) -> BlossomError { + let status = response.status(); + let mut error = http_status_error(status, phase, possible_orphan, attempts); + if let Some(code) = read_server_error_code(response, cancellation).await { + error = error.with_server_error_code(code); + } + error +} + +async fn read_server_error_code( + mut response: reqwest::Response, + cancellation: &BlossomCancellation, +) -> Option<String> { + if response + .content_length() + .is_some_and(|length| length > MAX_ERROR_RESPONSE_BYTES as u64) + { + return None; + } + let mut bytes = Vec::with_capacity( + response + .content_length() + .and_then(|length| usize::try_from(length).ok()) + .unwrap_or(0) + .min(MAX_ERROR_RESPONSE_BYTES), + ); + loop { + let chunk = tokio::select! { + biased; + _ = cancellation.cancelled() => return None, + chunk = response.chunk() => chunk.ok()?, + }; + let Some(chunk) = chunk else { + break; + }; + if bytes.len().saturating_add(chunk.len()) > MAX_ERROR_RESPONSE_BYTES { + return None; + } + bytes.extend_from_slice(&chunk); + } + parse_server_error_code(bytes.as_slice()) +} + +fn parse_server_error_code(bytes: &[u8]) -> Option<String> { + if bytes.len() > MAX_ERROR_RESPONSE_BYTES { + return None; + } + let value: serde_json::Value = serde_json::from_slice(bytes).ok()?; + let code = value.get("error")?.as_str()?; + if code.is_empty() + || code.len() > MAX_SERVER_ERROR_CODE_BYTES + || !code + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + return None; + } + Some(code.to_owned()) +} + fn with_operation(error: BlossomError, possible_orphan: bool, attempts: u8) -> BlossomError { error.with_operation(possible_orphan, attempts) } @@ -1393,6 +1465,69 @@ mod tests { } #[test] + fn server_error_codes_are_bounded_validated_and_detail_free() { + assert_eq!( + parse_server_error_code( + br#"{"error":"entitlement_missing","detail":"must never be retained"}"# + ) + .as_deref(), + Some("entitlement_missing") + ); + assert_eq!( + parse_server_error_code(br#"{"error":"unsafe-value"}"#), + None + ); + assert_eq!(parse_server_error_code(br#"{"error":"UPPERCASE"}"#), None); + assert_eq!( + parse_server_error_code(format!(r#"{{"error":"{}"}}"#, "a".repeat(65)).as_bytes()), + None + ); + assert_eq!( + parse_server_error_code(vec![b' '; MAX_ERROR_RESPONSE_BYTES + 1].as_slice()), + None + ); + assert_eq!(parse_server_error_code(b"not-json"), None); + } + + #[tokio::test] + async fn http_failure_retains_only_the_public_server_error_code() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let address = listener.local_addr().expect("address"); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept"); + let request = read_request(&mut stream).await; + assert!(String::from_utf8_lossy(&request).starts_with("PUT /upload HTTP/1.1")); + let body = + br#"{"error":"entitlement_missing","detail":"sensitive operational context"}"#; + let response = format!( + "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(response.as_bytes()).await.expect("head"); + stream.write_all(body).await.expect("body"); + stream.shutdown().await.expect("close"); + }); + let origin = format!("http://{address}"); + let config = config(origin.as_str()); + let endpoint = config.profile().primary().clone(); + let error = upload_once( + &config, + &endpoint, + &upload_request(origin.as_str(), png(2, 3)), + "Nostr redacted", + &BlossomCancellation::default(), + 1, + ) + .await + .expect_err("forbidden upload"); + assert_eq!(error.http_status(), Some(403)); + assert_eq!(error.server_error_code(), Some("entitlement_missing")); + assert!(!format!("{error:?}").contains("sensitive operational context")); + assert!(!format!("{error:?}").contains("Nostr redacted")); + server.await.expect("server"); + } + + #[test] fn descriptor_verification_checks_each_identity_field() { let bytes = png(2, 3); let request = upload_request("http://127.0.0.1:3000", bytes); diff --git a/crates/sdk/src/transport.rs b/crates/sdk/src/transport.rs @@ -597,6 +597,7 @@ pub struct BlossomEndpointEvidence { observed_at_unix_ms: Option<u64>, http_status: Option<u16>, error_code: Option<&'static str>, + server_error_code: Option<String>, error_phase: Option<BlossomPhase>, retryable: bool, possible_orphan: bool, @@ -605,7 +606,7 @@ pub struct BlossomEndpointEvidence { #[cfg(feature = "blossom")] impl BlossomEndpointEvidence { - const SCHEMA_VERSION: u16 = 1; + const SCHEMA_VERSION: u16 = 2; fn configured(config: &BlossomConfig) -> Self { let primary = config.profile().primary(); @@ -627,6 +628,7 @@ impl BlossomEndpointEvidence { observed_at_unix_ms: None, http_status: None, error_code: None, + server_error_code: None, error_phase: None, retryable: false, possible_orphan: false, @@ -679,6 +681,12 @@ impl BlossomEndpointEvidence { self.error_code } + /// Bounded, validated public error identifier returned by the server. + #[must_use] + pub fn server_error_code(&self) -> Option<&str> { + self.server_error_code.as_deref() + } + #[must_use] pub const fn error_phase(&self) -> Option<BlossomPhase> { self.error_phase @@ -705,6 +713,7 @@ impl BlossomEndpointEvidence { self.observed_at_unix_ms = Some(blossom_now_unix_ms()); self.http_status = http_status; self.error_code = None; + self.server_error_code = None; self.error_phase = None; self.retryable = false; self.possible_orphan = false; @@ -720,6 +729,7 @@ impl BlossomEndpointEvidence { self.observed_at_unix_ms = Some(blossom_now_unix_ms()); self.http_status = error.http_status(); self.error_code = Some(error.code()); + self.server_error_code = error.server_error_code().map(str::to_owned); self.error_phase = Some(error.phase()); self.retryable = error.retryable(); self.possible_orphan = error.possible_orphan(); @@ -878,6 +888,7 @@ pub struct BlossomError { possible_orphan: bool, attempts: u8, http_status: Option<u16>, + server_error_code: Option<String>, } #[cfg(feature = "blossom")] @@ -896,6 +907,7 @@ impl BlossomError { possible_orphan, attempts, http_status: None, + server_error_code: None, } } @@ -933,6 +945,12 @@ impl BlossomError { self.http_status } + /// Bounded, validated public error identifier returned by the server. + #[must_use] + pub fn server_error_code(&self) -> Option<&str> { + self.server_error_code.as_deref() + } + #[must_use] pub const fn code(&self) -> &'static str { match self.kind { @@ -975,6 +993,11 @@ impl BlossomError { self.http_status = Some(status); self } + + pub(crate) fn with_server_error_code(mut self, code: String) -> Self { + self.server_error_code = Some(code); + self + } } #[cfg(feature = "blossom")] @@ -995,6 +1018,7 @@ impl std::fmt::Debug for BlossomError { .field("possible_orphan", &self.possible_orphan) .field("attempts", &self.attempts) .field("http_status", &self.http_status) + .field("server_error_code", &self.server_error_code) .finish() } } @@ -2137,7 +2161,7 @@ mod tests { let public_fingerprint = public_config.fingerprint(); slot.configure(public_config).expect("public config"); let initial = slot.evidence().expect("initial evidence"); - assert_eq!(initial.schema_version(), 1); + assert_eq!(initial.schema_version(), 2); assert_eq!(initial.origin(), "https://media.example"); assert_eq!(initial.config_fingerprint(), public_fingerprint); assert_eq!(initial.state(), BlossomEvidenceState::ConfiguredUnobserved); @@ -2199,6 +2223,7 @@ mod tests { ); assert_eq!(evidence.http_status(), Some(403)); assert_eq!(evidence.error_code(), Some("blossom_http_status")); + assert_eq!(evidence.server_error_code(), None); assert_eq!(evidence.error_phase(), Some(BlossomPhase::Retrieval)); assert!(!evidence.retryable()); assert!(evidence.possible_orphan());