commit 2a982d02f4b251f59edf5c5ec54c53daac25fdcc
parent ad1985b1aa4d79a98fcacee4c8b8c96d663b8634
Author: triesap <tyson@radroots.org>
Date: Sat, 8 Aug 2026 21:35:30 +0000
blossom: expose passive endpoint evidence
Add a non-mutating bounded endpoint probe, fingerprint-guarded operation evidence, and focused mobile/UniFFI records for configuration, probe, upload, retrieval, and failure states.
Diffstat:
9 files changed, 952 insertions(+), 21 deletions(-)
diff --git a/crates/mobile_core/src/runtime/builder.rs b/crates/mobile_core/src/runtime/builder.rs
@@ -181,6 +181,16 @@ mod tests {
.host_kind,
"simulator"
);
+ let evidence = runtime
+ .sdk_blossom_evidence()
+ .expect("Blossom evidence")
+ .expect("configured evidence");
+ assert_eq!(evidence.schema_version, 1);
+ 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!(
runtime
.configure_blossom(
diff --git a/crates/mobile_core/src/runtime/sdk.rs b/crates/mobile_core/src/runtime/sdk.rs
@@ -51,6 +51,23 @@ pub struct SdkBlossomConfigurationRecord {
}
#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct SdkBlossomEvidenceRecord {
+ pub schema_version: u16,
+ pub origin: String,
+ pub config_fingerprint: String,
+ pub state: String,
+ pub last_successful_state: String,
+ pub transport_security: String,
+ pub observed_at_unix_ms: Option<u64>,
+ pub http_status: Option<u16>,
+ pub error_code: Option<String>,
+ pub error_phase: Option<String>,
+ pub retryable: bool,
+ pub possible_orphan: bool,
+ pub attempts: u8,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SdkShutdownRecord {
pub state: String,
pub already_closed: bool,
@@ -199,6 +216,34 @@ impl RadrootsRuntime {
)
}
+ /// Returns the latest passive Blossom evidence without network I/O.
+ #[cfg(feature = "mobile-social")]
+ pub fn sdk_blossom_evidence(
+ &self,
+ ) -> Result<Option<SdkBlossomEvidenceRecord>, RadrootsAppError> {
+ let evidence = self
+ .client
+ .blossom()
+ .map_err(RadrootsAppError::from_sdk)?
+ .and_then(radroots_sdk::transport::BlossomSlot::evidence);
+ Ok(evidence.map(sdk_blossom_evidence_record))
+ }
+
+ /// Explicitly probes the primary Blossom origin without mutation or authorization.
+ #[cfg(feature = "mobile-social")]
+ pub async fn probe_blossom(&self) -> Result<SdkBlossomEvidenceRecord, RadrootsAppError> {
+ let blossom = self
+ .client
+ .blossom()
+ .map_err(RadrootsAppError::from_sdk)?
+ .ok_or_else(|| RadrootsAppError::runtime("blossom_endpoint_not_configured"))?;
+ blossom
+ .probe(radroots_sdk::transport::BlossomCancellation::default())
+ .await
+ .map(sdk_blossom_evidence_record)
+ .map_err(|error| RadrootsAppError::runtime(error.code()))
+ }
+
/// Returns passive relay evidence without DNS, socket, or probe work.
#[cfg(feature = "mobile-social")]
pub fn sdk_relay_status(&self) -> Result<Option<SdkRelayStatusReportRecord>, RadrootsAppError> {
@@ -236,6 +281,76 @@ impl RadrootsRuntime {
}
#[cfg(feature = "mobile-social")]
+fn sdk_blossom_evidence_record(
+ value: radroots_sdk::transport::BlossomEndpointEvidence,
+) -> SdkBlossomEvidenceRecord {
+ SdkBlossomEvidenceRecord {
+ schema_version: value.schema_version(),
+ origin: value.origin().to_owned(),
+ config_fingerprint: value.config_fingerprint().to_hex(),
+ state: blossom_evidence_label(value.state()).to_owned(),
+ last_successful_state: blossom_evidence_label(value.last_successful_state()).to_owned(),
+ transport_security: blossom_transport_security_label(value.transport_security()).to_owned(),
+ observed_at_unix_ms: value.observed_at_unix_ms(),
+ http_status: value.http_status(),
+ error_code: value.error_code().map(str::to_owned),
+ error_phase: value
+ .error_phase()
+ .map(blossom_phase_label)
+ .map(str::to_owned),
+ retryable: value.retryable(),
+ possible_orphan: value.possible_orphan(),
+ attempts: value.attempts(),
+ }
+}
+
+#[cfg(feature = "mobile-social")]
+const fn blossom_evidence_label(
+ value: radroots_sdk::transport::BlossomEvidenceState,
+) -> &'static str {
+ match value {
+ radroots_sdk::transport::BlossomEvidenceState::ConfiguredUnobserved => {
+ "configured_unobserved"
+ }
+ radroots_sdk::transport::BlossomEvidenceState::DnsPolicyValidated => "dns_policy_validated",
+ radroots_sdk::transport::BlossomEvidenceState::TlsHttpObserved => "tls_http_observed",
+ radroots_sdk::transport::BlossomEvidenceState::UploadVerified => "upload_verified",
+ radroots_sdk::transport::BlossomEvidenceState::RetrievalVerified => "retrieval_verified",
+ radroots_sdk::transport::BlossomEvidenceState::RetryableFailure => "retryable_failure",
+ radroots_sdk::transport::BlossomEvidenceState::TerminalFailure => "terminal_failure",
+ _ => "unknown",
+ }
+}
+
+#[cfg(feature = "mobile-social")]
+const fn blossom_transport_security_label(
+ value: radroots_sdk::transport::BlossomTransportSecurity,
+) -> &'static str {
+ match value {
+ radroots_sdk::transport::BlossomTransportSecurity::PublicWebPki => "public_webpki",
+ radroots_sdk::transport::BlossomTransportSecurity::DevelopmentTls => "development_tls",
+ radroots_sdk::transport::BlossomTransportSecurity::DevelopmentCleartext => {
+ "development_cleartext"
+ }
+ _ => "unknown",
+ }
+}
+
+#[cfg(feature = "mobile-social")]
+const fn blossom_phase_label(value: radroots_sdk::transport::BlossomPhase) -> &'static str {
+ match value {
+ radroots_sdk::transport::BlossomPhase::Configuration => "configuration",
+ radroots_sdk::transport::BlossomPhase::Probe => "probe",
+ radroots_sdk::transport::BlossomPhase::Authorization => "authorization",
+ radroots_sdk::transport::BlossomPhase::Upload => "upload",
+ radroots_sdk::transport::BlossomPhase::Descriptor => "descriptor",
+ radroots_sdk::transport::BlossomPhase::Retrieval => "retrieval",
+ radroots_sdk::transport::BlossomPhase::Verification => "verification",
+ _ => "unknown",
+ }
+}
+
+#[cfg(feature = "mobile-social")]
const fn blossom_host_kind_label(value: radroots_sdk::transport::BlossomHostKind) -> &'static str {
match value {
radroots_sdk::transport::BlossomHostKind::Native => "native",
diff --git a/crates/mobile_ffi/src/dto.rs b/crates/mobile_ffi/src/dto.rs
@@ -28,8 +28,9 @@ use radroots_mobile_core::runtime::{
TodayRefreshReceipt, TodayRelaySyncState, TodaySyncReceipt,
},
sdk::{
- SdkBlossomConfigurationRecord, SdkCapabilityRecord, SdkRelayStatusRecord,
- SdkRelayStatusReportRecord, SdkShutdownRecord, SdkStorageStatusRecord,
+ SdkBlossomConfigurationRecord, SdkBlossomEvidenceRecord, SdkCapabilityRecord,
+ SdkRelayStatusRecord, SdkRelayStatusReportRecord, SdkShutdownRecord,
+ SdkStorageStatusRecord,
},
};
@@ -1862,6 +1863,23 @@ pub struct FfiBlossomConfigurationRecord {
pub config_fingerprint: String,
}
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiBlossomEvidenceRecord {
+ pub schema_version: u16,
+ pub origin: String,
+ pub config_fingerprint: String,
+ pub state: String,
+ pub last_successful_state: String,
+ pub transport_security: String,
+ pub observed_at_unix_ms: Option<u64>,
+ pub http_status: Option<u16>,
+ pub error_code: Option<String>,
+ pub error_phase: Option<String>,
+ pub retryable: bool,
+ pub possible_orphan: bool,
+ pub attempts: u8,
+}
+
#[cfg_attr(coverage_nightly, coverage(off))]
impl From<SdkBlossomConfigurationRecord> for FfiBlossomConfigurationRecord {
fn from(value: SdkBlossomConfigurationRecord) -> Self {
@@ -1877,6 +1895,27 @@ impl From<SdkBlossomConfigurationRecord> for FfiBlossomConfigurationRecord {
}
#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<SdkBlossomEvidenceRecord> for FfiBlossomEvidenceRecord {
+ fn from(value: SdkBlossomEvidenceRecord) -> Self {
+ Self {
+ schema_version: value.schema_version,
+ origin: value.origin,
+ config_fingerprint: value.config_fingerprint,
+ state: value.state,
+ last_successful_state: value.last_successful_state,
+ transport_security: value.transport_security,
+ observed_at_unix_ms: value.observed_at_unix_ms,
+ http_status: value.http_status,
+ error_code: value.error_code,
+ error_phase: value.error_phase,
+ retryable: value.retryable,
+ possible_orphan: value.possible_orphan,
+ attempts: value.attempts,
+ }
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
impl From<SdkRelayStatusReportRecord> for FfiRelayStatusReportRecord {
fn from(value: SdkRelayStatusReportRecord) -> Self {
Self {
diff --git a/crates/mobile_ffi/src/runtime.rs b/crates/mobile_ffi/src/runtime.rs
@@ -8,13 +8,14 @@ use crate::signer::HostSignerAdapter;
use crate::subscription::SubscriptionHub;
use crate::{
FfiAddDraftInput, FfiAddSchemaRecord, FfiBlossomConfigurationRecord,
- FfiBlossomEndpointAuthority, FfiBlossomHostKind, FfiBlossomUploadInput, FfiCapabilityRecord,
- FfiCardAddParityRecord, FfiDraftStatusRecord, FfiIdentityStatusRecord, FfiLocalNetworkRecord,
- FfiMeRecord, FfiQueuePolicyRecord, FfiRelayStatusReportRecord, FfiRetractionDraftInput,
- FfiRuntimeChangeKind, FfiRuntimeInfoRecord, FfiSearchResultRecord, FfiShutdownRecord,
- FfiStorageStatusRecord, FfiSubscriptionHandle, FfiTodayPageRecord, FfiTodayProjectionUpdate,
- FfiTodayRefreshRecord, FfiTodaySyncRecord, RadrootsAppError, RadrootsHostSigner,
- RadrootsRuntimeObserver, add_schemas, decode_id,
+ FfiBlossomEndpointAuthority, FfiBlossomEvidenceRecord, FfiBlossomHostKind,
+ FfiBlossomUploadInput, FfiCapabilityRecord, FfiCardAddParityRecord, FfiDraftStatusRecord,
+ FfiIdentityStatusRecord, FfiLocalNetworkRecord, FfiMeRecord, FfiQueuePolicyRecord,
+ FfiRelayStatusReportRecord, FfiRetractionDraftInput, FfiRuntimeChangeKind,
+ FfiRuntimeInfoRecord, FfiSearchResultRecord, FfiShutdownRecord, FfiStorageStatusRecord,
+ FfiSubscriptionHandle, FfiTodayPageRecord, FfiTodayProjectionUpdate, FfiTodayRefreshRecord,
+ FfiTodaySyncRecord, RadrootsAppError, RadrootsHostSigner, RadrootsRuntimeObserver, add_schemas,
+ decode_id,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
@@ -174,6 +175,26 @@ impl RadrootsRuntime {
.map_err(Into::into)
}
+ pub fn sdk_blossom_evidence(
+ &self,
+ ) -> Result<Option<FfiBlossomEvidenceRecord>, RadrootsAppError> {
+ self.inner
+ .sdk_blossom_evidence()
+ .map(|value| value.map(Into::into))
+ .map_err(Into::into)
+ }
+
+ pub async fn probe_blossom(&self) -> Result<FfiBlossomEvidenceRecord, RadrootsAppError> {
+ let evidence = self
+ .inner
+ .probe_blossom()
+ .await
+ .map(Into::into)
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions.notify(FfiRuntimeChangeKind::Media, None);
+ Ok(evidence)
+ }
+
pub fn subscribe_changes(
&self,
observer: Box<dyn RadrootsRuntimeObserver>,
diff --git a/crates/mobile_ffi/tests/local_mvp_real_io.rs b/crates/mobile_ffi/tests/local_mvp_real_io.rs
@@ -233,6 +233,13 @@ async fn public_runtime_completes_the_local_mvp_against_real_protocol_services()
.await
.expect("upload and re-fetch exact media");
assert_eq!(uploaded.media[0].stage, FfiMediaStage::Verified);
+ let evidence = publisher
+ .sdk_blossom_evidence()
+ .expect("Blossom evidence")
+ .expect("configured evidence");
+ assert_eq!(evidence.state, "retrieval_verified");
+ assert_eq!(evidence.last_successful_state, "retrieval_verified");
+ assert!(evidence.error_code.is_none());
uploaded
} else {
saved
@@ -812,6 +819,18 @@ async fn prove_corrupted_media_fails(
.expect("durable corrupt-media status");
assert_eq!(failed.media[0].stage, FfiMediaStage::Failed);
assert!(failed.media[0].possible_orphan);
+ let evidence = runtime
+ .sdk_blossom_evidence()
+ .expect("Blossom evidence")
+ .expect("configured evidence");
+ assert_eq!(evidence.state, "terminal_failure");
+ assert_eq!(evidence.last_successful_state, "upload_verified");
+ assert_eq!(
+ evidence.error_code.as_deref(),
+ Some("blossom_retrieved_bytes_mismatch")
+ );
+ assert_eq!(evidence.error_phase.as_deref(), Some("verification"));
+ assert!(evidence.possible_orphan);
corrupt.finish().await;
}
diff --git a/crates/mobile_ffi/tests/runtime_delegation.rs b/crates/mobile_ffi/tests/runtime_delegation.rs
@@ -119,6 +119,17 @@ async fn native_boundary_delegates_the_complete_core_surface() {
assert_eq!(blossom.primary_origin, "https://media.example");
assert_eq!(blossom.fallback_origins, ["https://fallback.example"]);
assert_eq!(blossom.config_fingerprint.len(), 64);
+ let evidence = runtime
+ .sdk_blossom_evidence()
+ .expect("Blossom evidence")
+ .expect("configured evidence");
+ assert_eq!(evidence.schema_version, 1);
+ 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());
runtime
.configure_blossom(
FfiBlossomHostKind::Simulator,
diff --git a/crates/sdk/src/adapters/blossom.rs b/crates/sdk/src/adapters/blossom.rs
@@ -14,6 +14,192 @@ use crate::transport::{
const MAX_RESOLVED_ADDRESSES: usize = 32;
const X_SHA_256: &str = "x-sha-256";
+const BLOSSOM_PROBE_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
+
+pub(crate) struct BlossomProbeObservation {
+ pub(crate) http_status: u16,
+}
+
+pub(crate) struct BlossomProbeFailure {
+ pub(crate) error: BlossomError,
+ pub(crate) dns_policy_validated: bool,
+}
+
+/// Performs one BUD-01-shaped GET for an impossible sentinel digest.
+///
+/// The request carries no authorization and cannot upload, delete, or mutate a
+/// server. Any terminal HTTP response proves only DNS-policy, transport, and
+/// HTTP reachability for the exact configured origin.
+#[cfg_attr(coverage_nightly, coverage(off))]
+pub(crate) async fn probe(
+ config: BlossomConfig,
+ endpoint: BlossomEndpoint,
+ cancellation: BlossomCancellation,
+) -> Result<BlossomProbeObservation, BlossomProbeFailure> {
+ let mut url = BlobUrl::parse(format!("{}/{BLOSSOM_PROBE_HASH}", endpoint.origin()).as_str())
+ .map_err(|_| BlossomProbeFailure {
+ error: failure(
+ BlossomErrorKind::InvalidEndpoint,
+ BlossomPhase::Probe,
+ false,
+ false,
+ 0,
+ ),
+ dns_policy_validated: false,
+ })?;
+ let mut dns_policy_validated = false;
+ for redirects in 0..=config.max_redirects() {
+ let current =
+ config
+ .profile()
+ .endpoint_for_blob(&url)
+ .ok_or_else(|| BlossomProbeFailure {
+ error: failure(
+ BlossomErrorKind::UnsafeRedirect,
+ BlossomPhase::Probe,
+ false,
+ false,
+ 1,
+ ),
+ dns_policy_validated,
+ })?;
+ let addresses = resolve(
+ current,
+ config.connect_timeout(),
+ &cancellation,
+ BlossomPhase::Probe,
+ 1,
+ false,
+ )
+ .await
+ .map_err(|error| BlossomProbeFailure {
+ error,
+ dns_policy_validated,
+ })?;
+ dns_policy_validated = true;
+ let client = hardened_client_with_addresses(
+ &config,
+ current,
+ addresses.as_slice(),
+ BlossomPhase::Probe,
+ 1,
+ false,
+ )
+ .map_err(|error| BlossomProbeFailure {
+ error,
+ dns_policy_validated,
+ })?;
+ let pending = client
+ .get(url.as_str())
+ .header(ACCEPT, "*/*")
+ .header(ACCEPT_ENCODING, "identity")
+ .send();
+ let response = tokio::select! {
+ biased;
+ _ = cancellation.cancelled() => {
+ return Err(BlossomProbeFailure {
+ error: failure(
+ BlossomErrorKind::Cancelled,
+ BlossomPhase::Probe,
+ true,
+ false,
+ 1,
+ ),
+ dns_policy_validated,
+ });
+ }
+ response = pending => response.map_err(|error| BlossomProbeFailure {
+ error: request_error(error, BlossomPhase::Probe, false, 1),
+ dns_policy_validated,
+ })?,
+ };
+ if response.status().is_redirection() {
+ if redirects == config.max_redirects() {
+ return Err(BlossomProbeFailure {
+ error: failure(
+ BlossomErrorKind::RedirectLimit,
+ BlossomPhase::Probe,
+ false,
+ false,
+ 1,
+ ),
+ dns_policy_validated,
+ });
+ }
+ let location = response
+ .headers()
+ .get(LOCATION)
+ .and_then(|value| value.to_str().ok())
+ .ok_or_else(|| BlossomProbeFailure {
+ error: failure(
+ BlossomErrorKind::UnsafeRedirect,
+ BlossomPhase::Probe,
+ false,
+ false,
+ 1,
+ ),
+ dns_policy_validated,
+ })?;
+ let base = reqwest::Url::parse(url.as_str()).map_err(|_| BlossomProbeFailure {
+ error: failure(
+ BlossomErrorKind::UnsafeRedirect,
+ BlossomPhase::Probe,
+ false,
+ false,
+ 1,
+ ),
+ dns_policy_validated,
+ })?;
+ let next = base
+ .join(location)
+ .ok()
+ .and_then(|value| BlobUrl::parse(value.as_str()).ok())
+ .filter(|value| {
+ value.hash_path().hash() == url.hash_path().hash()
+ && config.profile().endpoint_for_blob(value).is_some()
+ })
+ .ok_or_else(|| BlossomProbeFailure {
+ error: failure(
+ BlossomErrorKind::UnsafeRedirect,
+ BlossomPhase::Probe,
+ false,
+ false,
+ 1,
+ ),
+ dns_policy_validated,
+ })?;
+ url = next;
+ continue;
+ }
+ let status = response.status().as_u16();
+ read_bounded(
+ response,
+ config.max_descriptor_bytes(),
+ &cancellation,
+ BlossomPhase::Probe,
+ false,
+ 1,
+ )
+ .await
+ .map_err(|error| BlossomProbeFailure {
+ error,
+ dns_policy_validated,
+ })?;
+ return Ok(BlossomProbeObservation {
+ http_status: status,
+ });
+ }
+ Err(BlossomProbeFailure {
+ error: failure(
+ BlossomErrorKind::RedirectLimit,
+ BlossomPhase::Probe,
+ false,
+ false,
+ 1,
+ ),
+ dns_policy_validated,
+ })
+}
pub(crate) async fn upload(
transaction: BlossomUploadTransaction,
@@ -476,13 +662,40 @@ async fn hardened_client(
possible_orphan,
)
.await?;
+ hardened_client_with_addresses(
+ config,
+ endpoint,
+ addresses.as_slice(),
+ phase,
+ attempts,
+ possible_orphan,
+ )
+}
+
+fn hardened_client_with_addresses(
+ config: &BlossomConfig,
+ endpoint: &BlossomEndpoint,
+ addresses: &[SocketAddr],
+ phase: BlossomPhase,
+ attempts: u8,
+ possible_orphan: bool,
+) -> Result<reqwest::Client, BlossomError> {
let mut builder = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(config.connect_timeout())
.timeout(config.request_timeout())
.pool_max_idle_per_host(0);
if endpoint.host().parse::<std::net::IpAddr>().is_err() {
- builder = builder.resolve(endpoint.host(), addresses[0]);
+ let address = addresses.first().ok_or_else(|| {
+ failure(
+ BlossomErrorKind::ResolutionFailed,
+ phase,
+ true,
+ possible_orphan,
+ attempts,
+ )
+ })?;
+ builder = builder.resolve(endpoint.host(), *address);
}
builder.build().map_err(|_| {
failure(
@@ -694,6 +907,7 @@ fn http_status_error(
possible_orphan,
attempts,
)
+ .with_http_status(status.as_u16())
}
fn with_operation(error: BlossomError, possible_orphan: bool, attempts: u8) -> BlossomError {
@@ -1475,6 +1689,105 @@ mod tests {
}
#[tokio::test]
+ async fn non_mutating_probe_records_only_dns_transport_and_http_evidence() {
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let address = listener.local_addr().unwrap();
+ let server = tokio::spawn(async move {
+ let (mut stream, _) = listener.accept().await.unwrap();
+ let mut request = vec![0_u8; 2_048];
+ let count = stream.read(&mut request).await.unwrap();
+ let request = String::from_utf8_lossy(&request[..count]);
+ assert!(request.starts_with(&format!("GET /{BLOSSOM_PROBE_HASH} HTTP/1.1")));
+ assert!(!request.to_ascii_lowercase().contains("authorization:"));
+ stream
+ .write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n")
+ .await
+ .unwrap();
+ });
+ let slot = crate::transport::BlossomSlot::new();
+ slot.configure(config(format!("http://{address}").as_str()))
+ .unwrap();
+ let initial = slot.evidence().unwrap();
+ assert_eq!(
+ initial.state(),
+ crate::transport::BlossomEvidenceState::ConfiguredUnobserved
+ );
+ assert!(initial.observed_at_unix_ms().is_none());
+
+ let observed = slot.probe(BlossomCancellation::default()).await.unwrap();
+ assert_eq!(
+ observed.state(),
+ crate::transport::BlossomEvidenceState::TlsHttpObserved
+ );
+ assert_eq!(observed.http_status(), Some(404));
+ assert!(observed.error_code().is_none());
+ assert!(observed.observed_at_unix_ms().is_some());
+ server.await.unwrap();
+ }
+
+ #[tokio::test]
+ async fn cancelled_probe_is_retryable_redacted_and_never_claims_dns() {
+ let slot = crate::transport::BlossomSlot::new();
+ slot.configure(config("http://127.0.0.1:9")).unwrap();
+ let cancellation = BlossomCancellation::default();
+ cancellation.cancel();
+ let error = slot.probe(cancellation).await.unwrap_err();
+ assert_eq!(error.kind(), BlossomErrorKind::Cancelled);
+ let evidence = slot.evidence().unwrap();
+ assert_eq!(
+ evidence.state(),
+ crate::transport::BlossomEvidenceState::RetryableFailure
+ );
+ assert_eq!(
+ evidence.last_successful_state(),
+ crate::transport::BlossomEvidenceState::ConfiguredUnobserved
+ );
+ assert_eq!(evidence.error_code(), Some("blossom_cancelled"));
+ assert_eq!(evidence.error_phase(), Some(BlossomPhase::Probe));
+ assert!(!evidence.possible_orphan());
+ }
+
+ #[tokio::test]
+ async fn reconfiguration_during_probe_cannot_promote_stale_evidence() {
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let address = listener.local_addr().unwrap();
+ let (accepted_tx, accepted_rx) = tokio::sync::oneshot::channel();
+ let (release_tx, release_rx) = tokio::sync::oneshot::channel();
+ let server = tokio::spawn(async move {
+ let (mut stream, _) = listener.accept().await.unwrap();
+ let mut request = vec![0_u8; 2_048];
+ let _ = stream.read(&mut request).await.unwrap();
+ accepted_tx.send(()).unwrap();
+ release_rx.await.unwrap();
+ stream
+ .write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n")
+ .await
+ .unwrap();
+ });
+ let slot = crate::transport::BlossomSlot::new();
+ slot.configure(config(format!("http://{address}").as_str()))
+ .unwrap();
+ let probing = {
+ let slot = slot.clone();
+ tokio::spawn(async move { slot.probe(BlossomCancellation::default()).await })
+ };
+ accepted_rx.await.unwrap();
+ slot.configure(config("http://127.0.0.1:9")).unwrap();
+ release_tx.send(()).unwrap();
+ let error = probing.await.unwrap().unwrap_err();
+ assert_eq!(error.kind(), BlossomErrorKind::ConfigurationChanged);
+ assert_eq!(error.phase(), BlossomPhase::Probe);
+ assert!(!error.possible_orphan());
+ let evidence = slot.evidence().unwrap();
+ assert_eq!(evidence.origin(), "http://127.0.0.1:9");
+ assert_eq!(
+ evidence.state(),
+ crate::transport::BlossomEvidenceState::ConfiguredUnobserved
+ );
+ server.await.unwrap();
+ }
+
+ #[tokio::test]
async fn loopback_upload_preserves_exact_bytes_and_verifies_retrieval() {
let bytes = png(2, 3);
let (origin, server) = spawn_server(bytes.clone(), RetrievalResponse::Exact, false).await;
diff --git a/crates/sdk/src/transport.rs b/crates/sdk/src/transport.rs
@@ -17,7 +17,7 @@ use std::{
collections::BTreeSet,
net::{IpAddr, Ipv4Addr, Ipv6Addr},
sync::atomic::{AtomicBool, Ordering},
- time::Duration,
+ time::{Duration, SystemTime, UNIX_EPOCH},
};
#[cfg(feature = "blossom")]
@@ -444,6 +444,16 @@ fn append_fingerprint_field(material: &mut Vec<u8>, value: &[u8]) {
material.extend_from_slice(value);
}
+#[cfg(feature = "blossom")]
+fn blossom_now_unix_ms() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .ok()
+ .and_then(|duration| u64::try_from(duration.as_millis()).ok())
+ .unwrap_or(u64::MAX)
+ .max(1)
+}
+
/// Nonzero dimensions verified from the final image bytes.
#[cfg(feature = "blossom")]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -548,6 +558,175 @@ pub struct BlossomUploadTransaction {
request: BlossomUploadRequest,
}
+/// Security property observed for the configured primary transport.
+#[cfg(feature = "blossom")]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[non_exhaustive]
+pub enum BlossomTransportSecurity {
+ /// Public HTTPS authenticated by the bundled platform WebPKI roots.
+ PublicWebPki,
+ /// Development HTTPS without a public-origin availability claim.
+ DevelopmentTls,
+ /// Simulator-only cleartext loopback HTTP.
+ DevelopmentCleartext,
+}
+
+/// Latest redacted evidence state for the configured primary Blossom origin.
+#[cfg(feature = "blossom")]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[non_exhaustive]
+pub enum BlossomEvidenceState {
+ ConfiguredUnobserved,
+ DnsPolicyValidated,
+ TlsHttpObserved,
+ UploadVerified,
+ RetrievalVerified,
+ RetryableFailure,
+ TerminalFailure,
+}
+
+/// Versioned, passive, secret-safe evidence for one exact configuration.
+#[cfg(feature = "blossom")]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct BlossomEndpointEvidence {
+ origin: String,
+ config_fingerprint: BlossomConfigFingerprint,
+ state: BlossomEvidenceState,
+ last_successful_state: BlossomEvidenceState,
+ transport_security: BlossomTransportSecurity,
+ observed_at_unix_ms: Option<u64>,
+ http_status: Option<u16>,
+ error_code: Option<&'static str>,
+ error_phase: Option<BlossomPhase>,
+ retryable: bool,
+ possible_orphan: bool,
+ attempts: u8,
+}
+
+#[cfg(feature = "blossom")]
+impl BlossomEndpointEvidence {
+ const SCHEMA_VERSION: u16 = 1;
+
+ fn configured(config: &BlossomConfig) -> Self {
+ let primary = config.profile().primary();
+ Self {
+ origin: primary.origin().to_owned(),
+ config_fingerprint: config.fingerprint(),
+ state: BlossomEvidenceState::ConfiguredUnobserved,
+ last_successful_state: BlossomEvidenceState::ConfiguredUnobserved,
+ transport_security: match (
+ primary.origin().starts_with("https://"),
+ primary.authority(),
+ ) {
+ (true, BlossomEndpointAuthority::PublicWebPki) => {
+ BlossomTransportSecurity::PublicWebPki
+ }
+ (true, _) => BlossomTransportSecurity::DevelopmentTls,
+ (false, _) => BlossomTransportSecurity::DevelopmentCleartext,
+ },
+ observed_at_unix_ms: None,
+ http_status: None,
+ error_code: None,
+ error_phase: None,
+ retryable: false,
+ possible_orphan: false,
+ attempts: 0,
+ }
+ }
+
+ #[must_use]
+ pub const fn schema_version(&self) -> u16 {
+ Self::SCHEMA_VERSION
+ }
+
+ #[must_use]
+ pub fn origin(&self) -> &str {
+ self.origin.as_str()
+ }
+
+ #[must_use]
+ pub const fn config_fingerprint(&self) -> BlossomConfigFingerprint {
+ self.config_fingerprint
+ }
+
+ #[must_use]
+ pub const fn state(&self) -> BlossomEvidenceState {
+ self.state
+ }
+
+ #[must_use]
+ pub const fn last_successful_state(&self) -> BlossomEvidenceState {
+ self.last_successful_state
+ }
+
+ #[must_use]
+ pub const fn transport_security(&self) -> BlossomTransportSecurity {
+ self.transport_security
+ }
+
+ #[must_use]
+ pub const fn observed_at_unix_ms(&self) -> Option<u64> {
+ self.observed_at_unix_ms
+ }
+
+ #[must_use]
+ pub const fn http_status(&self) -> Option<u16> {
+ self.http_status
+ }
+
+ #[must_use]
+ pub const fn error_code(&self) -> Option<&'static str> {
+ self.error_code
+ }
+
+ #[must_use]
+ pub const fn error_phase(&self) -> Option<BlossomPhase> {
+ self.error_phase
+ }
+
+ #[must_use]
+ pub const fn retryable(&self) -> bool {
+ self.retryable
+ }
+
+ #[must_use]
+ pub const fn possible_orphan(&self) -> bool {
+ self.possible_orphan
+ }
+
+ #[must_use]
+ pub const fn attempts(&self) -> u8 {
+ self.attempts
+ }
+
+ fn record_success(&mut self, state: BlossomEvidenceState, http_status: Option<u16>) {
+ self.state = state;
+ self.last_successful_state = state;
+ self.observed_at_unix_ms = Some(blossom_now_unix_ms());
+ self.http_status = http_status;
+ self.error_code = None;
+ self.error_phase = None;
+ self.retryable = false;
+ self.possible_orphan = false;
+ self.attempts = 0;
+ }
+
+ fn record_failure(&mut self, error: &BlossomError) {
+ self.state = if error.retryable() {
+ BlossomEvidenceState::RetryableFailure
+ } else {
+ BlossomEvidenceState::TerminalFailure
+ };
+ self.observed_at_unix_ms = Some(blossom_now_unix_ms());
+ self.http_status = error.http_status();
+ self.error_code = Some(error.code());
+ self.error_phase = Some(error.phase());
+ self.retryable = error.retryable();
+ self.possible_orphan = error.possible_orphan();
+ self.attempts = error.attempts();
+ }
+}
+
#[cfg(feature = "blossom")]
impl BlossomUploadTransaction {
#[must_use]
@@ -648,6 +827,7 @@ impl BlossomCancellation {
#[non_exhaustive]
pub enum BlossomPhase {
Configuration,
+ Probe,
Authorization,
Upload,
Descriptor,
@@ -697,6 +877,7 @@ pub struct BlossomError {
retryable: bool,
possible_orphan: bool,
attempts: u8,
+ http_status: Option<u16>,
}
#[cfg(feature = "blossom")]
@@ -714,6 +895,7 @@ impl BlossomError {
retryable,
possible_orphan,
attempts,
+ http_status: None,
}
}
@@ -747,6 +929,11 @@ impl BlossomError {
}
#[must_use]
+ pub const fn http_status(&self) -> Option<u16> {
+ self.http_status
+ }
+
+ #[must_use]
pub const fn code(&self) -> &'static str {
match self.kind {
BlossomErrorKind::InvalidEndpoint => "blossom_invalid_endpoint",
@@ -783,6 +970,11 @@ impl BlossomError {
self.attempts = attempts;
self
}
+
+ pub(crate) const fn with_http_status(mut self, status: u16) -> Self {
+ self.http_status = Some(status);
+ self
+ }
}
#[cfg(feature = "blossom")]
@@ -802,6 +994,7 @@ impl std::fmt::Debug for BlossomError {
.field("retryable", &self.retryable)
.field("possible_orphan", &self.possible_orphan)
.field("attempts", &self.attempts)
+ .field("http_status", &self.http_status)
.finish()
}
}
@@ -865,7 +1058,14 @@ impl BlossomUploadReceipt {
#[cfg(feature = "blossom")]
#[derive(Clone, Default)]
pub struct BlossomSlot {
- config: Arc<RwLock<Option<BlossomConfig>>>,
+ state: Arc<RwLock<BlossomSlotState>>,
+}
+
+#[cfg(feature = "blossom")]
+#[derive(Default)]
+struct BlossomSlotState {
+ config: Option<BlossomConfig>,
+ evidence: Option<BlossomEndpointEvidence>,
}
#[cfg(feature = "blossom")]
@@ -877,17 +1077,20 @@ impl BlossomSlot {
/// Atomically installs completely validated inert configuration.
pub fn configure(&self, config: BlossomConfig) -> Result<(), BlossomError> {
- let mut current = self
- .config
+ let evidence = BlossomEndpointEvidence::configured(&config);
+ let mut state = self
+ .state
.write()
.map_err(|_| BlossomError::configuration(BlossomErrorKind::EndpointNotConfigured))?;
- *current = Some(config);
+ state.config = Some(config);
+ state.evidence = Some(evidence);
Ok(())
}
pub fn clear(&self) {
- if let Ok(mut current) = self.config.write() {
- *current = None;
+ if let Ok(mut state) = self.state.write() {
+ state.config = None;
+ state.evidence = None;
}
}
@@ -921,6 +1124,51 @@ impl BlossomSlot {
})
}
+ /// Returns the latest passive evidence without performing network I/O.
+ #[must_use]
+ pub fn evidence(&self) -> Option<BlossomEndpointEvidence> {
+ self.state
+ .read()
+ .ok()
+ .and_then(|state| state.evidence.clone())
+ }
+
+ /// Performs a bounded non-mutating primary-origin probe.
+ pub async fn probe(
+ &self,
+ cancellation: BlossomCancellation,
+ ) -> Result<BlossomEndpointEvidence, BlossomError> {
+ let config = self
+ .snapshot()
+ .ok_or_else(|| BlossomError::configuration(BlossomErrorKind::EndpointNotConfigured))?;
+ let fingerprint = config.fingerprint();
+ let result = crate::adapters::blossom::probe(
+ config.clone(),
+ config.profile().primary().clone(),
+ cancellation,
+ )
+ .await;
+ match result {
+ Ok(observation) => {
+ self.record_evidence(fingerprint, BlossomPhase::Probe, false, |evidence| {
+ evidence.record_success(
+ BlossomEvidenceState::TlsHttpObserved,
+ Some(observation.http_status),
+ );
+ })
+ }
+ Err(failure) => {
+ self.record_evidence(fingerprint, BlossomPhase::Probe, false, |evidence| {
+ if failure.dns_policy_validated {
+ evidence.last_successful_state = BlossomEvidenceState::DnsPolicyValidated;
+ }
+ evidence.record_failure(&failure.error);
+ })?;
+ Err(failure.error)
+ }
+ }
+ }
+
/// Binds verified bytes to the configured primary origin without network I/O.
pub fn prepare_upload(
&self,
@@ -980,7 +1228,31 @@ impl BlossomSlot {
cancellation: BlossomCancellation,
) -> Result<BlossomUploadReceipt, BlossomError> {
self.validate_transaction(&transaction)?;
- crate::adapters::blossom::upload(transaction, authorization, cancellation).await
+ let fingerprint = transaction.config_fingerprint();
+ let result =
+ crate::adapters::blossom::upload(transaction, authorization, cancellation).await;
+ match result {
+ Ok(receipt) => {
+ self.record_evidence(fingerprint, BlossomPhase::Verification, true, |evidence| {
+ evidence.record_success(BlossomEvidenceState::RetrievalVerified, None);
+ })?;
+ Ok(receipt)
+ }
+ Err(error) => {
+ self.record_evidence(fingerprint, BlossomPhase::Verification, true, |evidence| {
+ if error.possible_orphan()
+ && matches!(
+ error.phase(),
+ BlossomPhase::Retrieval | BlossomPhase::Verification
+ )
+ {
+ evidence.last_successful_state = BlossomEvidenceState::UploadVerified;
+ }
+ evidence.record_failure(&error);
+ })?;
+ Err(error)
+ }
+ }
}
fn validate_transaction(
@@ -999,7 +1271,42 @@ impl BlossomSlot {
}
fn snapshot(&self) -> Option<BlossomConfig> {
- self.config.read().ok().and_then(|config| config.clone())
+ self.state
+ .read()
+ .ok()
+ .and_then(|state| state.config.clone())
+ }
+
+ fn record_evidence(
+ &self,
+ fingerprint: BlossomConfigFingerprint,
+ drift_phase: BlossomPhase,
+ drift_possible_orphan: bool,
+ update: impl FnOnce(&mut BlossomEndpointEvidence),
+ ) -> Result<BlossomEndpointEvidence, BlossomError> {
+ let mut state = self
+ .state
+ .write()
+ .map_err(|_| BlossomError::configuration(BlossomErrorKind::EndpointNotConfigured))?;
+ let current = state
+ .config
+ .as_ref()
+ .ok_or_else(|| BlossomError::configuration(BlossomErrorKind::EndpointNotConfigured))?;
+ if current.fingerprint() != fingerprint {
+ return Err(BlossomError::new(
+ BlossomErrorKind::ConfigurationChanged,
+ drift_phase,
+ false,
+ drift_possible_orphan,
+ 0,
+ ));
+ }
+ let evidence = state
+ .evidence
+ .as_mut()
+ .ok_or_else(|| BlossomError::configuration(BlossomErrorKind::EndpointNotConfigured))?;
+ update(evidence);
+ Ok(evidence.clone())
}
}
@@ -1817,6 +2124,99 @@ mod tests {
}
#[cfg(feature = "blossom")]
+ #[test]
+ fn blossom_evidence_is_versioned_passive_and_preserves_last_success() {
+ let slot = BlossomSlot::new();
+ assert!(slot.profile().is_none());
+ assert!(slot.configuration().is_none());
+ assert!(slot.evidence().is_none());
+
+ let public_config = BlossomConfig::from_profile(
+ public_blossom_profile("https://media.example").expect("public profile"),
+ );
+ 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.origin(), "https://media.example");
+ assert_eq!(initial.config_fingerprint(), public_fingerprint);
+ assert_eq!(initial.state(), BlossomEvidenceState::ConfiguredUnobserved);
+ assert_eq!(
+ initial.last_successful_state(),
+ BlossomEvidenceState::ConfiguredUnobserved
+ );
+ assert_eq!(
+ initial.transport_security(),
+ BlossomTransportSecurity::PublicWebPki
+ );
+ assert_eq!(initial.observed_at_unix_ms(), None);
+ assert_eq!(initial.http_status(), None);
+ assert_eq!(initial.error_code(), None);
+ assert_eq!(initial.error_phase(), None);
+ assert!(!initial.retryable());
+ assert!(!initial.possible_orphan());
+ assert_eq!(initial.attempts(), 0);
+ assert_eq!(public_fingerprint.to_hex(), public_fingerprint.to_string());
+ assert_eq!(
+ slot.profile().expect("profile").primary().origin(),
+ initial.origin()
+ );
+ assert_eq!(
+ slot.configuration().expect("configuration").1,
+ public_fingerprint
+ );
+
+ let private_tls = BlossomConfig::from_profile(
+ device_blossom_profile("https://10.0.0.10:8443").expect("device profile"),
+ );
+ let mut evidence = BlossomEndpointEvidence::configured(&private_tls);
+ assert_eq!(
+ evidence.transport_security(),
+ BlossomTransportSecurity::DevelopmentTls
+ );
+ evidence.record_success(BlossomEvidenceState::UploadVerified, Some(201));
+ assert_eq!(evidence.state(), BlossomEvidenceState::UploadVerified);
+ assert_eq!(
+ evidence.last_successful_state(),
+ BlossomEvidenceState::UploadVerified
+ );
+ assert!(evidence.observed_at_unix_ms().is_some());
+ assert_eq!(evidence.http_status(), Some(201));
+
+ let failure = BlossomError::new(
+ BlossomErrorKind::HttpStatus,
+ BlossomPhase::Retrieval,
+ false,
+ true,
+ 2,
+ )
+ .with_http_status(403);
+ evidence.record_failure(&failure);
+ assert_eq!(evidence.state(), BlossomEvidenceState::TerminalFailure);
+ assert_eq!(
+ evidence.last_successful_state(),
+ BlossomEvidenceState::UploadVerified
+ );
+ assert_eq!(evidence.http_status(), Some(403));
+ assert_eq!(evidence.error_code(), Some("blossom_http_status"));
+ assert_eq!(evidence.error_phase(), Some(BlossomPhase::Retrieval));
+ assert!(!evidence.retryable());
+ assert!(evidence.possible_orphan());
+ assert_eq!(evidence.attempts(), 2);
+
+ let cleartext = BlossomEndpointEvidence::configured(&BlossomConfig::from_profile(
+ simulator_blossom_profile("http://127.0.0.1:3000").expect("simulator profile"),
+ ));
+ assert_eq!(
+ cleartext.transport_security(),
+ BlossomTransportSecurity::DevelopmentCleartext
+ );
+
+ slot.clear();
+ assert!(slot.evidence().is_none());
+ }
+
+ #[cfg(feature = "blossom")]
fn blossom_png(width: u32, height: u32) -> Vec<u8> {
let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
bytes.extend_from_slice(&width.to_be_bytes());
@@ -2447,7 +2847,7 @@ mod tests {
assert_eq!(receipt.into_descriptor().size(), request.byte_size());
let poisoned = BlossomSlot::new();
- let state = Arc::clone(&poisoned.config);
+ let state = Arc::clone(&poisoned.state);
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = state.write().expect("write lock");
panic!("poison Blossom slot");
diff --git a/crates/sdk/tests/public_api.rs b/crates/sdk/tests/public_api.rs
@@ -79,8 +79,10 @@ fn public_native_type_snapshot_uses_contextual_names() {
type_name::<radroots_sdk::transport::BlossomConfigFingerprint>(),
type_name::<radroots_sdk::transport::BlossomEndpoint>(),
type_name::<radroots_sdk::transport::BlossomEndpointAuthority>(),
+ type_name::<radroots_sdk::transport::BlossomEndpointEvidence>(),
type_name::<radroots_sdk::transport::BlossomError>(),
type_name::<radroots_sdk::transport::BlossomErrorKind>(),
+ type_name::<radroots_sdk::transport::BlossomEvidenceState>(),
type_name::<radroots_sdk::transport::BlossomHostKind>(),
type_name::<radroots_sdk::transport::BlossomImageDimensions>(),
type_name::<radroots_sdk::transport::BlossomPhase>(),
@@ -89,6 +91,7 @@ fn public_native_type_snapshot_uses_contextual_names() {
type_name::<radroots_sdk::transport::BlossomUploadReceipt>(),
type_name::<radroots_sdk::transport::BlossomUploadRequest>(),
type_name::<radroots_sdk::transport::BlossomUploadTransaction>(),
+ type_name::<radroots_sdk::transport::BlossomTransportSecurity>(),
])
.collect::<BTreeSet<_>>();
#[cfg(feature = "radrootsd")]
@@ -168,7 +171,7 @@ const fn expected_public_type_count() -> usize {
#[cfg(feature = "nostr")]
let count = count + 1;
#[cfg(feature = "blossom")]
- let count = count + 17;
+ let count = count + 20;
#[cfg(feature = "radrootsd")]
let count = count + 5;
count