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:
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