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:
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());