lib

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

commit 7cb44ebba39b090a2b1e73f9b133c74f8ab0e063
parent b8ed88d8cd99f3d1c3c84ee6925b621ecf4c49bf
Author: triesap <tyson@radroots.org>
Date:   Wed,  8 Jul 2026 05:01:14 +0000

transport: reject duplicate target identities

- validate explicit publish targets by canonical transport fingerprint
- reject duplicate outbox delivery targets before plan preparation
- remove silent ordered target dedupe from delivery planning
- cover protocol and outbox duplicate rejection before persistence

Diffstat:
Mcrates/outbox/src/store.rs | 58+++++++++++++++++++++++++++++++++++++++++++---------------
Mcrates/transport_publish_protocol/src/lib.rs | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 123 insertions(+), 16 deletions(-)

diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs @@ -27,6 +27,7 @@ use serde::Serialize; use sha2::{Digest, Sha256}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions, SqliteQueryResult}; use sqlx::{Row, SqlitePool}; +use std::collections::BTreeSet; use std::path::Path; use std::str::FromStr; @@ -1255,10 +1256,11 @@ fn prepare_delivery_plan( if input.transport_profile_id.trim().is_empty() { return Err(RadrootsOutboxError::EmptyTransportProfileId); } - let targets = ordered_unique_targets(input.targets.clone()); + let targets = input.targets.clone(); if targets.is_empty() { return Err(RadrootsOutboxError::EmptyDeliveryTargets); } + validate_unique_targets(&targets)?; if targets .iter() .any(|target| target.kind == RadrootsTransportKind::Proxy) @@ -1318,6 +1320,18 @@ fn validate_delivery_target(target: &RadrootsTransportTarget) -> Result<(), Radr Ok(()) } +fn validate_unique_targets(targets: &[RadrootsTransportTarget]) -> Result<(), RadrootsOutboxError> { + let mut fingerprints = BTreeSet::new(); + for target in targets { + if !fingerprints.insert(target.fingerprint.as_str()) { + return Err(RadrootsOutboxError::Transport( + RadrootsTransportError::DuplicateTargetFingerprint, + )); + } + } + Ok(()) +} + fn initial_delivery_target_status( target: &RadrootsTransportTarget, reticulum_preview_behavior: RadrootsOutboxReticulumPreviewBehavior, @@ -2026,19 +2040,6 @@ fn event_from_signed(signed_event: &RadrootsSignedNostrEvent) -> RadrootsNostrEv } } -fn ordered_unique_targets(targets: Vec<RadrootsTransportTarget>) -> Vec<RadrootsTransportTarget> { - let mut out = Vec::new(); - for target in targets { - if !out - .iter() - .any(|existing: &RadrootsTransportTarget| existing.fingerprint == target.fingerprint) - { - out.push(target); - } - } - out -} - #[derive(Serialize)] struct OperationDigestInput<'a> { operation_kind: &'a str, @@ -2267,7 +2268,6 @@ mod tests { delivery_plan(vec![ nostr_target(NOSTR_PRIMARY_WSS), nostr_target(NOSTR_SECONDARY_WSS), - nostr_target(NOSTR_PRIMARY_WSS), ]), created_at_ms, ) @@ -2442,6 +2442,34 @@ mod tests { } #[tokio::test] + async fn enqueue_rejects_duplicate_delivery_targets_before_persistence() { + let outbox = RadrootsOutbox::open_memory().await.expect("open"); + let draft = post_draft(hex_64('a').as_str(), "duplicate targets"); + + let err = outbox + .enqueue_operation(RadrootsOutboxOperationInput::new( + "publish_post", + draft, + delivery_plan(vec![ + nostr_target(NOSTR_PRIMARY_WSS), + nostr_target(NOSTR_PRIMARY_WSS), + ]), + 1_000, + )) + .await + .expect_err("duplicate targets"); + + assert!(matches!( + err, + RadrootsOutboxError::Transport(RadrootsTransportError::DuplicateTargetFingerprint) + )); + assert_eq!(table_count(&outbox, "outbox_operations").await, 0); + assert_eq!(table_count(&outbox, "outbox_event").await, 0); + assert_eq!(table_count(&outbox, "outbox_delivery_plan").await, 0); + assert_eq!(table_count(&outbox, "outbox_delivery_target").await, 0); + } + + #[tokio::test] async fn enqueue_rejects_invalid_reticulum_targets_before_persistence() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); let draft = post_draft(hex_64('a').as_str(), "invalid reticulum"); diff --git a/crates/transport_publish_protocol/src/lib.rs b/crates/transport_publish_protocol/src/lib.rs @@ -12,7 +12,8 @@ use std::{string::String, vec::Vec}; use core::fmt; use radroots_transport::{ RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, - RadrootsTransportError, RadrootsTransportKind, + RadrootsTransportError, RadrootsTransportKind, RadrootsTransportTarget, + RadrootsTransportTargetFingerprint, }; pub const API_VERSION: &str = "radrootsd.transport_publish.v2"; @@ -42,6 +43,9 @@ pub enum TransportPublishProtocolError { EmptyEndpointUri { index: usize, }, + InvalidEndpointUri { + index: usize, + }, InvalidPreviewBehavior { index: usize, }, @@ -52,6 +56,9 @@ pub enum TransportPublishProtocolError { ExplicitProxyTarget { index: usize, }, + DuplicateTarget { + index: usize, + }, TargetLimitExceeded { max: usize, actual: usize, @@ -118,6 +125,9 @@ impl fmt::Display for TransportPublishProtocolError { Self::EmptyEndpointUri { index } => { write!(f, "transport target {index} endpoint_uri must not be empty") } + Self::InvalidEndpointUri { index } => { + write!(f, "transport target {index} endpoint_uri is invalid") + } Self::InvalidPreviewBehavior { index } => write!( f, "transport target {index} preview_behavior is only valid for Reticulum targets" @@ -131,6 +141,9 @@ impl fmt::Display for TransportPublishProtocolError { f, "transport target {index} proxy is an SDK delegation target and cannot be used as a daemon explicit target" ), + Self::DuplicateTarget { index } => { + write!(f, "transport target {index} duplicates an earlier target") + } Self::TargetLimitExceeded { max, actual } => { write!(f, "transport target count {actual} exceeds limit {max}") } @@ -285,6 +298,17 @@ impl TransportPublishTarget { } Ok(()) } + + fn fingerprint( + &self, + index: usize, + ) -> Result<RadrootsTransportTargetFingerprint, TransportPublishProtocolError> { + let transport_kind = RadrootsTransportKind::parse_canonical(self.transport_kind.as_str()) + .map_err(|error| transport_kind_error(error, index))?; + let target = RadrootsTransportTarget::new(transport_kind, self.endpoint_uri.as_str()) + .map_err(|error| target_fingerprint_error(error, index))?; + Ok(target.fingerprint) + } } fn transport_kind_error( @@ -299,6 +323,18 @@ fn transport_kind_error( } } +fn target_fingerprint_error( + error: RadrootsTransportError, + index: usize, +) -> TransportPublishProtocolError { + match error { + RadrootsTransportError::EmptyTargetUri => { + TransportPublishProtocolError::EmptyEndpointUri { index } + } + _ => TransportPublishProtocolError::InvalidEndpointUri { index }, + } +} + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -352,6 +388,7 @@ impl TransportPublishTargetPolicy { for (index, target) in targets.iter().enumerate() { target.validate(index)?; } + validate_explicit_target_uniqueness(targets)?; } Self::Nostr { relay_urls, .. } => { validate_target_limit(relay_urls.len(), max_targets)?; @@ -366,6 +403,20 @@ impl TransportPublishTargetPolicy { } } +fn validate_explicit_target_uniqueness( + targets: &[TransportPublishTarget], +) -> Result<(), TransportPublishProtocolError> { + let mut fingerprints = Vec::new(); + for (index, target) in targets.iter().enumerate() { + let fingerprint = target.fingerprint(index)?; + if fingerprints.iter().any(|existing| existing == &fingerprint) { + return Err(TransportPublishProtocolError::DuplicateTarget { index }); + } + fingerprints.push(fingerprint); + } + Ok(()) +} + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(tag = "mode", rename_all = "snake_case"))] #[derive(Clone, Debug, PartialEq, Eq)] @@ -1180,6 +1231,16 @@ mod tests { Err(TransportPublishProtocolError::EmptyEndpointUri { index: 0 }) )); + let mut invalid_endpoint = request.clone(); + invalid_endpoint.target_policy = + TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget::nostr( + "wss://relay.example.com/has space", + )]); + assert_eq!( + invalid_endpoint.validate(1), + Err(TransportPublishProtocolError::InvalidEndpointUri { index: 0 }) + ); + let mut invalid_reticulum_endpoint = request.clone(); invalid_reticulum_endpoint.target_policy = TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget { @@ -1261,6 +1322,16 @@ mod tests { Err(TransportPublishProtocolError::ExplicitProxyTarget { index: 0 }) ); + let mut duplicate_targets = request.clone(); + duplicate_targets.target_policy = TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::nostr("wss://relay.example.com/a"), + TransportPublishTarget::nostr("WSS://RELAY.EXAMPLE.COM/a"), + ]); + assert_eq!( + duplicate_targets.validate(2), + Err(TransportPublishProtocolError::DuplicateTarget { index: 1 }) + ); + let mut empty_key = request.clone(); empty_key.idempotency_key = Some(" ".to_owned()); assert_eq!( @@ -1582,6 +1653,10 @@ mod tests { "transport target 3 endpoint_uri must not be empty", ), ( + TransportPublishProtocolError::InvalidEndpointUri { index: 3 }, + "transport target 3 endpoint_uri is invalid", + ), + ( TransportPublishProtocolError::InvalidPreviewBehavior { index: 4 }, "transport target 4 preview_behavior is only valid for Reticulum targets", ), @@ -1598,6 +1673,10 @@ mod tests { "transport target count 2 exceeds limit 1", ), ( + TransportPublishProtocolError::DuplicateTarget { index: 1 }, + "transport target 1 duplicates an earlier target", + ), + ( TransportPublishProtocolError::EmptyTargetSet, "transport publish target set must not be empty", ),