sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit 6435ca9c20de00ce8cf0c832068d90f3607412cf
parent 915bc14f3951f99ba853667690776068f991a6af
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 11:09:50 +0000

sdk: migrate transport profiles and policies

- compose profiles from canonical target and policy types
- validate quorum and required targets before operations
- model preview transports with canonical unavailable statuses
- remove duplicate wrappers tests and fallback vocabulary

Diffstat:
Mcrates/sdk/src/transport.rs | 238++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/tests/package_boundary.rs | 21+++++++++++++++++++++
Dcrates/sdk/tests/unit/transport_tests.rs | 362-------------------------------------------------------------------------------
3 files changed, 258 insertions(+), 363 deletions(-)

diff --git a/crates/sdk/src/transport.rs b/crates/sdk/src/transport.rs @@ -1 +1,237 @@ -//! Transport capability composition. +//! Explicit user-facing transport profile composition. +//! +//! Profiles retain canonical `radroots_transport` identities, targets, +//! policies, and statuses. They select no adapter implicitly and never replace +//! an unavailable selection with another transport. + +use radroots_transport::{ + Error, SinkStatus, SourceStatus, TargetSet, TransportId, + capability::{Availability, Maturity, SinkCapabilities, SourceCapabilities}, + policy::SatisfactionPolicy, +}; + +const PREVIEW_UNAVAILABLE_MESSAGE: &str = "preview transport is unavailable in this SDK release"; + +/// A side-effect-free transport selection for a client operation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Profile { + selection: Selection, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum Selection { + LocalOnly, + Delivery { + targets: TargetSet, + satisfaction: SatisfactionPolicy, + }, + UnavailablePreview { + source: SourceStatus, + sink: SinkStatus, + }, +} + +impl Profile { + /// Selects local persistence only, with no transport target or fallback. + #[must_use] + pub const fn local_only() -> Self { + Self { + selection: Selection::LocalOnly, + } + } + + /// Selects an exact bounded target set and canonical satisfaction policy. + /// + /// Impossible quorum and required-target policies are rejected here by the + /// owning transport contract. Construction performs no network operation. + pub fn delivery(targets: TargetSet, satisfaction: SatisfactionPolicy) -> Result<Self, Error> { + satisfaction.validate_for(&targets)?; + Ok(Self { + selection: Selection::Delivery { + targets, + satisfaction, + }, + }) + } + + /// Describes a preview transport that is intentionally not selectable. + /// + /// Both canonical capability directions remain explicitly unconfigured + /// and unavailable. The profile has no targets and therefore cannot fall + /// back to local, Nostr, daemon, or another transport. + #[must_use] + pub fn unavailable_preview(transport_id: TransportId) -> Self { + Self { + selection: Selection::UnavailablePreview { + source: SourceStatus::new( + transport_id, + false, + Maturity::Preview, + Availability::Unavailable, + SourceCapabilities::NONE, + PREVIEW_UNAVAILABLE_MESSAGE, + ), + sink: SinkStatus::new( + transport_id, + false, + Maturity::Preview, + Availability::Unavailable, + SinkCapabilities::NONE, + PREVIEW_UNAVAILABLE_MESSAGE, + ), + }, + } + } + + /// Returns whether this profile authorizes no transport operation. + #[must_use] + pub const fn is_local_only(&self) -> bool { + matches!(self.selection, Selection::LocalOnly) + } + + /// Returns the exact selected targets, if delivery is authorized. + #[must_use] + pub const fn targets(&self) -> Option<&TargetSet> { + match &self.selection { + Selection::Delivery { targets, .. } => Some(targets), + Selection::LocalOnly | Selection::UnavailablePreview { .. } => None, + } + } + + /// Returns the exact selected satisfaction policy, if delivery is authorized. + #[must_use] + pub const fn satisfaction(&self) -> Option<&SatisfactionPolicy> { + match &self.selection { + Selection::Delivery { satisfaction, .. } => Some(satisfaction), + Selection::LocalOnly | Selection::UnavailablePreview { .. } => None, + } + } + + /// Returns canonical source status for an unavailable preview. + #[must_use] + pub const fn source_status(&self) -> Option<&SourceStatus> { + match &self.selection { + Selection::UnavailablePreview { source, .. } => Some(source), + Selection::LocalOnly | Selection::Delivery { .. } => None, + } + } + + /// Returns canonical sink status for an unavailable preview. + #[must_use] + pub const fn sink_status(&self) -> Option<&SinkStatus> { + match &self.selection { + Selection::UnavailablePreview { sink, .. } => Some(sink), + Selection::LocalOnly | Selection::Delivery { .. } => None, + } + } +} + +impl Default for Profile { + fn default() -> Self { + Self::local_only() + } +} + +#[cfg(test)] +mod tests { + use radroots_transport::{ + Error, TARGET_SET_MAX_ITEMS, Target, + capability::{Availability, Maturity}, + policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, + target::TargetFingerprint, + }; + + use super::*; + + fn target(index: usize) -> Target { + Target::nostr_relay(format!("wss://relay-{index}.example")).expect("target") + } + + #[test] + fn delivery_profile_preserves_canonical_targets_and_policy() { + let targets = TargetSet::new(vec![target(1), target(2)]).expect("target set"); + let policy = SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::all()); + let profile = Profile::delivery(targets.clone(), policy.clone()).expect("profile"); + + assert_eq!(profile.targets(), Some(&targets)); + assert_eq!(profile.satisfaction(), Some(&policy)); + assert!(!profile.is_local_only()); + assert!(profile.source_status().is_none()); + assert!(profile.sink_status().is_none()); + } + + #[test] + fn canonical_target_and_policy_bounds_fail_during_profile_construction() { + assert_eq!(TargetSet::new(Vec::new()), Err(Error::EmptyTargetSet)); + assert_eq!( + TargetSet::new((0..=TARGET_SET_MAX_ITEMS).map(target).collect()), + Err(Error::TargetSetTooLarge) + ); + + let targets = TargetSet::new(vec![target(1)]).expect("target set"); + let quorum = SatisfactionPolicy::new( + SatisfactionClass::Delivered, + TargetPolicy::quorum(2).expect("non-zero quorum"), + ); + assert_eq!( + Profile::delivery(targets.clone(), quorum), + Err(Error::InvalidSatisfactionPolicy) + ); + + let missing = + TargetFingerprint::from_target(target(2).kind(), target(2).uri(), target(2).scope()); + let required = SatisfactionPolicy::new( + SatisfactionClass::Accepted, + TargetPolicy::required(vec![missing]).expect("required policy"), + ); + assert_eq!( + Profile::delivery(targets, required), + Err(Error::RequiredTargetNotRequested) + ); + } + + #[test] + fn preview_transport_is_explicitly_unavailable_and_unselectable() { + let profile = Profile::unavailable_preview(TransportId::RETICULUM); + let source = profile.source_status().expect("source status"); + let sink = profile.sink_status().expect("sink status"); + + assert_eq!(source.transport_id(), TransportId::RETICULUM); + assert_eq!(sink.transport_id(), TransportId::RETICULUM); + assert!(!source.is_configured()); + assert!(!sink.is_configured()); + assert_eq!(source.maturity(), Maturity::Preview); + assert_eq!(sink.maturity(), Maturity::Preview); + assert_eq!(source.availability(), Availability::Unavailable); + assert_eq!(sink.availability(), Availability::Unavailable); + assert!(!source.capabilities().can_fetch()); + assert!(!sink.capabilities().can_deliver()); + assert!(profile.targets().is_none()); + assert!(profile.satisfaction().is_none()); + } + + #[test] + fn local_and_preview_profiles_never_substitute_fallback_targets() { + let local = Profile::local_only(); + let preview = Profile::unavailable_preview(TransportId::RETICULUM); + assert!(local.is_local_only()); + assert!(local.targets().is_none()); + assert!(preview.targets().is_none()); + + let selected = TargetSet::new(vec![target(7)]).expect("selected targets"); + let profile = Profile::delivery( + selected.clone(), + SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::any()), + ) + .expect("profile"); + assert_eq!(profile.targets(), Some(&selected)); + assert!( + profile + .targets() + .expect("targets") + .targets() + .iter() + .all(|target| *target.kind() == TransportId::NOSTR) + ); + } +} diff --git a/crates/sdk/tests/package_boundary.rs b/crates/sdk/tests/package_boundary.rs @@ -3,6 +3,7 @@ use std::collections::BTreeSet; const MANIFEST: &str = include_str!("../Cargo.toml"); const ROOT: &str = include_str!("../src/lib.rs"); const CLIENT: &str = include_str!("../src/client.rs"); +const TRANSPORT: &str = include_str!("../src/transport.rs"); #[test] fn manifest_has_final_identity_and_dependency_boundary() { @@ -137,6 +138,26 @@ fn sdk_source_contains_no_studio_storage_surface() { } } +#[test] +fn transport_profiles_reuse_canonical_types_and_forbid_fallback() { + assert!(TRANSPORT.contains("use radroots_transport::{")); + assert!(TRANSPORT.contains("satisfaction.validate_for(&targets)?")); + assert!(TRANSPORT.contains("Selection::UnavailablePreview")); + for duplicate in [ + "pub struct TargetSet", + "pub enum TargetPolicy", + "pub enum SatisfactionPolicy", + "pub struct SourceStatus", + "pub struct SinkStatus", + "DefaultProfile", + ] { + assert!( + !TRANSPORT.contains(duplicate), + "SDK transport source contains forbidden duplicate or fallback `{duplicate}`" + ); + } +} + fn dependency_names(manifest: &str) -> BTreeSet<&str> { let dependencies = manifest .split_once("[dependencies]") diff --git a/crates/sdk/tests/unit/transport_tests.rs b/crates/sdk/tests/unit/transport_tests.rs @@ -1,362 +0,0 @@ -use super::{ - MeshScopeId, NostrProfile, NostrRelayUrlPolicy, PublishMode, ReticulumAgentEndpoint, - ReticulumBehavior, ReticulumProfile, SatisfactionPolicy, TargetPolicy, TargetSet, - TransportProfile, -}; -use crate::{RadrootsSdkError, SDK_TRANSPORT_TARGET_MAX_COUNT}; -use radroots_transport::{RadrootsTransportError, Target, TransportId}; -use radroots_transport_reticulum::RADROOTS_RETICULUM_ENDPOINT_URI; - -use crate::serializer_failure::assert_struct_serialize_error_paths; - -#[test] -fn publish_mode_and_ack_policy_serialize_explicit_product_contracts() { - assert_eq!( - serde_json::to_value(PublishMode::DryRun).expect("json"), - serde_json::json!("dry_run") - ); - assert_eq!( - serde_json::to_value(PublishMode::EnqueueOnly).expect("json"), - serde_json::json!("enqueue_only") - ); - assert_eq!( - serde_json::to_value(PublishMode::EnqueueAndPublish).expect("json"), - serde_json::json!("enqueue_and_publish") - ); - assert_eq!( - serde_json::to_value(SatisfactionPolicy::NoWait).expect("json"), - serde_json::json!("no_wait") - ); - assert_eq!( - serde_json::to_value(SatisfactionPolicy::AnyAccepted).expect("json"), - serde_json::json!("any_accepted") - ); - assert_eq!( - serde_json::to_value(SatisfactionPolicy::AllAccepted).expect("json"), - serde_json::json!("all_accepted") - ); - assert_eq!( - serde_json::to_value(SatisfactionPolicy::quorum_accepted(2).expect("satisfaction policy")) - .expect("json"), - serde_json::json!({ "quorum_accepted": { "threshold": 2 } }) - ); - assert_eq!( - serde_json::to_value(SatisfactionPolicy::AnyDelivered).expect("json"), - serde_json::json!("any_delivered") - ); - assert_eq!( - serde_json::to_value(SatisfactionPolicy::AllDelivered).expect("json"), - serde_json::json!("all_delivered") - ); - assert_eq!( - serde_json::to_value(SatisfactionPolicy::quorum_delivered(3).expect("satisfaction policy")) - .expect("json"), - serde_json::json!({ "quorum_delivered": { "threshold": 3 } }) - ); - assert_eq!( - serde_json::to_value( - SatisfactionPolicy::required_accepted_targets(["a".repeat(64)]) - .expect("satisfaction policy") - ) - .expect("json"), - serde_json::json!({ - "required_accepted_targets": { - "target_fingerprints": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] - } - }) - ); - assert!(matches!( - SatisfactionPolicy::quorum_accepted(0), - Err(RadrootsSdkError::InvalidRequest { ref message }) - if message == "satisfaction policy threshold must require at least one target" - )); -} - -fn is_local_ws_relay(value: &str) -> bool { - let Some(rest) = value.strip_prefix("ws://") else { - return false; - }; - let authority = rest - .split_once('/') - .map(|(authority, _)| authority) - .unwrap_or(rest); - let host = relay_authority_host(authority); - matches!(host.as_deref(), Some("localhost" | "127.0.0.1" | "[::1]")) -} - -fn relay_authority_host(authority: &str) -> Option<String> { - if let Some(after_open) = authority.strip_prefix('[') { - let close_index = after_open.find(']')?; - return Some(format!("[{}]", &after_open[..close_index])); - } - Some( - authority - .split_once(':') - .map(|(host, _)| host) - .unwrap_or(authority) - .to_owned(), - ) -} - -#[test] -fn transport_profile_policy_serializes_as_kind_only() { - let transport_profile_policy = TargetPolicy::default_profile(); - assert_eq!( - serde_json::to_value(&transport_profile_policy).expect("json"), - serde_json::json!({ "kind": "default_profile" }) - ); - assert_struct_serialize_error_paths(&transport_profile_policy, 1); -} - -#[test] -fn target_set_accessors_and_configured_relays_cover_empty_paths() { - assert!( - TransportProfile::local_only() - .configured_nostr_relay_urls() - .is_empty() - ); - - let targets = TargetSet::nostr_relays( - ["wss://relay-a.example.com", "wss://relay-b.example.com"], - NostrRelayUrlPolicy::Public, - ) - .expect("targets"); - - assert_eq!(targets.len(), 2); - assert!(!targets.is_empty()); - assert_struct_serialize_error_paths(&targets, 2); - assert_struct_serialize_error_paths(&TargetPolicy::explicit(targets.clone()), 3); - let targets_json = serde_json::to_value(&targets).expect("targets json"); - assert_eq!( - targets_json["targets"].as_array().expect("targets").len(), - 2 - ); - assert_eq!( - targets_json["canonical_targets"] - .as_array() - .expect("canonical targets") - .len(), - 2 - ); - assert_eq!( - targets.nostr_relay_urls(), - vec![ - "wss://relay-a.example.com".to_owned(), - "wss://relay-b.example.com".to_owned() - ] - ); - - assert_eq!( - TargetPolicy::try_nostr_relays( - vec!["wss://relay-c.example.com".to_owned()], - NostrRelayUrlPolicy::Public, - ) - .expect("explicit policy"), - TargetPolicy::Explicit( - TargetSet::nostr_relays(["wss://relay-c.example.com"], NostrRelayUrlPolicy::Public) - .expect("target set"), - ) - ); - assert_eq!( - serde_json::to_value( - TargetPolicy::try_nostr_relays( - vec!["wss://relay-c.example.com".to_owned()], - NostrRelayUrlPolicy::Public, - ) - .expect("trade explicit policy") - ) - .expect("trade policy json"), - serde_json::json!({ - "kind": "explicit", - "targets": [{ - "kind": "nostr", - "uri": "wss://relay-c.example.com", - "scope": null, - "label": null, - "fingerprint": "ec4b5005dd1fcf0d949045e3d5524f9a6a95209ecc888f582ae2e9bf69e5b8e6" - }], - "canonical_targets": ["ec4b5005dd1fcf0d949045e3d5524f9a6a95209ecc888f582ae2e9bf69e5b8e6"] - }) - ); - - let nostr_profile = TransportProfile::nostr( - NostrProfile::new(["wss://relay-d.example.com"], NostrRelayUrlPolicy::Public) - .expect("Nostr profile"), - ); - assert_eq!( - nostr_profile.configured_nostr_relay_urls(), - vec!["wss://relay-d.example.com".to_owned()] - ); -} - -#[test] -fn target_sets_reject_duplicate_transport_fingerprints() { - let duplicate_relays = TargetSet::nostr_relays( - [ - "wss://relay-a.example.com/path", - "WSS://RELAY-A.EXAMPLE.COM/path", - ], - NostrRelayUrlPolicy::Public, - ) - .expect_err("duplicate relays"); - - assert!(matches!( - duplicate_relays, - RadrootsSdkError::Transport { ref message } - if message == "transport target set contains duplicate fingerprints" - )); - - let first = - Target::new(TransportId::NOSTR, "wss://relay-a.example.com/path").expect("first target"); - let second = - Target::new(TransportId::NOSTR, "WSS://RELAY-A.EXAMPLE.COM/path").expect("second target"); - let duplicate_targets = - TargetSet::transport_targets(vec![first, second]).expect_err("duplicate targets"); - - assert!(matches!( - duplicate_targets, - RadrootsSdkError::Transport { ref message } - if message == "transport target set contains duplicate fingerprints" - )); -} - -#[test] -fn reticulum_profile_uses_canonical_endpoint_and_behavior_names() { - let profile = ReticulumProfile::deferred_until_implemented(); - - assert_eq!(profile.endpoint_uri(), RADROOTS_RETICULUM_ENDPOINT_URI); - assert_eq!( - profile.behavior(), - ReticulumBehavior::RejectDeliveryAttempts - ); - assert_eq!( - ReticulumBehavior::RejectDeliveryAttempts.as_str(), - "reject_delivery_attempts" - ); - assert_eq!( - ReticulumBehavior::DeferDeliveryPlans.as_str(), - "defer_delivery_plans" - ); - assert_eq!( - serde_json::to_value(profile).expect("profile json"), - serde_json::json!({ - "endpoint_uri": "reticulum:local", - "scope": "local", - "agent_endpoint": null, - "behavior": "reject_delivery_attempts" - }) - ); -} - -#[test] -fn reticulum_profile_preserves_explicit_scope_and_agent_endpoint() { - let profile = ReticulumProfile::deferred_until_implemented() - .with_scope(MeshScopeId::parse("farmers_market").expect("scope")) - .with_agent_endpoint( - ReticulumAgentEndpoint::parse("reticulum-agent:local").expect("agent endpoint"), - ); - - assert_eq!(profile.scope().as_str(), "farmers_market"); - assert_eq!( - profile.agent_endpoint().expect("agent endpoint").as_str(), - "reticulum-agent:local" - ); - assert_eq!( - serde_json::to_value(profile).expect("profile json"), - serde_json::json!({ - "endpoint_uri": "reticulum:local", - "scope": "farmers_market", - "agent_endpoint": "reticulum-agent:local", - "behavior": "reject_delivery_attempts" - }) - ); -} - -#[test] -fn reticulum_agent_endpoint_rejects_non_agent_endpoint_families() { - for invalid in [ - "", - "reticulum-agent:", - " reticulum-agent:local", - "reticulum-agent:local ", - "RETICULUM-AGENT:local", - "reticulum:local", - "https://reticulum.example.com", - "ws://127.0.0.1:9735", - ] { - assert!(matches!( - ReticulumAgentEndpoint::parse(invalid), - Err(RadrootsSdkError::InvalidRequest { ref message }) - if message == "Reticulum agent endpoint is invalid" - )); - } -} - -#[test] -fn explicit_target_sets_reject_noncanonical_reticulum_endpoints() { - for invalid in [" reticulum:local", "reticulum:local "] { - assert_eq!( - Target::new(TransportId::RETICULUM, invalid) - .expect_err("syntactically invalid transport target"), - RadrootsTransportError::InvalidTargetUri - ); - } - - for noncanonical in [ - "RETICULUM:deferred-until-implemented".to_owned(), - "reticulum:Preview-Unavailable".to_owned(), - ["reticulum:", "pre", "view"].concat(), - "reticulum:local-alt".to_owned(), - "reticulum:custom".to_owned(), - ] { - let target = Target::new(TransportId::RETICULUM, noncanonical.as_str()) - .expect("transport-neutral target"); - assert!(matches!( - TargetSet::transport_targets(vec![target]), - Err(RadrootsSdkError::InvalidRequest { ref message }) - if message == "Reticulum endpoint must be reticulum:local" - )); - } -} - -#[test] -fn normalized_relays_reject_empty_and_over_limit_sets() { - assert!(matches!( - TargetSet::nostr_relays(Vec::<String>::new(), NostrRelayUrlPolicy::Public), - Err(RadrootsSdkError::EmptyTransportTargets { .. }) - )); - - let too_many = (0..=SDK_TRANSPORT_TARGET_MAX_COUNT) - .map(|index| format!("wss://relay-{index}.example.com")) - .collect::<Vec<_>>(); - assert!(matches!( - TargetSet::nostr_relays(too_many, NostrRelayUrlPolicy::Public), - Err(RadrootsSdkError::TransportTargetLimitExceeded { actual, .. }) - if actual == SDK_TRANSPORT_TARGET_MAX_COUNT + 1 - )); -} - -#[test] -fn local_ws_authority_parser_handles_ipv6_ports_and_non_ws_values() { - assert!(is_local_ws_relay("ws://localhost:8080/path")); - assert!(is_local_ws_relay("ws://127.0.0.1:8080")); - assert!(is_local_ws_relay("ws://[::1]:8080")); - assert!(!is_local_ws_relay("wss://relay.example.com")); - assert!(!is_local_ws_relay("ws://relay.example.com")); - assert!(matches!( - TargetSet::nostr_relays(["ws://relay.example.com"], NostrRelayUrlPolicy::Localhost), - Err(RadrootsSdkError::InvalidRelayUrl { reason, .. }) - if reason.contains("localhost") - )); - assert!(matches!( - TargetSet::nostr_relays(["ws://relay.example.com"], NostrRelayUrlPolicy::Public), - Err(RadrootsSdkError::InvalidRelayUrl { reason, .. }) - if reason.contains("localhost") - )); - assert_eq!(relay_authority_host("[::1]:8080"), Some("[::1]".to_owned())); - assert_eq!( - relay_authority_host("relay.example.com:443"), - Some("relay.example.com".to_owned()) - ); - assert_eq!(relay_authority_host("[::1"), None); -}