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:
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",
),