lib

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

commit ad1985b1aa4d79a98fcacee4c8b8c96d663b8634
parent 50d084db20365d446b6c989ad5c0417129b102eb
Author: triesap <tyson@radroots.org>
Date:   Sat,  8 Aug 2026 20:47:20 +0000

blossom: bind uploads to canonical endpoint authority

Separate host execution context from endpoint trust authority. Derive upload destinations in Rust, bind authorization and transfer to complete configuration fingerprints, and remove caller-owned remote URLs from prepared media inputs.

Diffstat:
Mcrates/mobile_core/src/runtime/builder.rs | 30++++++++++++++++++++++++------
Mcrates/mobile_core/src/runtime/product_surface/outbox.rs | 19+++++++++++--------
Mcrates/mobile_core/src/runtime/sdk.rs | 114++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mcrates/mobile_ffi/src/dto.rs | 355++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcrates/mobile_ffi/src/runtime.rs | 57+++++++++++++++++++++++++++++++++------------------------
Mcrates/mobile_ffi/tests/local_mvp_real_io.rs | 30++++++++++++++++++++----------
Mcrates/mobile_ffi/tests/runtime_delegation.rs | 44++++++++++++++++++++++++++++++++------------
Mcrates/sdk/src/adapters/blossom.rs | 139++++++++++++++++++++++++++++++++++++-------------------------------------------
Mcrates/sdk/src/transport.rs | 763++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcrates/sdk/tests/public_api.rs | 8+++++---
10 files changed, 1061 insertions(+), 498 deletions(-)

diff --git a/crates/mobile_core/src/runtime/builder.rs b/crates/mobile_core/src/runtime/builder.rs @@ -165,21 +165,39 @@ mod tests { ); assert!( runtime - .configure_simulator_blossom(vec!["http://127.0.0.1:3000".to_owned()]) + .configure_blossom( + radroots_sdk::transport::BlossomHostKind::Simulator, + radroots_sdk::transport::BlossomEndpointAuthority::LoopbackDevelopment, + "http://127.0.0.1:3000".to_owned(), + vec![], + ) .is_ok() ); assert_eq!( - runtime.sdk_blossom_profile().expect("Blossom profile"), - Some("simulator_local".to_owned()) + runtime + .sdk_blossom_configuration() + .expect("Blossom profile") + .expect("configured") + .host_kind, + "simulator" ); assert!( runtime - .configure_public_blossom(vec!["http://127.0.0.1:3000".to_owned()]) + .configure_blossom( + radroots_sdk::transport::BlossomHostKind::PhysicalDevice, + radroots_sdk::transport::BlossomEndpointAuthority::PublicWebPki, + "http://127.0.0.1:3000".to_owned(), + vec![], + ) .is_err() ); assert_eq!( - runtime.sdk_blossom_profile().expect("unchanged profile"), - Some("simulator_local".to_owned()) + runtime + .sdk_blossom_configuration() + .expect("unchanged profile") + .expect("configured") + .host_kind, + "simulator" ); runtime.shutdown().await.expect("shutdown"); } diff --git a/crates/mobile_core/src/runtime/product_surface/outbox.rs b/crates/mobile_core/src/runtime/product_surface/outbox.rs @@ -1291,7 +1291,15 @@ impl RadrootsRuntime { transfer_cancellation: radroots_sdk::transport::BlossomCancellation, updated_at_unix_ms: u64, ) -> Result<Phase1DraftStatus, Phase1DraftError> { - let url = request.expected_url().as_str().to_owned(); + let blossom = self + .client + .blossom() + .map_err(|_| Phase1DraftError::OperationUnavailable)? + .ok_or(Phase1DraftError::OperationUnavailable)?; + let transaction = blossom + .prepare_upload(request) + .map_err(|_| Phase1DraftError::Operation)?; + let url = transaction.expected_url().as_str().to_owned(); let uploading = self .phase1_update_draft_media( draft_id, @@ -1303,13 +1311,8 @@ impl RadrootsRuntime { ) .await?; let revision = uploading.draft().revision().get(); - let blossom = self - .client - .blossom() - .map_err(|_| Phase1DraftError::OperationUnavailable)? - .ok_or(Phase1DraftError::OperationUnavailable)?; let claim = match blossom.authored_upload_claim( - &request, + &transaction, authorization_content, authorization_created_at_unix_s, authorization_lifetime_seconds, @@ -1353,7 +1356,7 @@ impl RadrootsRuntime { } }; match blossom - .upload(request, authorization, transfer_cancellation) + .upload(transaction, authorization, transfer_cancellation) .await { Ok(receipt) => { diff --git a/crates/mobile_core/src/runtime/sdk.rs b/crates/mobile_core/src/runtime/sdk.rs @@ -42,6 +42,15 @@ pub struct SdkRelayStatusReportRecord { } #[derive(Clone, Debug, Eq, PartialEq)] +pub struct SdkBlossomConfigurationRecord { + pub host_kind: String, + pub endpoint_authority: String, + pub primary_origin: String, + pub fallback_origins: Vec<String>, + pub config_fingerprint: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] pub struct SdkShutdownRecord { pub state: String, pub already_closed: bool, @@ -122,33 +131,23 @@ impl RadrootsRuntime { .map_err(RadrootsAppError::from_sdk) } - /// Installs explicit public TLS Blossom origins without probing them. + /// Installs one canonical inert Blossom configuration without probing it. #[cfg(feature = "mobile-social")] - pub fn configure_public_blossom(&self, origins: Vec<String>) -> Result<(), RadrootsAppError> { - self.configure_blossom_profile( - radroots_sdk::transport::BlossomProfile::public(origins) - .map_err(|error| RadrootsAppError::runtime(error.code().to_owned()))?, - ) - } - - /// Installs exact-loopback simulator Blossom origins without probing them. - #[cfg(feature = "mobile-social")] - pub fn configure_simulator_blossom( + pub fn configure_blossom( &self, - origins: Vec<String>, + host_kind: radroots_sdk::transport::BlossomHostKind, + endpoint_authority: radroots_sdk::transport::BlossomEndpointAuthority, + primary_origin: String, + fallback_origins: Vec<String>, ) -> Result<(), RadrootsAppError> { self.configure_blossom_profile( - radroots_sdk::transport::BlossomProfile::simulator(origins) - .map_err(|error| RadrootsAppError::runtime(error.code().to_owned()))?, - ) - } - - /// Installs explicit physical-device TLS Blossom origins without probing them. - #[cfg(feature = "mobile-social")] - pub fn configure_device_blossom(&self, origins: Vec<String>) -> Result<(), RadrootsAppError> { - self.configure_blossom_profile( - radroots_sdk::transport::BlossomProfile::device(origins) - .map_err(|error| RadrootsAppError::runtime(error.code().to_owned()))?, + radroots_sdk::transport::BlossomProfile::new( + host_kind, + endpoint_authority, + primary_origin, + fallback_origins, + ) + .map_err(|error| RadrootsAppError::runtime(error.code().to_owned()))?, ) } @@ -164,23 +163,40 @@ impl RadrootsRuntime { .map_err(RadrootsAppError::from_sdk) } - /// Returns the inert Blossom environment profile, when configured. + /// Returns the configured adapter slot for Rust-owned media binding. + #[cfg(feature = "mobile-social")] + pub fn sdk_blossom_slot( + &self, + ) -> Result<Option<radroots_sdk::transport::BlossomSlot>, RadrootsAppError> { + self.client + .blossom() + .map(|slot| slot.cloned()) + .map_err(RadrootsAppError::from_sdk) + } + + /// Returns the complete inert Blossom configuration, when configured. #[cfg(feature = "mobile-social")] - pub fn sdk_blossom_profile(&self) -> Result<Option<String>, RadrootsAppError> { - let profile = self + pub fn sdk_blossom_configuration( + &self, + ) -> Result<Option<SdkBlossomConfigurationRecord>, RadrootsAppError> { + let configuration = self .client .blossom() .map_err(RadrootsAppError::from_sdk)? - .and_then(radroots_sdk::transport::BlossomSlot::profile_kind); - Ok(profile.map(|profile| { - match profile { - radroots_sdk::transport::BlossomProfileKind::Public => "public", - radroots_sdk::transport::BlossomProfileKind::Simulator => "simulator_local", - radroots_sdk::transport::BlossomProfileKind::Device => "device_development", - _ => "unknown", - } - .to_owned() - })) + .and_then(radroots_sdk::transport::BlossomSlot::configuration); + Ok( + configuration.map(|(profile, fingerprint)| SdkBlossomConfigurationRecord { + host_kind: blossom_host_kind_label(profile.host_kind()).to_owned(), + endpoint_authority: blossom_authority_label(profile.authority()).to_owned(), + primary_origin: profile.primary().origin().to_owned(), + fallback_origins: profile + .fallbacks() + .iter() + .map(|endpoint| endpoint.origin().to_owned()) + .collect(), + config_fingerprint: fingerprint.to_hex(), + }), + ) } /// Returns passive relay evidence without DNS, socket, or probe work. @@ -220,6 +236,32 @@ impl RadrootsRuntime { } #[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", + radroots_sdk::transport::BlossomHostKind::Simulator => "simulator", + radroots_sdk::transport::BlossomHostKind::PhysicalDevice => "physical_device", + _ => "unknown", + } +} + +#[cfg(feature = "mobile-social")] +const fn blossom_authority_label( + value: radroots_sdk::transport::BlossomEndpointAuthority, +) -> &'static str { + match value { + radroots_sdk::transport::BlossomEndpointAuthority::PublicWebPki => "public_webpki", + radroots_sdk::transport::BlossomEndpointAuthority::LoopbackDevelopment => { + "loopback_development" + } + radroots_sdk::transport::BlossomEndpointAuthority::PrivateNetworkDevelopment => { + "private_network_development" + } + _ => "unknown", + } +} + +#[cfg(feature = "mobile-social")] const fn relay_evidence_label(value: radroots_sdk::transport::RelayEvidenceState) -> &'static str { match value { radroots_sdk::transport::RelayEvidenceState::Unsupported => "unsupported", diff --git a/crates/mobile_ffi/src/dto.rs b/crates/mobile_ffi/src/dto.rs @@ -3,7 +3,7 @@ #[cfg(unix)] use std::os::unix::fs::FileExt; -use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256}; +use radroots_blossom::{BlobDescriptor, MediaType, Sha256}; use radroots_event::{ calendar::{AuthoredCalendarDateEvent, AuthoredCalendarTimeEvent, CalendarDate}, food::availability::{ @@ -28,8 +28,8 @@ use radroots_mobile_core::runtime::{ TodayRefreshReceipt, TodayRelaySyncState, TodaySyncReceipt, }, sdk::{ - SdkCapabilityRecord, SdkRelayStatusRecord, SdkRelayStatusReportRecord, SdkShutdownRecord, - SdkStorageStatusRecord, + SdkBlossomConfigurationRecord, SdkCapabilityRecord, SdkRelayStatusRecord, + SdkRelayStatusReportRecord, SdkShutdownRecord, SdkStorageStatusRecord, }, }; @@ -838,7 +838,6 @@ pub struct FfiPreparedMediaInput { pub schema_version: u16, pub opaque_reference: String, pub file_descriptor: u64, - pub url: String, pub sha256: String, pub media_type: String, pub byte_size: u64, @@ -904,14 +903,16 @@ impl FfiAddDraftInput { pub(crate) fn command_and_media( self, authored_at_unix_s: u64, + blossom: Option<&radroots_sdk::transport::BlossomSlot>, ) -> Result<(Phase1AddCommand, Vec<Phase1MediaPrerequisite>), RadrootsAppError> { - self.command_media_and_form(authored_at_unix_s) + self.command_media_and_form(authored_at_unix_s, blossom) .map(|(command, media, _)| (command, media)) } pub(crate) fn command_media_and_form( self, authored_at_unix_s: u64, + blossom: Option<&radroots_sdk::transport::BlossomSlot>, ) -> Result< ( Phase1AddCommand, @@ -929,19 +930,30 @@ impl FfiAddDraftInput { .iter() .cloned() .map(PreparedMedia::try_from) - .collect::<Result<Vec<_>, _>>()?; + .map(|media| { + media.and_then(|media| { + let blossom = blossom.ok_or_else(|| { + RadrootsAppError::invalid_argument("blossom_not_configured") + })?; + media.bind(blossom) + }) + }) + .collect::<Result<Vec<BoundPreparedMedia>, _>>()?; let prerequisites = prepared .iter() .map(|value| { - Phase1MediaPrerequisite::new(value.opaque_reference.clone(), &value.descriptor) + Phase1MediaPrerequisite::new( + value.media.opaque_reference.clone(), + &value.descriptor, + ) }) .collect::<Result<Vec<_>, _>>() .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_reference"))?; let post_images = prepared .iter() - .map(PreparedMedia::post_image) + .map(BoundPreparedMedia::post_image) .collect::<Result<Vec<_>, _>>()?; - let form = self.form_snapshot(); + let form = self.form_snapshot(&prepared); let command = match self.command_type { FfiAddCommandType::CreateUpdate => { reject_media(&prepared)?; @@ -974,7 +986,7 @@ impl FfiAddDraftInput { Ok((command, prerequisites, form)) } - fn form_snapshot(&self) -> Phase1DraftFormSnapshot { + fn form_snapshot(&self, prepared: &[BoundPreparedMedia]) -> Phase1DraftFormSnapshot { Phase1DraftFormSnapshot { command_type: match self.command_type { FfiAddCommandType::CreateUpdate => AddCommandType::CreateUpdate, @@ -1003,19 +1015,18 @@ impl FfiAddDraftInput { quantity: self.quantity.clone(), food_published_at_unix_s: self.food_published_at_unix_s, food_status: self.food_status.clone(), - media: self - .media + media: prepared .iter() .map(|value| Phase1DraftMediaSnapshot { - opaque_reference: value.opaque_reference.clone(), - url: value.url.clone(), - sha256: value.sha256.clone(), - media_type: value.media_type.clone(), - byte_size: value.byte_size, - width: value.width, - height: value.height, - alt: value.alt.clone(), - prepared_at_unix_s: value.prepared_at_unix_s, + opaque_reference: value.media.opaque_reference.clone(), + url: value.descriptor.url().as_str().to_owned(), + sha256: value.media.sha256.to_hex(), + media_type: value.media.media_type.as_str().to_owned(), + byte_size: value.media.byte_size, + width: value.media.width, + height: value.media.height, + alt: value.media.alt.clone(), + prepared_at_unix_s: value.media.prepared_at_unix_s, }) .collect(), } @@ -1024,7 +1035,9 @@ impl FfiAddDraftInput { pub(crate) struct PreparedMedia { opaque_reference: String, - descriptor: radroots_blossom::ByteVerifiedDescriptor, + sha256: Sha256, + byte_size: u64, + prepared_at_unix_s: u64, bytes: std::sync::Arc<[u8]>, media_type: MediaType, width: u32, @@ -1032,6 +1045,11 @@ pub(crate) struct PreparedMedia { alt: String, } +struct BoundPreparedMedia { + media: PreparedMedia, + descriptor: radroots_blossom::ByteVerifiedDescriptor, +} + impl TryFrom<FfiPreparedMediaInput> for PreparedMedia { type Error = RadrootsAppError; @@ -1055,21 +1073,32 @@ impl TryFrom<FfiPreparedMediaInput> for PreparedMedia { let bytes = read_media_file_descriptor(value.file_descriptor, value.byte_size, byte_size)?; let media_type = MediaType::parse(&value.media_type) .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_type"))?; - let descriptor = BlobDescriptor::new( - BlobUrl::parse(&value.url) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_url"))?, - Sha256::from_hex(&value.sha256) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_digest"))?, - value.byte_size, + let sha256 = Sha256::from_hex(&value.sha256) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_digest"))?; + if Sha256::digest(&bytes) != sha256 { + return Err(RadrootsAppError::invalid_argument( + "media_verification_failed", + )); + } + let dimensions = + radroots_sdk::transport::BlossomImageDimensions::new(value.width, value.height) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?; + let verified_at_unix_ms = value + .prepared_at_unix_s + .checked_mul(1_000) + .ok_or_else(|| RadrootsAppError::invalid_argument("invalid_media_reference"))?; + radroots_sdk::transport::BlossomUploadRequest::new( + bytes.clone().into(), media_type.clone(), - value.prepared_at_unix_s, + dimensions, + verified_at_unix_ms, ) - .and_then(BlobDescriptor::approve_reference) - .and_then(|descriptor| descriptor.verify_bytes(&bytes, &media_type)) .map_err(|_| RadrootsAppError::invalid_argument("media_verification_failed"))?; Ok(Self { opaque_reference: value.opaque_reference, - descriptor, + sha256, + byte_size: value.byte_size, + prepared_at_unix_s: value.prepared_at_unix_s, bytes: bytes.into(), media_type, width: value.width, @@ -1115,21 +1144,6 @@ fn read_media_file_descriptor( } impl PreparedMedia { - fn authored_image(&self) -> Result<AuthoredImage, RadrootsAppError> { - AuthoredImage::try_from_verified_descriptor(self.descriptor.clone()) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_media")) - } - - fn post_image(&self) -> Result<AuthoredPostImage, RadrootsAppError> { - AuthoredPostImage::new( - self.authored_image()?, - PostImageDimensions::new(self.width, self.height) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?, - self.alt.clone(), - ) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_image")) - } - pub(crate) fn upload_request( &self, verified_at_unix_ms: u64, @@ -1138,7 +1152,6 @@ impl PreparedMedia { radroots_sdk::transport::BlossomImageDimensions::new(self.width, self.height) .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?; radroots_sdk::transport::BlossomUploadRequest::new( - self.descriptor.url().as_blob_url().clone(), std::sync::Arc::clone(&self.bytes), self.media_type.clone(), dimensions, @@ -1146,11 +1159,55 @@ impl PreparedMedia { ) .map_err(|_| RadrootsAppError::invalid_argument("invalid_blossom_upload")) } + + fn bind( + self, + blossom: &radroots_sdk::transport::BlossomSlot, + ) -> Result<BoundPreparedMedia, RadrootsAppError> { + let verified_at_unix_ms = self + .prepared_at_unix_s + .checked_mul(1_000) + .ok_or_else(|| RadrootsAppError::invalid_argument("invalid_media_reference"))?; + let transaction = blossom + .prepare_upload(self.upload_request(verified_at_unix_ms)?) + .map_err(|error| RadrootsAppError::invalid_argument(error.code()))?; + let descriptor = BlobDescriptor::new( + transaction.expected_url().clone(), + self.sha256, + self.byte_size, + self.media_type.clone(), + self.prepared_at_unix_s, + ) + .and_then(BlobDescriptor::approve_reference) + .and_then(|descriptor| descriptor.verify_bytes(&self.bytes, &self.media_type)) + .map_err(|_| RadrootsAppError::invalid_argument("media_verification_failed"))?; + Ok(BoundPreparedMedia { + media: self, + descriptor, + }) + } +} + +impl BoundPreparedMedia { + fn authored_image(&self) -> Result<AuthoredImage, RadrootsAppError> { + AuthoredImage::try_from_verified_descriptor(self.descriptor.clone()) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_media")) + } + + fn post_image(&self) -> Result<AuthoredPostImage, RadrootsAppError> { + AuthoredPostImage::new( + self.authored_image()?, + PostImageDimensions::new(self.media.width, self.media.height) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?, + self.media.alt.clone(), + ) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_image")) + } } fn event_command( input: &FfiAddDraftInput, - image: Option<&PreparedMedia>, + image: Option<&BoundPreparedMedia>, ) -> Result<CreateEvent, RadrootsAppError> { if input.media.len() > 1 { return Err(RadrootsAppError::invalid_argument("event_image_limit")); @@ -1233,7 +1290,7 @@ fn event_command( fn food_command( input: FfiAddDraftInput, authored_at_unix_s: u64, - media: &[PreparedMedia], + media: &[BoundPreparedMedia], ) -> Result<CreateFoodAvailability, RadrootsAppError> { let unit = FoodUnit::parse(required(input.unit.as_deref(), "food_unit_required")?) .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_unit"))?; @@ -1242,7 +1299,7 @@ fn food_command( .map(|image| { Ok(FoodAvailabilityImage::new( image.authored_image()?, - FoodImageDimensions::new(image.width, image.height) + FoodImageDimensions::new(image.media.width, image.media.height) .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?, )) }) @@ -1286,7 +1343,7 @@ fn food_command( Ok(CreateFoodAvailability::new(details)) } -fn reject_media(media: &[PreparedMedia]) -> Result<(), RadrootsAppError> { +fn reject_media(media: &[BoundPreparedMedia]) -> Result<(), RadrootsAppError> { if media.is_empty() { Ok(()) } else { @@ -1296,7 +1353,7 @@ fn reject_media(media: &[PreparedMedia]) -> Result<(), RadrootsAppError> { fn content_with_media_references( mut content: String, - media: &[PreparedMedia], + media: &[BoundPreparedMedia], ) -> Result<String, RadrootsAppError> { if content.trim().is_empty() { return Err(RadrootsAppError::invalid_argument("content_required")); @@ -1759,6 +1816,66 @@ pub struct FfiRelayStatusReportRecord { pub relays: Vec<FfiRelayStatusRecord>, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiBlossomHostKind { + Native, + Simulator, + PhysicalDevice, +} + +impl From<FfiBlossomHostKind> for radroots_sdk::transport::BlossomHostKind { + fn from(value: FfiBlossomHostKind) -> Self { + match value { + FfiBlossomHostKind::Native => Self::Native, + FfiBlossomHostKind::Simulator => Self::Simulator, + FfiBlossomHostKind::PhysicalDevice => Self::PhysicalDevice, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiBlossomEndpointAuthority { + PublicWebPki, + LoopbackDevelopment, + PrivateNetworkDevelopment, +} + +impl From<FfiBlossomEndpointAuthority> for radroots_sdk::transport::BlossomEndpointAuthority { + fn from(value: FfiBlossomEndpointAuthority) -> Self { + match value { + FfiBlossomEndpointAuthority::PublicWebPki => Self::PublicWebPki, + FfiBlossomEndpointAuthority::LoopbackDevelopment => Self::LoopbackDevelopment, + FfiBlossomEndpointAuthority::PrivateNetworkDevelopment => { + Self::PrivateNetworkDevelopment + } + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiBlossomConfigurationRecord { + pub schema_version: u16, + pub host_kind: String, + pub endpoint_authority: String, + pub primary_origin: String, + pub fallback_origins: Vec<String>, + pub config_fingerprint: String, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<SdkBlossomConfigurationRecord> for FfiBlossomConfigurationRecord { + fn from(value: SdkBlossomConfigurationRecord) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + host_kind: value.host_kind, + endpoint_authority: value.endpoint_authority, + primary_origin: value.primary_origin, + fallback_origins: value.fallback_origins, + config_fingerprint: value.config_fingerprint, + } + } +} + #[cfg_attr(coverage_nightly, coverage(off))] impl From<SdkRelayStatusReportRecord> for FfiRelayStatusReportRecord { fn from(value: SdkRelayStatusReportRecord) -> Self { @@ -1818,6 +1935,29 @@ mod tests { use super::*; + fn 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()); + bytes.extend_from_slice(&height.to_be_bytes()); + bytes + } + + fn blossom_slot() -> radroots_sdk::transport::BlossomSlot { + let profile = radroots_sdk::transport::BlossomProfile::new( + radroots_sdk::transport::BlossomHostKind::Simulator, + radroots_sdk::transport::BlossomEndpointAuthority::LoopbackDevelopment, + "http://127.0.0.1:3000", + std::iter::empty::<&str>(), + ) + .unwrap(); + let slot = radroots_sdk::transport::BlossomSlot::new(); + slot.configure(radroots_sdk::transport::BlossomConfig::from_profile( + profile, + )) + .unwrap(); + slot + } + fn photo_input(file_descriptor: u64, bytes: &[u8], digest: String) -> FfiAddDraftInput { FfiAddDraftInput { schema_version: MOBILE_FFI_SCHEMA_VERSION, @@ -1843,9 +1983,8 @@ mod tests { schema_version: MOBILE_FFI_SCHEMA_VERSION, opaque_reference: "media:carrots-01".to_owned(), file_descriptor, - url: format!("https://blossom.example/{digest}.jpg"), sha256: digest, - media_type: "image/jpeg".to_owned(), + media_type: "image/png".to_owned(), byte_size: bytes.len() as u64, width: 2, height: 2, @@ -1883,13 +2022,13 @@ mod tests { #[test] fn exact_five_add_inputs_build_their_typed_core_commands() { let (update, media) = text_input(FfiAddCommandType::CreateUpdate) - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, None) .expect("update"); assert!(matches!(update, Phase1AddCommand::CreateUpdate(_))); assert!(media.is_empty()); let (ask, media) = text_input(FfiAddCommandType::CreateAsk) - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, None) .expect("ask"); assert!(matches!(ask, Phase1AddCommand::CreateAsk(_))); assert!(media.is_empty()); @@ -1902,7 +2041,7 @@ mod tests { all_day.event_start_date = Some("2026-08-08".to_owned()); all_day.event_end_date = Some("2026-08-09".to_owned()); let (event, media) = all_day - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, None) .expect("all-day event"); assert!(matches!(event, Phase1AddCommand::CreateEvent(_))); assert!(media.is_empty()); @@ -1914,16 +2053,18 @@ mod tests { timed.event_start_unix_s = Some(1_800_000_000); timed.event_end_unix_s = Some(1_800_003_600); timed.event_timezone = Some("America/Vancouver".to_owned()); - let (event, media) = timed.command_and_media(1_800_000_000).expect("timed event"); + let (event, media) = timed + .command_and_media(1_800_000_000, None) + .expect("timed event"); assert!(matches!(event, Phase1AddCommand::CreateEvent(_))); assert!(media.is_empty()); - let bytes = b"verified-food-image"; + let bytes = png(2, 2); let mut file = tempfile::NamedTempFile::new().expect("media file"); - file.write_all(bytes).expect("write media"); + file.write_all(&bytes).expect("write media"); file.flush().expect("flush media"); - let digest = Sha256::digest(bytes).to_hex(); - let mut food = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest); + let digest = Sha256::digest(&bytes).to_hex(); + let mut food = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest); food.command_type = FfiAddCommandType::CreateFoodAvailability; food.identifier = Some("carrots-2026-08".to_owned()); food.title = Some("Carrots".to_owned()); @@ -1934,8 +2075,9 @@ mod tests { food.unit = Some("bunch".to_owned()); food.quantity = Some("12".to_owned()); food.food_status = Some("active".to_owned()); + let blossom = blossom_slot(); let (food, media) = food - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, Some(&blossom)) .expect("food availability"); assert!(matches!(food, Phase1AddCommand::CreateFoodAvailability(_))); assert_eq!(media.len(), 1); @@ -1947,7 +2089,7 @@ mod tests { wrong_schema.schema_version = MOBILE_FFI_SCHEMA_VERSION + 1; assert_eq!( wrong_schema - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, None) .expect_err("schema") .report() .code, @@ -1955,7 +2097,7 @@ mod tests { ); assert_eq!( text_input(FfiAddCommandType::CreateUpdate) - .command_and_media(0) + .command_and_media(0, None) .expect_err("authored time") .report() .code, @@ -1963,7 +2105,7 @@ mod tests { ); assert_eq!( text_input(FfiAddCommandType::CreateEvent) - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, None) .expect_err("event identity") .report() .code, @@ -1972,7 +2114,7 @@ mod tests { let mut food = text_input(FfiAddCommandType::CreateFoodAvailability); food.unit = Some("crate".to_owned()); assert_eq!( - food.command_and_media(1_800_000_000) + food.command_and_media(1_800_000_000, None) .expect_err("food unit") .report() .code, @@ -1982,39 +2124,41 @@ mod tests { #[test] fn prepared_media_accepts_only_the_exact_bounded_file_descriptor_bytes() { - let bytes = b"normalized-image-bytes"; + let bytes = png(2, 2); let mut file = tempfile::NamedTempFile::new().expect("media file"); - file.write_all(bytes).expect("write media"); + file.write_all(&bytes).expect("write media"); file.flush().expect("flush media"); - let digest = Sha256::digest(bytes).to_hex(); - let input = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest.clone()); + let digest = Sha256::digest(&bytes).to_hex(); + let input = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest.clone()); + let blossom = blossom_slot(); let (command, media) = input - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, Some(&blossom)) .expect("verified media input"); assert!(matches!(command, Phase1AddCommand::CreatePhotoUpdate(_))); assert_eq!(media.len(), 1); assert_eq!( media[0].url(), - format!("https://blossom.example/{digest}.jpg") + format!("http://127.0.0.1:3000/{digest}.png") ); } #[test] fn prepared_media_rejects_digest_tamper_and_path_like_references() { - let bytes = b"normalized-image-bytes"; + let bytes = png(2, 2); let mut file = tempfile::NamedTempFile::new().expect("media file"); - file.write_all(bytes).expect("write media"); + file.write_all(&bytes).expect("write media"); file.flush().expect("flush media"); let tampered = photo_input( file.as_file().as_raw_fd() as u64, - bytes, + &bytes, Sha256::digest(b"other").to_hex(), ); + let blossom = blossom_slot(); assert_eq!( tampered - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, Some(&blossom)) .expect_err("digest mismatch") .report() .code, @@ -2023,13 +2167,13 @@ mod tests { let mut path_like = photo_input( file.as_file().as_raw_fd() as u64, - bytes, - Sha256::digest(bytes).to_hex(), + &bytes, + Sha256::digest(&bytes).to_hex(), ); path_like.media[0].opaque_reference = "file:/private/media.jpg".to_owned(); assert_eq!( path_like - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, Some(&blossom)) .expect_err("path-like reference") .report() .code, @@ -2045,7 +2189,6 @@ mod tests { file.flush().expect("flush media"); let digest = Sha256::digest(bytes).to_hex(); let mut input = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest.clone()); - input.media[0].url = format!("https://blossom.example/{digest}.png"); input.media[0].media_type = "image/png".to_owned(); let prepared = PreparedMedia::try_from(input.media.remove(0)).expect("prepared media"); @@ -2061,12 +2204,12 @@ mod tests { #[test] fn media_validation_executes_every_bounded_shape_guard() { - let bytes = b"bounded-media"; + let bytes = png(2, 2); let mut file = tempfile::NamedTempFile::new().expect("media file"); - file.write_all(bytes).expect("write media"); + file.write_all(&bytes).expect("write media"); file.flush().expect("flush media"); - let digest = Sha256::digest(bytes).to_hex(); - let valid = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest).media[0].clone(); + let digest = Sha256::digest(&bytes).to_hex(); + let valid = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest).media[0].clone(); let mut invalid_values = Vec::new(); let mut value = valid.clone(); @@ -2130,7 +2273,7 @@ mod tests { minimal_date.title = Some("Minimal date".to_owned()); minimal_date.event_timing = Some(FfiEventTimingKind::AllDay); minimal_date.event_start_date = Some("2026-08-08".to_owned()); - assert!(minimal_date.command_and_media(1_800_000_000).is_ok()); + assert!(minimal_date.command_and_media(1_800_000_000, None).is_ok()); let mut minimal_time = text_input(FfiAddCommandType::CreateEvent); minimal_time.content.clear(); @@ -2138,27 +2281,33 @@ mod tests { minimal_time.title = Some("Minimal time".to_owned()); minimal_time.event_timing = Some(FfiEventTimingKind::Timed); minimal_time.event_start_unix_s = Some(1_800_000_000); - assert!(minimal_time.command_and_media(1_800_000_000).is_ok()); + assert!(minimal_time.command_and_media(1_800_000_000, None).is_ok()); - let bytes = b"event-image"; + let bytes = png(2, 2); let mut file = tempfile::NamedTempFile::new().expect("media file"); - file.write_all(bytes).expect("write media"); + file.write_all(&bytes).expect("write media"); file.flush().expect("flush media"); - let digest = Sha256::digest(bytes).to_hex(); - let mut event = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest); + let digest = Sha256::digest(&bytes).to_hex(); + let mut event = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest); event.command_type = FfiAddCommandType::CreateEvent; event.identifier = Some("event-image".to_owned()); event.title = Some("Event image".to_owned()); event.event_timing = Some(FfiEventTimingKind::Timed); event.event_start_unix_s = Some(1_800_000_000); - assert!(event.clone().command_and_media(1_800_000_000).is_ok()); + let blossom = blossom_slot(); + assert!( + event + .clone() + .command_and_media(1_800_000_000, Some(&blossom)) + .is_ok() + ); let mut second = event.media[0].clone(); second.opaque_reference = "media:event-image-two".to_owned(); event.media.push(second); assert_eq!( event - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, Some(&blossom)) .expect_err("event media limit") .report() .code, @@ -2168,13 +2317,17 @@ mod tests { #[test] fn content_references_and_identifier_decoding_cover_all_outcomes() { - let bytes = b"post-image"; + let bytes = png(2, 2); let mut file = tempfile::NamedTempFile::new().expect("media file"); - file.write_all(bytes).expect("write media"); + file.write_all(&bytes).expect("write media"); file.flush().expect("flush media"); - let digest = Sha256::digest(bytes).to_hex(); - let input = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest); - let prepared = PreparedMedia::try_from(input.media[0].clone()).expect("prepared media"); + let digest = Sha256::digest(&bytes).to_hex(); + let input = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest); + let blossom = blossom_slot(); + let prepared = PreparedMedia::try_from(input.media[0].clone()) + .expect("prepared media") + .bind(&blossom) + .expect("bound media"); let url = prepared.descriptor.url().as_str().to_owned(); assert_eq!( @@ -2214,7 +2367,7 @@ mod tests { update.media.push(input.media[0].clone()); assert_eq!( update - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, Some(&blossom)) .expect_err("update media") .report() .code, @@ -2224,7 +2377,7 @@ mod tests { over_limit.media = vec![input.media[0].clone(); 21]; assert_eq!( over_limit - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, None) .expect_err("media count") .report() .code, diff --git a/crates/mobile_ffi/src/runtime.rs b/crates/mobile_ffi/src/runtime.rs @@ -7,7 +7,8 @@ use crate::dto::PreparedMedia; use crate::signer::HostSignerAdapter; use crate::subscription::SubscriptionHub; use crate::{ - FfiAddDraftInput, FfiAddSchemaRecord, FfiBlossomUploadInput, FfiCapabilityRecord, + FfiAddDraftInput, FfiAddSchemaRecord, FfiBlossomConfigurationRecord, + FfiBlossomEndpointAuthority, FfiBlossomHostKind, FfiBlossomUploadInput, FfiCapabilityRecord, FfiCardAddParityRecord, FfiDraftStatusRecord, FfiIdentityStatusRecord, FfiLocalNetworkRecord, FfiMeRecord, FfiQueuePolicyRecord, FfiRelayStatusReportRecord, FfiRetractionDraftInput, FfiRuntimeChangeKind, FfiRuntimeInfoRecord, FfiSearchResultRecord, FfiShutdownRecord, @@ -164,8 +165,13 @@ impl RadrootsRuntime { .map_err(Into::into) } - pub fn sdk_blossom_profile(&self) -> Result<Option<String>, RadrootsAppError> { - self.inner.sdk_blossom_profile().map_err(Into::into) + pub fn sdk_blossom_configuration( + &self, + ) -> Result<Option<FfiBlossomConfigurationRecord>, RadrootsAppError> { + self.inner + .sdk_blossom_configuration() + .map(|value| value.map(Into::into)) + .map_err(Into::into) } pub fn subscribe_changes( @@ -208,28 +214,20 @@ impl RadrootsRuntime { Ok(()) } - pub fn configure_public_blossom(&self, origins: Vec<String>) -> Result<(), RadrootsAppError> { - self.inner - .configure_public_blossom(origins) - .map_err(RadrootsAppError::from)?; - self.subscriptions.notify(FfiRuntimeChangeKind::Media, None); - Ok(()) - } - - pub fn configure_simulator_blossom( + pub fn configure_blossom( &self, - origins: Vec<String>, + host_kind: FfiBlossomHostKind, + endpoint_authority: FfiBlossomEndpointAuthority, + primary_origin: String, + fallback_origins: Vec<String>, ) -> Result<(), RadrootsAppError> { self.inner - .configure_simulator_blossom(origins) - .map_err(RadrootsAppError::from)?; - self.subscriptions.notify(FfiRuntimeChangeKind::Media, None); - Ok(()) - } - - pub fn configure_device_blossom(&self, origins: Vec<String>) -> Result<(), RadrootsAppError> { - self.inner - .configure_device_blossom(origins) + .configure_blossom( + host_kind.into(), + endpoint_authority.into(), + primary_origin, + fallback_origins, + ) .map_err(RadrootsAppError::from)?; self.subscriptions.notify(FfiRuntimeChangeKind::Media, None); Ok(()) @@ -363,7 +361,13 @@ impl RadrootsRuntime { input: FfiAddDraftInput, authored_at_unix_s: u64, ) -> Result<(), RadrootsAppError> { - input.command_and_media(authored_at_unix_s).map(|_| ()) + let blossom = self + .inner + .sdk_blossom_slot() + .map_err(RadrootsAppError::from)?; + input + .command_and_media(authored_at_unix_s, blossom.as_ref()) + .map(|_| ()) } #[allow(clippy::too_many_arguments)] @@ -375,7 +379,12 @@ impl RadrootsRuntime { expected_revision: Option<u64>, persisted_at_unix_ms: u64, ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { - let (command, media, form) = input.command_media_and_form(authored_at_unix_s)?; + let blossom = self + .inner + .sdk_blossom_slot() + .map_err(RadrootsAppError::from)?; + let (command, media, form) = + input.command_media_and_form(authored_at_unix_s, blossom.as_ref())?; let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; let status = self .inner diff --git a/crates/mobile_ffi/tests/local_mvp_real_io.rs b/crates/mobile_ffi/tests/local_mvp_real_io.rs @@ -7,12 +7,13 @@ use nostr_relay_builder::MockRelay; use nostr_sdk::Client; use radroots_blossom::Sha256; use radroots_mobile_ffi::{ - FfiAddCommandType, FfiAddDraftInput, FfiBlossomUploadInput, FfiCancellationPolicy, - FfiEventTimingKind, FfiLocalNetworkRecord, FfiMediaStage, FfiOutboxState, - FfiPreparedMediaInput, FfiQueuePolicyRecord, FfiRelaySatisfaction, FfiRetractionDraftInput, - FfiTodayCardType, FfiTodayProjectionUpdate, FfiTodayRelaySyncState, HostSigningOutcome, - HostSigningRequest, HostSigningResult, MOBILE_FFI_SCHEMA_VERSION, ProtectedDataAvailability, - RadrootsHostSigner, RadrootsRuntime, SignerAvailabilityRecord, SignerStatusRecord, + FfiAddCommandType, FfiAddDraftInput, FfiBlossomEndpointAuthority, FfiBlossomHostKind, + FfiBlossomUploadInput, FfiCancellationPolicy, FfiEventTimingKind, FfiLocalNetworkRecord, + FfiMediaStage, FfiOutboxState, FfiPreparedMediaInput, FfiQueuePolicyRecord, + FfiRelaySatisfaction, FfiRetractionDraftInput, FfiTodayCardType, FfiTodayProjectionUpdate, + FfiTodayRelaySyncState, HostSigningOutcome, HostSigningRequest, HostSigningResult, + MOBILE_FFI_SCHEMA_VERSION, ProtectedDataAvailability, RadrootsHostSigner, RadrootsRuntime, + SignerAvailabilityRecord, SignerStatusRecord, }; use secp256k1::{Keypair, Message, Secp256k1, SecretKey}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -507,7 +508,12 @@ fn configure_simulator(runtime: &RadrootsRuntime, relay_url: &str, blossom_origi .configure_simulator_relays(vec![relay_url.to_owned()]) .expect("simulator relay profile"); runtime - .configure_simulator_blossom(vec![blossom_origin.to_owned()]) + .configure_blossom( + FfiBlossomHostKind::Simulator, + FfiBlossomEndpointAuthority::LoopbackDevelopment, + blossom_origin.to_owned(), + vec![], + ) .expect("simulator Blossom profile"); } @@ -579,7 +585,7 @@ fn food_input(content: &str, identifier: &str) -> FfiAddDraftInput { } fn prepared_media( - origin: &str, + _origin: &str, bytes: &[u8], file: &std::fs::File, alt: &str, @@ -589,7 +595,6 @@ fn prepared_media( schema_version: MOBILE_FFI_SCHEMA_VERSION, opaque_reference: format!("media:{hash}"), file_descriptor: u64::try_from(file.as_raw_fd()).expect("nonnegative media descriptor"), - url: format!("{origin}/{hash}.png"), sha256: hash, media_type: "image/png".to_owned(), byte_size: u64::try_from(bytes.len()).expect("media size"), @@ -754,7 +759,12 @@ async fn prove_corrupted_media_fails( ) { let corrupt = BlossomServer::spawn(bytes.to_vec(), true).await; runtime - .configure_simulator_blossom(vec![corrupt.origin.clone()]) + .configure_blossom( + FfiBlossomHostKind::Simulator, + FfiBlossomEndpointAuthority::LoopbackDevelopment, + corrupt.origin.clone(), + vec![], + ) .expect("corrupt test Blossom profile"); let media = prepared_media( &corrupt.origin, diff --git a/crates/mobile_ffi/tests/runtime_delegation.rs b/crates/mobile_ffi/tests/runtime_delegation.rs @@ -1,8 +1,9 @@ use radroots_mobile_ffi::{ - FfiAddCommandType, FfiAddDraftInput, FfiBlossomUploadInput, FfiCancellationPolicy, - FfiDraftKind, FfiLocalNetworkRecord, FfiOutboxState, FfiPreparedMediaInput, - FfiQueuePolicyRecord, FfiRelaySatisfaction, FfiRetractionDraftInput, FfiTodayCardType, - FfiTodayProjectionUpdate, MOBILE_FFI_SCHEMA_VERSION, RadrootsAppError, + FfiAddCommandType, FfiAddDraftInput, FfiBlossomEndpointAuthority, FfiBlossomHostKind, + FfiBlossomUploadInput, FfiCancellationPolicy, FfiDraftKind, FfiLocalNetworkRecord, + FfiOutboxState, FfiPreparedMediaInput, FfiQueuePolicyRecord, FfiRelaySatisfaction, + FfiRetractionDraftInput, FfiTodayCardType, FfiTodayProjectionUpdate, MOBILE_FFI_SCHEMA_VERSION, + RadrootsAppError, }; mod support; @@ -102,17 +103,37 @@ async fn native_boundary_delegates_the_complete_core_surface() { ); runtime - .configure_public_blossom(vec!["https://media.example".to_owned()]) + .configure_blossom( + FfiBlossomHostKind::PhysicalDevice, + FfiBlossomEndpointAuthority::PublicWebPki, + "https://media.example".to_owned(), + vec!["https://fallback.example".to_owned()], + ) .expect("public Blossom"); - assert_eq!( - runtime.sdk_blossom_profile().expect("Blossom profile"), - Some("public".to_owned()) - ); + let blossom = runtime + .sdk_blossom_configuration() + .expect("Blossom configuration") + .expect("configured Blossom"); + assert_eq!(blossom.host_kind, "physical_device"); + assert_eq!(blossom.endpoint_authority, "public_webpki"); + assert_eq!(blossom.primary_origin, "https://media.example"); + assert_eq!(blossom.fallback_origins, ["https://fallback.example"]); + assert_eq!(blossom.config_fingerprint.len(), 64); runtime - .configure_simulator_blossom(vec!["http://127.0.0.1:3100".to_owned()]) + .configure_blossom( + FfiBlossomHostKind::Simulator, + FfiBlossomEndpointAuthority::LoopbackDevelopment, + "http://127.0.0.1:3100".to_owned(), + vec![], + ) .expect("simulator Blossom"); runtime - .configure_device_blossom(vec!["https://10.0.0.5:3100".to_owned()]) + .configure_blossom( + FfiBlossomHostKind::PhysicalDevice, + FfiBlossomEndpointAuthority::PrivateNetworkDevelopment, + "https://10.0.0.5:3100".to_owned(), + vec![], + ) .expect("device Blossom"); assert_eq!( @@ -368,7 +389,6 @@ async fn native_boundary_delegates_the_complete_core_surface() { schema_version: MOBILE_FFI_SCHEMA_VERSION, opaque_reference: "media:unused".to_owned(), file_descriptor: 0, - url: "https://media.example/unused.png".to_owned(), sha256: "00".repeat(32), media_type: "image/png".to_owned(), byte_size: 1, diff --git a/crates/sdk/src/adapters/blossom.rs b/crates/sdk/src/adapters/blossom.rs @@ -9,26 +9,29 @@ use reqwest::{ use crate::transport::{ BlossomCancellation, BlossomConfig, BlossomEndpoint, BlossomError, BlossomErrorKind, BlossomImageDimensions, BlossomPhase, BlossomUploadReceipt, BlossomUploadRequest, + BlossomUploadTransaction, }; const MAX_RESOLVED_ADDRESSES: usize = 32; const X_SHA_256: &str = "x-sha-256"; pub(crate) async fn upload( - config: BlossomConfig, - request: BlossomUploadRequest, + transaction: BlossomUploadTransaction, authorization: crate::signing::AuthorizationHeader, cancellation: BlossomCancellation, ) -> Result<BlossomUploadReceipt, BlossomError> { - upload_with_authorization(config, request, authorization.as_str(), cancellation).await + upload_bound_with_authorization(transaction, authorization.as_str(), cancellation).await } -async fn upload_with_authorization( - config: BlossomConfig, - request: BlossomUploadRequest, +async fn upload_bound_with_authorization( + transaction: BlossomUploadTransaction, authorization: &str, cancellation: BlossomCancellation, ) -> Result<BlossomUploadReceipt, BlossomError> { + let config = transaction.config().clone(); + let endpoint = transaction.endpoint().clone(); + let expected_url = transaction.expected_url().clone(); + let request = transaction.into_request(); if request.byte_size() > config.max_blob_bytes() { return Err(failure( BlossomErrorKind::ResponseTooLarge, @@ -38,20 +41,6 @@ async fn upload_with_authorization( 0, )); } - let endpoint = config - .profile() - .endpoint_for_blob(request.expected_url()) - .cloned() - .ok_or_else(|| { - failure( - BlossomErrorKind::EndpointNotConfigured, - BlossomPhase::Configuration, - false, - false, - 0, - ) - })?; - let mut upload_attempts = 0_u8; let descriptor = loop { ensure_not_cancelled(&cancellation, BlossomPhase::Upload, upload_attempts, false)?; @@ -74,7 +63,7 @@ async fn upload_with_authorization( } }; - let verified_upload = verify_descriptor(&request, descriptor, upload_attempts)?; + let verified_upload = verify_descriptor(&request, &expected_url, descriptor, upload_attempts)?; let mut retrieval_attempts = 0_u8; let retrieved = loop { ensure_not_cancelled( @@ -138,6 +127,19 @@ async fn upload_with_authorization( )) } +#[cfg(test)] +async fn upload_with_authorization( + config: BlossomConfig, + request: BlossomUploadRequest, + authorization: &str, + cancellation: BlossomCancellation, +) -> Result<BlossomUploadReceipt, BlossomError> { + let slot = crate::transport::BlossomSlot::new(); + slot.configure(config)?; + let transaction = slot.prepare_upload(request)?; + upload_bound_with_authorization(transaction, authorization, cancellation).await +} + // Direct DNS/socket/HTTP behavior is verified by the local real-I/O suite; // deterministic coverage owns the surrounding retry, validation, and durable // state policy. @@ -249,12 +251,13 @@ async fn upload_once( fn verify_descriptor( request: &BlossomUploadRequest, + expected_url: &BlobUrl, descriptor: BlobDescriptor, attempts: u8, ) -> Result<radroots_blossom::ByteVerifiedDescriptor, BlossomError> { // `BlobDescriptor` construction already binds `sha256` to the URL hash, // so equality of the typed URL proves equality of that hash as well. - if descriptor.url() != request.expected_url() + if descriptor.url() != expected_url || descriptor.size() != request.byte_size() || descriptor.media_type() != request.media_type() { @@ -1126,8 +1129,7 @@ mod tests { BlossomErrorKind::Cancelled ); - let profile = - crate::transport::BlossomProfile::simulator(["http://127.0.0.1:9"]).expect("profile"); + let profile = simulator_profile("http://127.0.0.1:9"); let config = BlossomConfig::from_profile(profile) .with_network_policy( Duration::from_millis(10), @@ -1159,11 +1161,9 @@ mod tests { let bytes = png(2, 3); let request = upload_request("http://127.0.0.1:9", bytes.clone()); - let too_small = BlossomConfig::from_profile( - crate::transport::BlossomProfile::simulator(["http://127.0.0.1:9"]).unwrap(), - ) - .with_limits(1, 100, 0) - .unwrap(); + let too_small = BlossomConfig::from_profile(simulator_profile("http://127.0.0.1:9")) + .with_limits(1, 100, 0) + .unwrap(); assert_eq!( upload_with_authorization( too_small, @@ -1176,25 +1176,15 @@ mod tests { .kind(), BlossomErrorKind::ResponseTooLarge ); - let unconfigured_request = upload_request("http://127.0.0.1:10", bytes); - assert_eq!( - upload_with_authorization( - config, - unconfigured_request, - "Nostr redacted", - BlossomCancellation::default(), - ) - .await - .expect_err("unconfigured origin") - .kind(), - BlossomErrorKind::EndpointNotConfigured - ); } #[test] fn descriptor_verification_checks_each_identity_field() { let bytes = png(2, 3); let request = upload_request("http://127.0.0.1:3000", bytes); + let expected_url = + BlobUrl::parse(format!("http://127.0.0.1:3000/{}.png", request.sha256()).as_str()) + .unwrap(); let descriptor = |url: BlobUrl, size: u64, media_type: &str| { BlobDescriptor::new( url, @@ -1208,6 +1198,7 @@ mod tests { assert!( verify_descriptor( &request, + &expected_url, descriptor( BlobUrl::parse( format!("http://localhost:3000/{}.png", request.sha256()).as_str() @@ -1223,11 +1214,8 @@ mod tests { assert!( verify_descriptor( &request, - descriptor( - request.expected_url().clone(), - request.byte_size() + 1, - "image/png" - ), + &expected_url, + descriptor(expected_url.clone(), request.byte_size() + 1, "image/png"), 1, ) .is_err() @@ -1235,11 +1223,8 @@ mod tests { assert!( verify_descriptor( &request, - descriptor( - request.expected_url().clone(), - request.byte_size(), - "image/jpeg", - ), + &expected_url, + descriptor(expected_url.clone(), request.byte_size(), "image/jpeg",), 1, ) .is_err() @@ -1458,10 +1443,8 @@ mod tests { (origin, task) } - fn upload_request(origin: &str, bytes: Vec<u8>) -> BlossomUploadRequest { - let hash = Sha256::digest(bytes.as_slice()); + fn upload_request(_origin: &str, bytes: Vec<u8>) -> BlossomUploadRequest { BlossomUploadRequest::new( - BlobUrl::parse(format!("{origin}/{hash}.png").as_str()).expect("blob URL"), Arc::from(bytes), MediaType::parse("image/png").expect("media type"), BlossomImageDimensions::new(2, 3).expect("dimensions"), @@ -1471,16 +1454,24 @@ mod tests { } fn config(origin: &str) -> BlossomConfig { - BlossomConfig::from_profile( - crate::transport::BlossomProfile::simulator([origin]).expect("profile"), - ) - .with_network_policy( - Duration::from_millis(100), - Duration::from_millis(100), - 1, - Duration::from_millis(1), + BlossomConfig::from_profile(simulator_profile(origin)) + .with_network_policy( + Duration::from_millis(100), + Duration::from_millis(100), + 1, + Duration::from_millis(1), + ) + .expect("network policy") + } + + fn simulator_profile(origin: &str) -> crate::transport::BlossomProfile { + crate::transport::BlossomProfile::new( + crate::transport::BlossomHostKind::Simulator, + crate::transport::BlossomEndpointAuthority::LoopbackDevelopment, + origin, + std::iter::empty::<&str>(), ) - .expect("network policy") + .expect("profile") } #[tokio::test] @@ -1516,16 +1507,14 @@ mod tests { async fn retryable_upload_and_retrieval_failures_recover_with_bounded_attempts() { let bytes = png(2, 3); let (origin, server) = spawn_retry_server(bytes.clone()).await; - let config = BlossomConfig::from_profile( - crate::transport::BlossomProfile::simulator([origin.as_str()]).expect("profile"), - ) - .with_network_policy( - Duration::from_millis(100), - Duration::from_millis(100), - 2, - Duration::from_millis(1), - ) - .expect("network policy"); + let config = BlossomConfig::from_profile(simulator_profile(origin.as_str())) + .with_network_policy( + Duration::from_millis(100), + Duration::from_millis(100), + 2, + Duration::from_millis(1), + ) + .expect("network policy"); let receipt = upload_with_authorization( config, upload_request(origin.as_str(), bytes), @@ -1588,9 +1577,7 @@ mod tests { let cancellation = BlossomCancellation::default(); cancellation.cancel(); let bytes = png(2, 3); - let hash = Sha256::digest(bytes.as_slice()); let request = BlossomUploadRequest::new( - BlobUrl::parse(format!("http://127.0.0.1:9/{hash}.png").as_str()).unwrap(), Arc::from(bytes), MediaType::parse("image/png").unwrap(), BlossomImageDimensions::new(2, 3).unwrap(), diff --git a/crates/sdk/src/transport.rs b/crates/sdk/src/transport.rs @@ -50,27 +50,30 @@ const MAX_BLOSSOM_TIMEOUT: Duration = Duration::from_secs(120); #[cfg(feature = "blossom")] const MAX_BLOSSOM_RETRY_DELAY: Duration = Duration::from_secs(30); -/// Network trust profile applied to one configured Blossom origin. +/// Host environment executing Blossom operations. #[cfg(feature = "blossom")] #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] #[non_exhaustive] -pub enum BlossomEndpointPolicy { - /// TLS-only public Internet origin with global DNS results. - Public, - /// Development-only HTTP or HTTPS origin resolving only to loopback. +pub enum BlossomHostKind { + /// A non-mobile native host. + Native, + /// An Apple or Android simulator. Simulator, - /// Explicit TLS origin on a trusted physical-device network. - Device, + /// A physical mobile device. + PhysicalDevice, } -/// Host environment whose trust rules produced a Blossom profile. +/// Network authority applied independently to configured Blossom origins. #[cfg(feature = "blossom")] #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] #[non_exhaustive] -pub enum BlossomProfileKind { - Public, - Simulator, - Device, +pub enum BlossomEndpointAuthority { + /// Public HTTPS authenticated by the platform WebPKI roots. + PublicWebPki, + /// Development-only HTTP or HTTPS resolving exclusively to loopback. + LoopbackDevelopment, + /// Development-only HTTPS resolving to a non-loopback trusted network. + PrivateNetworkDevelopment, } /// One canonical configured Blossom origin. @@ -80,12 +83,15 @@ pub struct BlossomEndpoint { origin: String, host: String, port: u16, - policy: BlossomEndpointPolicy, + authority: BlossomEndpointAuthority, } #[cfg(feature = "blossom")] impl BlossomEndpoint { - fn parse(value: impl AsRef<str>, policy: BlossomEndpointPolicy) -> Result<Self, BlossomError> { + fn parse( + value: impl AsRef<str>, + authority: BlossomEndpointAuthority, + ) -> Result<Self, BlossomError> { let value = value.as_ref(); if value.is_empty() || !value.is_ascii() || value.chars().any(char::is_whitespace) { return Err(BlossomError::configuration( @@ -111,12 +117,12 @@ impl BlossomEndpoint { let port = parsed .port_or_known_default() .ok_or_else(|| BlossomError::configuration(BlossomErrorKind::InvalidEndpoint))?; - if port == 0 || !endpoint_scheme_is_allowed(parsed.scheme(), policy) { + if port == 0 || !endpoint_scheme_is_allowed(parsed.scheme(), authority) { return Err(BlossomError::configuration( BlossomErrorKind::EndpointSchemeDenied, )); } - validate_blossom_host(host.as_str(), policy)?; + validate_blossom_host(host.as_str(), authority)?; ServerDomain::parse(host.as_str()) .map_err(|_| BlossomError::configuration(BlossomErrorKind::InvalidEndpoint))?; // HTTP(S) URLs always have a tuple origin after the scheme and host @@ -127,7 +133,7 @@ impl BlossomEndpoint { origin, host, port, - policy, + authority, }) } @@ -149,10 +155,10 @@ impl BlossomEndpoint { self.port } - /// Returns the policy used before and after DNS resolution. + /// Returns the authority used before and after DNS resolution. #[must_use] - pub const fn policy(&self) -> BlossomEndpointPolicy { - self.policy + pub const fn authority(&self) -> BlossomEndpointAuthority { + self.authority } pub(crate) fn upload_url(&self) -> String { @@ -176,7 +182,7 @@ impl BlossomEndpoint { let mut found = false; for address in addresses { found = true; - if !blossom_policy_accepts_address(self.policy, address) { + if !blossom_authority_accepts_address(self.authority, address) { return Err(BlossomError::configuration( BlossomErrorKind::ResolvedAddressDenied, )); @@ -195,94 +201,80 @@ impl BlossomEndpoint { #[cfg(feature = "blossom")] #[derive(Clone, Debug, Eq, PartialEq)] pub struct BlossomProfile { - kind: BlossomProfileKind, - endpoints: Vec<BlossomEndpoint>, + host_kind: BlossomHostKind, + authority: BlossomEndpointAuthority, + primary: BlossomEndpoint, + fallbacks: Vec<BlossomEndpoint>, } #[cfg(feature = "blossom")] impl BlossomProfile { - /// Configures explicit public TLS Blossom origins. - pub fn public<I, S>(origins: I) -> Result<Self, BlossomError> - where - I: IntoIterator<Item = S>, - S: AsRef<str>, - { - Self::parse( - BlossomProfileKind::Public, - origins, - BlossomEndpointPolicy::Public, - ) - } - - /// Configures exact simulator-loopback Blossom origins. - pub fn simulator<I, S>(origins: I) -> Result<Self, BlossomError> - where - I: IntoIterator<Item = S>, - S: AsRef<str>, - { - Self::parse( - BlossomProfileKind::Simulator, - origins, - BlossomEndpointPolicy::Simulator, - ) - } - - /// Configures explicit TLS origins reachable from a physical device. - pub fn device<I, S>(origins: I) -> Result<Self, BlossomError> - where - I: IntoIterator<Item = S>, - S: AsRef<str>, - { - Self::parse( - BlossomProfileKind::Device, - origins, - BlossomEndpointPolicy::Device, - ) - } - - fn parse<I, S>( - kind: BlossomProfileKind, - origins: I, - policy: BlossomEndpointPolicy, + /// Configures one primary origin and an ordered, explicitly bounded fallback set. + pub fn new<I, S>( + host_kind: BlossomHostKind, + authority: BlossomEndpointAuthority, + primary_origin: impl AsRef<str>, + fallback_origins: I, ) -> Result<Self, BlossomError> where I: IntoIterator<Item = S>, S: AsRef<str>, { - let endpoints = origins + validate_host_authority(host_kind, authority)?; + let primary = BlossomEndpoint::parse(primary_origin, authority)?; + // The public constructor accepts any iterator, so bound collection + // before parsing to prevent an oversized caller from allocating an + // unbounded temporary vector only to be rejected afterward. + let fallbacks = fallback_origins .into_iter() - .map(|origin| BlossomEndpoint::parse(origin, policy)) + .take(MAX_BLOSSOM_ENDPOINTS) + .map(|origin| BlossomEndpoint::parse(origin, authority)) .collect::<Result<Vec<_>, _>>()?; - if endpoints.is_empty() || endpoints.len() > MAX_BLOSSOM_ENDPOINTS { + if fallbacks.len().saturating_add(1) > MAX_BLOSSOM_ENDPOINTS { return Err(BlossomError::configuration( BlossomErrorKind::InvalidEndpointCount, )); } - let unique = endpoints - .iter() + let unique = std::iter::once(&primary) + .chain(fallbacks.iter()) .map(BlossomEndpoint::origin) .collect::<BTreeSet<_>>(); - if unique.len() != endpoints.len() { + if unique.len() != fallbacks.len().saturating_add(1) { return Err(BlossomError::configuration( BlossomErrorKind::DuplicateEndpoint, )); } - Ok(Self { kind, endpoints }) + Ok(Self { + host_kind, + authority, + primary, + fallbacks, + }) } #[must_use] - pub const fn kind(&self) -> BlossomProfileKind { - self.kind + pub const fn host_kind(&self) -> BlossomHostKind { + self.host_kind + } + + #[must_use] + pub const fn authority(&self) -> BlossomEndpointAuthority { + self.authority } #[must_use] - pub fn endpoints(&self) -> &[BlossomEndpoint] { - self.endpoints.as_slice() + pub const fn primary(&self) -> &BlossomEndpoint { + &self.primary + } + + #[must_use] + pub fn fallbacks(&self) -> &[BlossomEndpoint] { + self.fallbacks.as_slice() } pub(crate) fn endpoint_for_blob(&self, url: &BlobUrl) -> Option<&BlossomEndpoint> { - self.endpoints - .iter() + std::iter::once(&self.primary) + .chain(self.fallbacks.iter()) .find(|endpoint| endpoint.accepts_blob_url(url)) } } @@ -367,6 +359,36 @@ impl BlossomConfig { &self.profile } + /// Returns the stable identity of every setting that can affect an operation. + #[must_use] + pub fn fingerprint(&self) -> BlossomConfigFingerprint { + let mut material = Vec::new(); + material.extend_from_slice(b"radroots-blossom-config-v1\0"); + material.push(match self.profile.host_kind { + BlossomHostKind::Native => 0, + BlossomHostKind::Simulator => 1, + BlossomHostKind::PhysicalDevice => 2, + }); + material.push(match self.profile.authority { + BlossomEndpointAuthority::PublicWebPki => 0, + BlossomEndpointAuthority::LoopbackDevelopment => 1, + BlossomEndpointAuthority::PrivateNetworkDevelopment => 2, + }); + append_fingerprint_field(&mut material, self.profile.primary.origin.as_bytes()); + material.extend_from_slice(&(self.profile.fallbacks.len() as u64).to_be_bytes()); + for endpoint in &self.profile.fallbacks { + append_fingerprint_field(&mut material, endpoint.origin.as_bytes()); + } + material.extend_from_slice(&self.max_blob_bytes.to_be_bytes()); + material.extend_from_slice(&(self.max_descriptor_bytes as u64).to_be_bytes()); + material.push(self.max_redirects); + material.push(self.max_attempts); + material.extend_from_slice(&(self.connect_timeout.as_millis() as u64).to_be_bytes()); + material.extend_from_slice(&(self.request_timeout.as_millis() as u64).to_be_bytes()); + material.extend_from_slice(&(self.initial_retry_delay.as_millis() as u64).to_be_bytes()); + BlossomConfigFingerprint(Sha256::digest(material.as_slice())) + } + pub(crate) const fn max_blob_bytes(&self) -> u64 { self.max_blob_bytes } @@ -396,6 +418,32 @@ impl BlossomConfig { } } +/// Stable, non-secret identity of a completely validated Blossom configuration. +#[cfg(feature = "blossom")] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct BlossomConfigFingerprint(Sha256); + +#[cfg(feature = "blossom")] +impl BlossomConfigFingerprint { + #[must_use] + pub fn to_hex(self) -> String { + self.0.to_hex() + } +} + +#[cfg(feature = "blossom")] +impl std::fmt::Display for BlossomConfigFingerprint { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, formatter) + } +} + +#[cfg(feature = "blossom")] +fn append_fingerprint_field(material: &mut Vec<u8>, value: &[u8]) { + material.extend_from_slice(&(value.len() as u64).to_be_bytes()); + material.extend_from_slice(value); +} + /// Nonzero dimensions verified from the final image bytes. #[cfg(feature = "blossom")] #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -426,11 +474,11 @@ impl BlossomImageDimensions { } } -/// Exact final image bytes and expected BUD-02 descriptor identity. +/// Exact final image bytes from which Rust derives the BUD-02 destination. #[cfg(feature = "blossom")] #[derive(Clone)] pub struct BlossomUploadRequest { - expected_url: BlobUrl, + sha256: Sha256, bytes: Arc<[u8]>, media_type: MediaType, dimensions: BlossomImageDimensions, @@ -440,24 +488,19 @@ pub struct BlossomUploadRequest { #[cfg(feature = "blossom")] impl BlossomUploadRequest { pub fn new( - expected_url: BlobUrl, bytes: Arc<[u8]>, media_type: MediaType, dimensions: BlossomImageDimensions, verified_at_unix_ms: u64, ) -> Result<Self, BlossomError> { - if bytes.is_empty() - || expected_url.hash_path().extension().is_none() - || expected_url.hash_path().hash() != Sha256::digest(bytes.as_ref()) - || verified_at_unix_ms == 0 - { + if bytes.is_empty() || verified_at_unix_ms == 0 { return Err(BlossomError::configuration( BlossomErrorKind::InvalidRequest, )); } crate::adapters::blossom::verify_image(bytes.as_ref(), &media_type, dimensions)?; Ok(Self { - expected_url, + sha256: Sha256::digest(bytes.as_ref()), bytes, media_type, dimensions, @@ -466,11 +509,6 @@ impl BlossomUploadRequest { } #[must_use] - pub const fn expected_url(&self) -> &BlobUrl { - &self.expected_url - } - - #[must_use] pub fn media_type(&self) -> &MediaType { &self.media_type } @@ -481,8 +519,8 @@ impl BlossomUploadRequest { } #[must_use] - pub fn sha256(&self) -> Sha256 { - self.expected_url.hash_path().hash() + pub const fn sha256(&self) -> Sha256 { + self.sha256 } #[must_use] @@ -499,6 +537,60 @@ impl BlossomUploadRequest { } } +/// Immutable upload plan binding exact bytes to one complete configuration. +#[cfg(feature = "blossom")] +#[derive(Clone)] +pub struct BlossomUploadTransaction { + config: BlossomConfig, + config_fingerprint: BlossomConfigFingerprint, + endpoint: BlossomEndpoint, + expected_url: BlobUrl, + request: BlossomUploadRequest, +} + +#[cfg(feature = "blossom")] +impl BlossomUploadTransaction { + #[must_use] + pub const fn config_fingerprint(&self) -> BlossomConfigFingerprint { + self.config_fingerprint + } + + #[must_use] + pub const fn expected_url(&self) -> &BlobUrl { + &self.expected_url + } + + #[must_use] + pub const fn request(&self) -> &BlossomUploadRequest { + &self.request + } + + pub(crate) const fn config(&self) -> &BlossomConfig { + &self.config + } + + pub(crate) const fn endpoint(&self) -> &BlossomEndpoint { + &self.endpoint + } + + pub(crate) fn into_request(self) -> BlossomUploadRequest { + self.request + } +} + +#[cfg(feature = "blossom")] +impl std::fmt::Debug for BlossomUploadTransaction { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("BlossomUploadTransaction") + .field("config_fingerprint", &self.config_fingerprint) + .field("endpoint", &self.endpoint) + .field("expected_url", &self.expected_url) + .field("request", &self.request) + .finish() + } +} + #[cfg(feature = "blossom")] impl std::fmt::Debug for BlossomUploadRequest { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -573,6 +665,7 @@ pub enum BlossomErrorKind { InvalidEndpointCount, DuplicateEndpoint, EndpointNotConfigured, + ConfigurationChanged, ResolutionFailed, ResolvedAddressDenied, InvalidLimits, @@ -661,6 +754,7 @@ impl BlossomError { BlossomErrorKind::InvalidEndpointCount => "blossom_invalid_endpoint_count", BlossomErrorKind::DuplicateEndpoint => "blossom_duplicate_endpoint", BlossomErrorKind::EndpointNotConfigured => "blossom_endpoint_not_configured", + BlossomErrorKind::ConfigurationChanged => "blossom_configuration_changed", BlossomErrorKind::ResolutionFailed => "blossom_resolution_failed", BlossomErrorKind::ResolvedAddressDenied => "blossom_resolved_address_denied", BlossomErrorKind::InvalidLimits => "blossom_invalid_limits", @@ -798,29 +892,72 @@ impl BlossomSlot { } #[must_use] - pub fn profile_kind(&self) -> Option<BlossomProfileKind> { - self.snapshot().map(|config| config.profile.kind()) + pub fn host_kind(&self) -> Option<BlossomHostKind> { + self.snapshot().map(|config| config.profile.host_kind()) + } + + #[must_use] + pub fn endpoint_authority(&self) -> Option<BlossomEndpointAuthority> { + self.snapshot().map(|config| config.profile.authority()) + } + + #[must_use] + pub fn config_fingerprint(&self) -> Option<BlossomConfigFingerprint> { + self.snapshot().map(|config| config.fingerprint()) + } + + /// Returns the configured inert profile without performing network I/O. + #[must_use] + pub fn profile(&self) -> Option<BlossomProfile> { + self.snapshot().map(|config| config.profile) + } + + /// Atomically returns the inert profile and its exact configuration identity. + #[must_use] + pub fn configuration(&self) -> Option<(BlossomProfile, BlossomConfigFingerprint)> { + self.snapshot().map(|config| { + let fingerprint = config.fingerprint(); + (config.profile, fingerprint) + }) + } + + /// Binds verified bytes to the configured primary origin without network I/O. + pub fn prepare_upload( + &self, + request: BlossomUploadRequest, + ) -> Result<BlossomUploadTransaction, BlossomError> { + let config = self + .snapshot() + .ok_or_else(|| BlossomError::configuration(BlossomErrorKind::EndpointNotConfigured))?; + let config_fingerprint = config.fingerprint(); + let endpoint = config.profile.primary.clone(); + let extension = canonical_image_extension(request.media_type())?; + let expected_url = BlobUrl::parse( + format!("{}/{}.{}", endpoint.origin(), request.sha256(), extension).as_str(), + ) + .map_err(|_| BlossomError::configuration(BlossomErrorKind::InvalidEndpoint))?; + Ok(BlossomUploadTransaction { + config, + config_fingerprint, + endpoint, + expected_url, + request, + }) } /// Builds the exact BUD-11 claim for this configured upload destination. pub fn authored_upload_claim( &self, - request: &BlossomUploadRequest, + transaction: &BlossomUploadTransaction, content: AuthorizationContent, created_at_unix_s: u64, lifetime_seconds: u64, ) -> Result<AuthoredUploadClaim, BlossomError> { - let config = self - .snapshot() - .ok_or_else(|| BlossomError::configuration(BlossomErrorKind::EndpointNotConfigured))?; - let endpoint = config - .profile - .endpoint_for_blob(request.expected_url()) - .ok_or_else(|| BlossomError::configuration(BlossomErrorKind::EndpointNotConfigured))?; + self.validate_transaction(transaction)?; AuthoredUploadClaim::new( content, - endpoint.server_domain()?, - request.sha256(), + transaction.endpoint.server_domain()?, + transaction.request.sha256(), created_at_unix_s, lifetime_seconds, ) @@ -838,14 +975,27 @@ impl BlossomSlot { /// Uploads exact bytes and verifies the returned descriptor and a full GET. pub async fn upload( &self, - request: BlossomUploadRequest, + transaction: BlossomUploadTransaction, authorization: crate::signing::AuthorizationHeader, cancellation: BlossomCancellation, ) -> Result<BlossomUploadReceipt, BlossomError> { - let config = self - .snapshot() + self.validate_transaction(&transaction)?; + crate::adapters::blossom::upload(transaction, authorization, cancellation).await + } + + fn validate_transaction( + &self, + transaction: &BlossomUploadTransaction, + ) -> Result<(), BlossomError> { + let fingerprint = self + .config_fingerprint() .ok_or_else(|| BlossomError::configuration(BlossomErrorKind::EndpointNotConfigured))?; - crate::adapters::blossom::upload(config, request, authorization, cancellation).await + if fingerprint != transaction.config_fingerprint { + return Err(BlossomError::configuration( + BlossomErrorKind::ConfigurationChanged, + )); + } + Ok(()) } fn snapshot(&self) -> Option<BlossomConfig> { @@ -858,26 +1008,53 @@ impl std::fmt::Debug for BlossomSlot { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("BlossomSlot") - .field("configured", &self.profile_kind().is_some()) + .field("configured", &self.host_kind().is_some()) .finish() } } #[cfg(feature = "blossom")] -fn endpoint_scheme_is_allowed(scheme: &str, policy: BlossomEndpointPolicy) -> bool { - scheme == "https" || scheme == "http" && policy == BlossomEndpointPolicy::Simulator +fn endpoint_scheme_is_allowed(scheme: &str, authority: BlossomEndpointAuthority) -> bool { + scheme == "https" + || scheme == "http" && authority == BlossomEndpointAuthority::LoopbackDevelopment +} + +#[cfg(feature = "blossom")] +fn validate_host_authority( + host_kind: BlossomHostKind, + authority: BlossomEndpointAuthority, +) -> Result<(), BlossomError> { + let accepted = match authority { + BlossomEndpointAuthority::PublicWebPki => true, + BlossomEndpointAuthority::LoopbackDevelopment => host_kind == BlossomHostKind::Simulator, + BlossomEndpointAuthority::PrivateNetworkDevelopment => { + host_kind == BlossomHostKind::PhysicalDevice + } + }; + if accepted { + Ok(()) + } else { + Err(BlossomError::configuration( + BlossomErrorKind::InvalidEndpoint, + )) + } } #[cfg(feature = "blossom")] -fn validate_blossom_host(host: &str, policy: BlossomEndpointPolicy) -> Result<(), BlossomError> { +fn validate_blossom_host( + host: &str, + authority: BlossomEndpointAuthority, +) -> Result<(), BlossomError> { let address = host.parse::<IpAddr>().ok(); - let accepted = match (policy, address) { - (BlossomEndpointPolicy::Public, Some(address)) => public_blossom_address(address), - (BlossomEndpointPolicy::Public, None) => public_blossom_hostname(host), - (BlossomEndpointPolicy::Simulator, Some(address)) => address.is_loopback(), - (BlossomEndpointPolicy::Simulator, None) => host == "localhost", - (BlossomEndpointPolicy::Device, Some(address)) => trusted_blossom_address(address), - (BlossomEndpointPolicy::Device, None) => host != "localhost", + let accepted = match (authority, address) { + (BlossomEndpointAuthority::PublicWebPki, Some(address)) => public_blossom_address(address), + (BlossomEndpointAuthority::PublicWebPki, None) => public_blossom_hostname(host), + (BlossomEndpointAuthority::LoopbackDevelopment, Some(address)) => address.is_loopback(), + (BlossomEndpointAuthority::LoopbackDevelopment, None) => host == "localhost", + (BlossomEndpointAuthority::PrivateNetworkDevelopment, Some(address)) => { + trusted_blossom_address(address) + } + (BlossomEndpointAuthority::PrivateNetworkDevelopment, None) => host != "localhost", }; if accepted { Ok(()) @@ -897,11 +1074,28 @@ fn public_blossom_hostname(host: &str) -> bool { } #[cfg(feature = "blossom")] -fn blossom_policy_accepts_address(policy: BlossomEndpointPolicy, address: IpAddr) -> bool { - match policy { - BlossomEndpointPolicy::Public => public_blossom_address(address), - BlossomEndpointPolicy::Simulator => address.is_loopback(), - BlossomEndpointPolicy::Device => trusted_blossom_address(address), +fn blossom_authority_accepts_address(authority: BlossomEndpointAuthority, address: IpAddr) -> bool { + match authority { + BlossomEndpointAuthority::PublicWebPki => public_blossom_address(address), + BlossomEndpointAuthority::LoopbackDevelopment => address.is_loopback(), + BlossomEndpointAuthority::PrivateNetworkDevelopment => trusted_blossom_address(address), + } +} + +#[cfg(feature = "blossom")] +fn canonical_image_extension(media_type: &MediaType) -> Result<&'static str, BlossomError> { + match media_type.as_str() { + "image/png" => Ok("png"), + "image/jpeg" => Ok("jpg"), + "image/gif" => Ok("gif"), + "image/webp" => Ok("webp"), + _ => Err(BlossomError::new( + BlossomErrorKind::UnsupportedMediaType, + BlossomPhase::Verification, + false, + false, + 0, + )), } } @@ -1580,22 +1774,22 @@ mod tests { #[cfg(feature = "blossom")] #[test] fn blossom_profiles_enforce_environment_and_ssrf_boundaries() { - assert!(BlossomProfile::public(["https://media.example"]).is_ok()); - assert!(BlossomProfile::public(["http://media.example"]).is_err()); - assert!(BlossomProfile::public(["https://127.0.0.1"]).is_err()); - assert!(BlossomProfile::public(["https://10.0.0.1"]).is_err()); - assert!(BlossomProfile::simulator(["http://127.0.0.1:3000"]).is_ok()); - assert!(BlossomProfile::simulator(["http://localhost:3000"]).is_ok()); - assert!(BlossomProfile::simulator(["http://media.example"]).is_err()); - assert!(BlossomProfile::device(["https://10.0.0.10:8443"]).is_ok()); - assert!(BlossomProfile::device(["http://10.0.0.10:8443"]).is_err()); - assert!(BlossomProfile::device(["https://127.0.0.1:8443"]).is_err()); + assert!(public_blossom_profile("https://media.example").is_ok()); + assert!(public_blossom_profile("http://media.example").is_err()); + assert!(public_blossom_profile("https://127.0.0.1").is_err()); + assert!(public_blossom_profile("https://10.0.0.1").is_err()); + assert!(simulator_blossom_profile("http://127.0.0.1:3000").is_ok()); + assert!(simulator_blossom_profile("http://localhost:3000").is_ok()); + assert!(simulator_blossom_profile("http://media.example").is_err()); + assert!(device_blossom_profile("https://10.0.0.10:8443").is_ok()); + assert!(device_blossom_profile("http://10.0.0.10:8443").is_err()); + assert!(device_blossom_profile("https://127.0.0.1:8443").is_err()); } #[cfg(feature = "blossom")] #[test] fn blossom_configuration_is_bounded_and_debug_is_secret_safe() { - let profile = BlossomProfile::simulator(["http://127.0.0.1:3000"]).unwrap(); + let profile = simulator_blossom_profile("http://127.0.0.1:3000").unwrap(); assert!( BlossomConfig::from_profile(profile.clone()) .with_limits(0, 1, 0) @@ -1614,7 +1808,11 @@ mod tests { let slot = BlossomSlot::new(); slot.configure(BlossomConfig::from_profile(profile)) .unwrap(); - assert_eq!(slot.profile_kind(), Some(BlossomProfileKind::Simulator)); + assert_eq!(slot.host_kind(), Some(BlossomHostKind::Simulator)); + assert_eq!( + slot.endpoint_authority(), + Some(BlossomEndpointAuthority::LoopbackDevelopment) + ); assert_eq!(format!("{slot:?}"), "BlossomSlot { configured: true }"); } @@ -1627,11 +1825,9 @@ mod tests { } #[cfg(feature = "blossom")] - fn blossom_request(origin: &str) -> BlossomUploadRequest { + fn blossom_request(_origin: &str) -> BlossomUploadRequest { let bytes = blossom_png(2, 3); - let hash = Sha256::digest(bytes.as_slice()); BlossomUploadRequest::new( - BlobUrl::parse(format!("{origin}/{hash}.png").as_str()).expect("blob URL"), Arc::from(bytes), MediaType::parse("image/png").expect("media type"), BlossomImageDimensions::new(2, 3).expect("dimensions"), @@ -1641,56 +1837,130 @@ mod tests { } #[cfg(feature = "blossom")] + fn public_blossom_profile(origin: &str) -> Result<BlossomProfile, BlossomError> { + BlossomProfile::new( + BlossomHostKind::Native, + BlossomEndpointAuthority::PublicWebPki, + origin, + std::iter::empty::<&str>(), + ) + } + + #[cfg(feature = "blossom")] + fn simulator_blossom_profile(origin: &str) -> Result<BlossomProfile, BlossomError> { + BlossomProfile::new( + BlossomHostKind::Simulator, + BlossomEndpointAuthority::LoopbackDevelopment, + origin, + std::iter::empty::<&str>(), + ) + } + + #[cfg(feature = "blossom")] + fn device_blossom_profile(origin: &str) -> Result<BlossomProfile, BlossomError> { + BlossomProfile::new( + BlossomHostKind::PhysicalDevice, + BlossomEndpointAuthority::PrivateNetworkDevelopment, + origin, + std::iter::empty::<&str>(), + ) + } + + #[cfg(feature = "blossom")] #[test] fn blossom_profiles_expose_exact_identity_and_reject_malformed_sets() { - let public = BlossomProfile::public(["https://media.example:8443"]).expect("public"); - assert_eq!(public.kind(), BlossomProfileKind::Public); - assert_eq!(public.endpoints().len(), 1); - let endpoint = &public.endpoints()[0]; + let public = BlossomProfile::new( + BlossomHostKind::PhysicalDevice, + BlossomEndpointAuthority::PublicWebPki, + "https://media.example:8443", + ["https://fallback.example"], + ) + .expect("public"); + assert_eq!(public.host_kind(), BlossomHostKind::PhysicalDevice); + assert_eq!(public.authority(), BlossomEndpointAuthority::PublicWebPki); + assert_eq!(public.fallbacks().len(), 1); + let endpoint = public.primary(); assert_eq!(endpoint.origin(), "https://media.example:8443"); assert_eq!(endpoint.host(), "media.example"); assert_eq!(endpoint.port(), 8443); - assert_eq!(endpoint.policy(), BlossomEndpointPolicy::Public); + assert_eq!(endpoint.authority(), BlossomEndpointAuthority::PublicWebPki); let request = blossom_request("https://media.example:8443"); - assert!(endpoint.accepts_blob_url(request.expected_url())); + let slot = BlossomSlot::new(); + slot.configure(BlossomConfig::from_profile(public.clone())) + .unwrap(); + let transaction = slot.prepare_upload(request).unwrap(); + assert!(endpoint.accepts_blob_url(transaction.expected_url())); + assert_eq!( + transaction + .expected_url() + .hash_path() + .extension() + .unwrap() + .as_str(), + "png" + ); + assert_eq!( + transaction.expected_url().hash_path().hash(), + transaction.request().sha256() + ); assert_eq!(endpoint.upload_url(), "https://media.example:8443/upload"); assert_eq!(endpoint.server_domain().unwrap().as_str(), "media.example"); assert_eq!( public - .endpoint_for_blob(request.expected_url()) + .endpoint_for_blob(transaction.expected_url()) .expect("configured endpoint"), endpoint ); assert_eq!( - BlossomProfile::device(["https://device.example"]) - .unwrap() - .kind(), - BlossomProfileKind::Device + BlossomProfile::new( + BlossomHostKind::PhysicalDevice, + BlossomEndpointAuthority::PrivateNetworkDevelopment, + "https://device.example", + std::iter::empty::<&str>(), + ) + .unwrap() + .host_kind(), + BlossomHostKind::PhysicalDevice ); assert_eq!( - BlossomProfile::simulator(["http://localhost:3000"]) + simulator_blossom_profile("http://localhost:3000") .unwrap() - .kind(), - BlossomProfileKind::Simulator + .host_kind(), + BlossomHostKind::Simulator ); assert_eq!( - BlossomProfile::public(std::iter::empty::<&str>()) - .expect_err("empty profile") - .kind(), - BlossomErrorKind::InvalidEndpointCount + BlossomProfile::new( + BlossomHostKind::Native, + BlossomEndpointAuthority::PublicWebPki, + "", + std::iter::empty::<&str>(), + ) + .expect_err("empty profile") + .kind(), + BlossomErrorKind::InvalidEndpoint ); assert_eq!( - BlossomProfile::public(std::iter::repeat_n("https://media.example", 17)) - .expect_err("bounded profile") - .kind(), + BlossomProfile::new( + BlossomHostKind::Native, + BlossomEndpointAuthority::PublicWebPki, + "https://primary.example", + std::iter::repeat_n("https://media.example", 16), + ) + .expect_err("bounded profile") + .kind(), BlossomErrorKind::InvalidEndpointCount ); assert_eq!( - BlossomProfile::public(["https://media.example", "https://media.example"]) - .expect_err("duplicate profile") - .kind(), + BlossomProfile::new( + BlossomHostKind::Native, + BlossomEndpointAuthority::PublicWebPki, + "https://media.example", + ["https://media.example"], + ) + .expect_err("duplicate profile") + .kind(), BlossomErrorKind::DuplicateEndpoint ); @@ -1706,14 +1976,85 @@ mod tests { "ftp://media.example", "https://media.example:0", ] { - assert!(BlossomProfile::public([malformed]).is_err(), "{malformed}"); + assert!(public_blossom_profile(malformed).is_err(), "{malformed}"); } } #[cfg(feature = "blossom")] #[test] + fn blossom_upload_transactions_bind_primary_authority_and_complete_config() { + let profile = BlossomProfile::new( + BlossomHostKind::PhysicalDevice, + BlossomEndpointAuthority::PublicWebPki, + "https://media.example:443", + ["https://fallback.example"], + ) + .unwrap(); + assert_eq!(profile.primary().origin(), "https://media.example"); + assert!( + profile + .primary() + .validate_resolved_addresses([IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))]) + .is_err() + ); + + let slot = BlossomSlot::new(); + let original = BlossomConfig::from_profile(profile); + let original_fingerprint = original.fingerprint(); + slot.configure(original).unwrap(); + let transaction = slot + .prepare_upload(blossom_request("caller-origin-is-ignored")) + .unwrap(); + assert_eq!(transaction.config_fingerprint(), original_fingerprint); + assert!( + transaction + .expected_url() + .as_str() + .starts_with("https://media.example/") + ); + let claim = slot + .authored_upload_claim( + &transaction, + AuthorizationContent::parse("Upload farm image").unwrap(), + 100, + 60, + ) + .unwrap(); + assert_eq!(claim.server_domain().as_str(), "media.example"); + + let changed = BlossomConfig::from_profile( + BlossomProfile::new( + BlossomHostKind::PhysicalDevice, + BlossomEndpointAuthority::PublicWebPki, + "https://other.example", + ["https://fallback.example"], + ) + .unwrap(), + ); + assert_ne!(changed.fingerprint(), original_fingerprint); + slot.configure(changed).unwrap(); + assert_eq!( + slot.authored_upload_claim( + &transaction, + AuthorizationContent::parse("Upload farm image").unwrap(), + 100, + 60, + ) + .expect_err("changed configuration"), + BlossomError::configuration(BlossomErrorKind::ConfigurationChanged) + ); + assert_eq!( + slot.validate_transaction(&transaction) + .expect_err("changed upload destination") + .kind(), + BlossomErrorKind::ConfigurationChanged + ); + } + + #[cfg(feature = "blossom")] + #[test] fn blossom_limits_requests_and_errors_cover_the_complete_public_contract() { - let profile = BlossomProfile::simulator(["http://127.0.0.1:3000"]).unwrap(); + let profile = simulator_blossom_profile("http://127.0.0.1:3000").unwrap(); let valid = BlossomConfig::from_profile(profile.clone()) .with_limits(1, 1, 5) .unwrap() @@ -1813,51 +2154,25 @@ mod tests { assert_eq!(request.media_type().as_str(), "image/png"); assert_eq!(request.dimensions(), dimensions); assert_eq!(request.byte_size(), request.bytes().len() as u64); - assert_eq!(request.sha256(), request.expected_url().hash_path().hash()); + assert_eq!(request.sha256(), Sha256::digest(request.bytes())); assert_eq!(request.verified_at_unix_ms(), 1_900_000_000_000); assert!(format!("{request:?}").contains("bytes: \"<redacted>\"")); let media_type = MediaType::parse("image/png").unwrap(); - let empty_hash = Sha256::digest(b""); - let empty_url = - BlobUrl::parse(format!("http://127.0.0.1:3000/{empty_hash}.png").as_str()).unwrap(); assert!( - BlossomUploadRequest::new(empty_url, Arc::from([]), media_type.clone(), dimensions, 1,) - .is_err() + BlossomUploadRequest::new(Arc::from([]), media_type.clone(), dimensions, 1,).is_err() ); let bytes = blossom_png(2, 3); - let hash = Sha256::digest(bytes.as_slice()); - let no_extension = - BlobUrl::parse(format!("http://127.0.0.1:3000/{hash}").as_str()).unwrap(); - assert!( - BlossomUploadRequest::new( - no_extension, - Arc::from(bytes.clone()), - media_type.clone(), - dimensions, - 1, - ) - .is_err() - ); - let wrong_hash = Sha256::digest(b"wrong"); - let wrong_url = - BlobUrl::parse(format!("http://127.0.0.1:3000/{wrong_hash}.png").as_str()).unwrap(); assert!( BlossomUploadRequest::new( - wrong_url, Arc::from(bytes.clone()), - media_type.clone(), + MediaType::parse("image/jpeg").unwrap(), dimensions, 1, ) .is_err() ); - let valid_url = - BlobUrl::parse(format!("http://127.0.0.1:3000/{hash}.png").as_str()).unwrap(); - assert!( - BlossomUploadRequest::new(valid_url, Arc::from(bytes), media_type, dimensions, 0) - .is_err() - ); + assert!(BlossomUploadRequest::new(Arc::from(bytes), media_type, dimensions, 0).is_err()); let all_kinds = [ ( @@ -1881,6 +2196,10 @@ mod tests { "blossom_endpoint_not_configured", ), ( + BlossomErrorKind::ConfigurationChanged, + "blossom_configuration_changed", + ), + ( BlossomErrorKind::ResolutionFailed, "blossom_resolution_failed", ), @@ -2010,8 +2329,8 @@ mod tests { assert_eq!(public_blossom_hostname(host), accepted, "{host}"); } - let simulator = BlossomProfile::simulator(["http://127.0.0.1:3000"]).unwrap(); - let simulator_endpoint = &simulator.endpoints()[0]; + let simulator = simulator_blossom_profile("http://127.0.0.1:3000").unwrap(); + let simulator_endpoint = simulator.primary(); assert!( simulator_endpoint .validate_resolved_addresses([IpAddr::V4(Ipv4Addr::LOCALHOST)]) @@ -2024,15 +2343,17 @@ mod tests { .is_err() ); - let public = BlossomProfile::public(["https://media.example"]).unwrap(); + let public = public_blossom_profile("https://media.example").unwrap(); assert!( - public.endpoints()[0] + public + .primary() .validate_resolved_addresses([IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))]) .is_ok() ); - let device = BlossomProfile::device(["https://device.example"]).unwrap(); + let device = device_blossom_profile("https://device.example").unwrap(); assert!( - device.endpoints()[0] + device + .primary() .validate_resolved_addresses([IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))]) .is_ok() ); @@ -2054,16 +2375,16 @@ mod tests { assert!(trusted_blossom_address(IpAddr::V6( "fd00::1".parse().unwrap() ))); - assert!(blossom_policy_accepts_address( - BlossomEndpointPolicy::Public, + assert!(blossom_authority_accepts_address( + BlossomEndpointAuthority::PublicWebPki, IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)) )); - assert!(blossom_policy_accepts_address( - BlossomEndpointPolicy::Simulator, + assert!(blossom_authority_accepts_address( + BlossomEndpointAuthority::LoopbackDevelopment, IpAddr::V4(Ipv4Addr::LOCALHOST) )); - assert!(blossom_policy_accepts_address( - BlossomEndpointPolicy::Device, + assert!(blossom_authority_accepts_address( + BlossomEndpointAuthority::PrivateNetworkDevelopment, IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)) )); } @@ -2083,33 +2404,31 @@ mod tests { let request = blossom_request("http://127.0.0.1:3000"); let slot = BlossomSlot::new(); - assert!(slot.profile_kind().is_none()); + assert!(slot.host_kind().is_none()); let content = AuthorizationContent::parse("Upload farm image").unwrap(); - assert!( - slot.authored_upload_claim(&request, content.clone(), 100, 60) - .is_err() - ); + assert!(slot.prepare_upload(request.clone()).is_err()); slot.configure(BlossomConfig::from_profile( - BlossomProfile::simulator(["http://127.0.0.1:3000"]).unwrap(), + simulator_blossom_profile("http://127.0.0.1:3000").unwrap(), )) .unwrap(); + let transaction = slot.prepare_upload(request.clone()).unwrap(); let claim = slot - .authored_upload_claim(&request, content.clone(), 100, 60) + .authored_upload_claim(&transaction, content.clone(), 100, 60) .unwrap(); assert_eq!(claim.server_domain().as_str(), "127.0.0.1"); assert_eq!(claim.sha256(), request.sha256()); assert_eq!(claim.lifetime_seconds(), 60); assert_eq!( - slot.authored_upload_claim(&request, content, 100, 0) + slot.authored_upload_claim(&transaction, content, 100, 0) .expect_err("invalid lifetime") .kind(), BlossomErrorKind::Authorization ); slot.clear(); - assert!(slot.profile_kind().is_none()); + assert!(slot.host_kind().is_none()); let descriptor = radroots_blossom::BlobDescriptor::new( - request.expected_url().clone(), + transaction.expected_url().clone(), request.sha256(), request.byte_size(), request.media_type().clone(), @@ -2137,11 +2456,11 @@ mod tests { assert!( poisoned .configure(BlossomConfig::from_profile( - BlossomProfile::simulator(["http://127.0.0.1:3000"]).unwrap(), + simulator_blossom_profile("http://127.0.0.1:3000").unwrap(), )) .is_err() ); - assert!(poisoned.profile_kind().is_none()); + assert!(poisoned.host_kind().is_none()); } #[cfg(feature = "radrootsd")] diff --git a/crates/sdk/tests/public_api.rs b/crates/sdk/tests/public_api.rs @@ -76,17 +76,19 @@ fn public_native_type_snapshot_uses_contextual_names() { type_name::<radroots_sdk::signing::BlossomSigningError>(), type_name::<radroots_sdk::transport::BlossomCancellation>(), type_name::<radroots_sdk::transport::BlossomConfig>(), + type_name::<radroots_sdk::transport::BlossomConfigFingerprint>(), type_name::<radroots_sdk::transport::BlossomEndpoint>(), - type_name::<radroots_sdk::transport::BlossomEndpointPolicy>(), + type_name::<radroots_sdk::transport::BlossomEndpointAuthority>(), type_name::<radroots_sdk::transport::BlossomError>(), type_name::<radroots_sdk::transport::BlossomErrorKind>(), + type_name::<radroots_sdk::transport::BlossomHostKind>(), type_name::<radroots_sdk::transport::BlossomImageDimensions>(), type_name::<radroots_sdk::transport::BlossomPhase>(), type_name::<radroots_sdk::transport::BlossomProfile>(), - type_name::<radroots_sdk::transport::BlossomProfileKind>(), type_name::<radroots_sdk::transport::BlossomSlot>(), type_name::<radroots_sdk::transport::BlossomUploadReceipt>(), type_name::<radroots_sdk::transport::BlossomUploadRequest>(), + type_name::<radroots_sdk::transport::BlossomUploadTransaction>(), ]) .collect::<BTreeSet<_>>(); #[cfg(feature = "radrootsd")] @@ -166,7 +168,7 @@ const fn expected_public_type_count() -> usize { #[cfg(feature = "nostr")] let count = count + 1; #[cfg(feature = "blossom")] - let count = count + 15; + let count = count + 17; #[cfg(feature = "radrootsd")] let count = count + 5; count