commit 58fa0ada8e0bb003cbc86e89a3b6ec1afebaeb38
parent 32d24adbeb0f874c1f242be47a6f3a49c53ccc4d
Author: triesap <tyson@radroots.org>
Date: Thu, 30 Jul 2026 16:17:35 +0000
transport: define target endpoint and fingerprint types
- promote canonical endpoint scope label target and fingerprint value names
- bound endpoint metadata and target sets at explicit public limits
- preserve deterministic fingerprints and reject duplicate target identity
- retain hidden predecessor aliases for the ordered workspace cutover
Diffstat:
8 files changed, 290 insertions(+), 64 deletions(-)
diff --git a/crates/transport/src/endpoint.rs b/crates/transport/src/endpoint.rs
@@ -1 +1,12 @@
//! Transport endpoint identity and validation.
+
+pub use crate::target::{EndpointUri, TargetLabel, TargetScope};
+
+/// Maximum encoded endpoint URI length.
+pub const ENDPOINT_URI_MAX_BYTES: usize = 2_048;
+
+/// Maximum encoded target scope length.
+pub const TARGET_SCOPE_MAX_BYTES: usize = 128;
+
+/// Maximum encoded human-readable target label length.
+pub const TARGET_LABEL_MAX_BYTES: usize = 128;
diff --git a/crates/transport/src/error.rs b/crates/transport/src/error.rs
@@ -15,6 +15,7 @@ pub enum RadrootsTransportError {
EmptyTargetLabel,
InvalidTargetLabel,
EmptyTargetSet,
+ TargetSetTooLarge,
DuplicateTargetFingerprint,
InvalidTargetFingerprint,
InvalidSatisfactionPolicy,
@@ -55,6 +56,7 @@ impl fmt::Display for RadrootsTransportError {
Self::EmptyTargetLabel => f.write_str("transport target label is empty"),
Self::InvalidTargetLabel => f.write_str("transport target label is invalid"),
Self::EmptyTargetSet => f.write_str("transport target set is empty"),
+ Self::TargetSetTooLarge => f.write_str("transport target set exceeds its item limit"),
Self::DuplicateTargetFingerprint => {
f.write_str("transport target set contains duplicate fingerprints")
}
diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs
@@ -52,6 +52,7 @@ pub use status::{
pub use target::{
RadrootsTransportMeshScopeId, RadrootsTransportTarget, RadrootsTransportTargetFingerprint,
RadrootsTransportTargetLabel, RadrootsTransportTargetSet, RadrootsTransportTargetUri,
+ TARGET_SET_MAX_ITEMS, Target, TargetSet,
};
pub use transport::{
RadrootsTransport, RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest,
diff --git a/crates/transport/src/target.rs b/crates/transport/src/target.rs
@@ -1,19 +1,25 @@
use crate::{
RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_SCOPE_ID, RadrootsTransportError,
RadrootsTransportKind,
+ endpoint::{ENDPOINT_URI_MAX_BYTES, TARGET_LABEL_MAX_BYTES, TARGET_SCOPE_MAX_BYTES},
};
use alloc::collections::BTreeSet;
use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use core::net::Ipv6Addr;
+use core::str::FromStr;
use sha2::{Digest, Sha256};
+/// Maximum number of targets in one operation.
+pub const TARGET_SET_MAX_ITEMS: usize = 64;
+
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
-pub struct RadrootsTransportTargetUri(String);
+/// Canonical transport endpoint URI.
+pub struct EndpointUri(String);
-impl RadrootsTransportTargetUri {
+impl EndpointUri {
pub fn parse(raw: impl AsRef<str>) -> Result<Self, RadrootsTransportError> {
let canonical = canonicalize_uri(raw.as_ref())?;
Ok(Self(canonical))
@@ -29,14 +35,36 @@ impl RadrootsTransportTargetUri {
}
}
-impl core::fmt::Display for RadrootsTransportTargetUri {
+impl core::fmt::Display for EndpointUri {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.0)
}
}
+impl AsRef<str> for EndpointUri {
+ fn as_ref(&self) -> &str {
+ self.as_str()
+ }
+}
+
+impl FromStr for EndpointUri {
+ type Err = RadrootsTransportError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ Self::parse(value)
+ }
+}
+
+impl TryFrom<&str> for EndpointUri {
+ type Error = RadrootsTransportError;
+
+ fn try_from(value: &str) -> Result<Self, Self::Error> {
+ Self::parse(value)
+ }
+}
+
#[cfg(feature = "serde")]
-impl<'de> serde::Deserialize<'de> for RadrootsTransportTargetUri {
+impl<'de> serde::Deserialize<'de> for EndpointUri {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
@@ -54,15 +82,17 @@ impl<'de> serde::Deserialize<'de> for RadrootsTransportTargetUri {
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
-pub struct RadrootsTransportMeshScopeId(String);
+/// Optional transport-neutral target scope.
+pub struct TargetScope(String);
-impl RadrootsTransportMeshScopeId {
+impl TargetScope {
pub fn parse(raw: impl AsRef<str>) -> Result<Self, RadrootsTransportError> {
let value = raw.as_ref();
if value.is_empty() {
return Err(RadrootsTransportError::EmptyTargetScope);
}
- if value != value.trim()
+ if value.len() > TARGET_SCOPE_MAX_BYTES
+ || value != value.trim()
|| value
.chars()
.any(|ch| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')))
@@ -81,14 +111,36 @@ impl RadrootsTransportMeshScopeId {
}
}
-impl core::fmt::Display for RadrootsTransportMeshScopeId {
+impl core::fmt::Display for TargetScope {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.0)
}
}
+impl AsRef<str> for TargetScope {
+ fn as_ref(&self) -> &str {
+ self.as_str()
+ }
+}
+
+impl FromStr for TargetScope {
+ type Err = RadrootsTransportError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ Self::parse(value)
+ }
+}
+
+impl TryFrom<&str> for TargetScope {
+ type Error = RadrootsTransportError;
+
+ fn try_from(value: &str) -> Result<Self, Self::Error> {
+ Self::parse(value)
+ }
+}
+
#[cfg(feature = "serde")]
-impl<'de> serde::Deserialize<'de> for RadrootsTransportMeshScopeId {
+impl<'de> serde::Deserialize<'de> for TargetScope {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
@@ -100,15 +152,17 @@ impl<'de> serde::Deserialize<'de> for RadrootsTransportMeshScopeId {
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
-pub struct RadrootsTransportTargetLabel(String);
+/// Optional human-readable target label excluded from target identity.
+pub struct TargetLabel(String);
-impl RadrootsTransportTargetLabel {
+impl TargetLabel {
pub fn parse(raw: impl AsRef<str>) -> Result<Self, RadrootsTransportError> {
- let trimmed = raw.as_ref().trim();
+ let raw = raw.as_ref();
+ let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(RadrootsTransportError::EmptyTargetLabel);
}
- if trimmed.chars().any(char::is_control) {
+ if raw.len() > TARGET_LABEL_MAX_BYTES || trimmed.chars().any(char::is_control) {
return Err(RadrootsTransportError::InvalidTargetLabel);
}
Ok(Self(trimmed.to_string()))
@@ -119,14 +173,36 @@ impl RadrootsTransportTargetLabel {
}
}
-impl core::fmt::Display for RadrootsTransportTargetLabel {
+impl core::fmt::Display for TargetLabel {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.0)
}
}
+impl AsRef<str> for TargetLabel {
+ fn as_ref(&self) -> &str {
+ self.as_str()
+ }
+}
+
+impl FromStr for TargetLabel {
+ type Err = RadrootsTransportError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ Self::parse(value)
+ }
+}
+
+impl TryFrom<&str> for TargetLabel {
+ type Error = RadrootsTransportError;
+
+ fn try_from(value: &str) -> Result<Self, Self::Error> {
+ Self::parse(value)
+ }
+}
+
#[cfg(feature = "serde")]
-impl<'de> serde::Deserialize<'de> for RadrootsTransportTargetLabel {
+impl<'de> serde::Deserialize<'de> for TargetLabel {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
@@ -144,13 +220,14 @@ impl<'de> serde::Deserialize<'de> for RadrootsTransportTargetLabel {
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
-pub struct RadrootsTransportTargetFingerprint(String);
+/// Deterministic SHA-256 fingerprint of canonical target identity fields.
+pub struct TargetFingerprint(String);
-impl RadrootsTransportTargetFingerprint {
+impl TargetFingerprint {
pub fn from_target(
kind: &RadrootsTransportKind,
- uri: &RadrootsTransportTargetUri,
- scope: Option<&RadrootsTransportMeshScopeId>,
+ uri: &EndpointUri,
+ scope: Option<&TargetScope>,
) -> Self {
let mut hasher = Sha256::new();
hasher.update(kind.canonical_label().as_bytes());
@@ -187,14 +264,36 @@ fn hex_encode(bytes: &[u8]) -> String {
out
}
-impl core::fmt::Display for RadrootsTransportTargetFingerprint {
+impl core::fmt::Display for TargetFingerprint {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.0)
}
}
+impl AsRef<str> for TargetFingerprint {
+ fn as_ref(&self) -> &str {
+ self.as_str()
+ }
+}
+
+impl FromStr for TargetFingerprint {
+ type Err = RadrootsTransportError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ Self::parse(value)
+ }
+}
+
+impl TryFrom<&str> for TargetFingerprint {
+ type Error = RadrootsTransportError;
+
+ fn try_from(value: &str) -> Result<Self, Self::Error> {
+ Self::parse(value)
+ }
+}
+
#[cfg(feature = "serde")]
-impl<'de> serde::Deserialize<'de> for RadrootsTransportTargetFingerprint {
+impl<'de> serde::Deserialize<'de> for TargetFingerprint {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
@@ -212,15 +311,16 @@ impl<'de> serde::Deserialize<'de> for RadrootsTransportTargetFingerprint {
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsTransportTarget {
+/// Validated transport-neutral target.
+pub struct Target {
kind: RadrootsTransportKind,
- uri: RadrootsTransportTargetUri,
- scope: Option<RadrootsTransportMeshScopeId>,
- label: Option<RadrootsTransportTargetLabel>,
- fingerprint: RadrootsTransportTargetFingerprint,
+ uri: EndpointUri,
+ scope: Option<TargetScope>,
+ label: Option<TargetLabel>,
+ fingerprint: TargetFingerprint,
}
-impl RadrootsTransportTarget {
+impl Target {
pub fn new(
kind: RadrootsTransportKind,
uri: impl AsRef<str>,
@@ -234,8 +334,8 @@ impl RadrootsTransportTarget {
pub fn nostr_relay_with_metadata(
uri: impl AsRef<str>,
- scope: Option<RadrootsTransportMeshScopeId>,
- label: Option<RadrootsTransportTargetLabel>,
+ scope: Option<TargetScope>,
+ label: Option<TargetLabel>,
) -> Result<Self, RadrootsTransportError> {
Self::new_with_metadata(RadrootsTransportKind::Nostr, uri, scope, label)
}
@@ -246,8 +346,8 @@ impl RadrootsTransportTarget {
pub fn reticulum_with_metadata(
uri: impl AsRef<str>,
- scope: Option<RadrootsTransportMeshScopeId>,
- label: Option<RadrootsTransportTargetLabel>,
+ scope: Option<TargetScope>,
+ label: Option<TargetLabel>,
) -> Result<Self, RadrootsTransportError> {
Self::new_with_metadata(RadrootsTransportKind::Reticulum, uri, scope, label)
}
@@ -258,8 +358,8 @@ impl RadrootsTransportTarget {
pub fn local_with_metadata(
uri: impl AsRef<str>,
- scope: Option<RadrootsTransportMeshScopeId>,
- label: Option<RadrootsTransportTargetLabel>,
+ scope: Option<TargetScope>,
+ label: Option<TargetLabel>,
) -> Result<Self, RadrootsTransportError> {
Self::new_with_metadata(RadrootsTransportKind::Local, uri, scope, label)
}
@@ -267,20 +367,19 @@ impl RadrootsTransportTarget {
pub fn new_with_metadata(
kind: RadrootsTransportKind,
uri: impl AsRef<str>,
- scope: Option<RadrootsTransportMeshScopeId>,
- label: Option<RadrootsTransportTargetLabel>,
+ scope: Option<TargetScope>,
+ label: Option<TargetLabel>,
) -> Result<Self, RadrootsTransportError> {
let raw_uri = uri.as_ref();
let uri = match kind {
- RadrootsTransportKind::Nostr => RadrootsTransportTargetUri::parse_nostr_relay(raw_uri)?,
- _ => RadrootsTransportTargetUri::parse(raw_uri)?,
+ RadrootsTransportKind::Nostr => EndpointUri::parse_nostr_relay(raw_uri)?,
+ _ => EndpointUri::parse(raw_uri)?,
};
if kind == RadrootsTransportKind::Reticulum && raw_uri != RADROOTS_RETICULUM_ENDPOINT_URI {
return Err(RadrootsTransportError::InvalidTargetUri);
}
let scope = scope.or_else(|| default_scope_for_kind(&kind));
- let fingerprint =
- RadrootsTransportTargetFingerprint::from_target(&kind, &uri, scope.as_ref());
+ let fingerprint = TargetFingerprint::from_target(&kind, &uri, scope.as_ref());
Ok(Self {
kind,
uri,
@@ -294,19 +393,19 @@ impl RadrootsTransportTarget {
&self.kind
}
- pub fn uri(&self) -> &RadrootsTransportTargetUri {
+ pub fn uri(&self) -> &EndpointUri {
&self.uri
}
- pub fn scope(&self) -> Option<&RadrootsTransportMeshScopeId> {
+ pub fn scope(&self) -> Option<&TargetScope> {
self.scope.as_ref()
}
- pub fn label(&self) -> Option<&RadrootsTransportTargetLabel> {
+ pub fn label(&self) -> Option<&TargetLabel> {
self.label.as_ref()
}
- pub fn fingerprint(&self) -> &RadrootsTransportTargetFingerprint {
+ pub fn fingerprint(&self) -> &TargetFingerprint {
&self.fingerprint
}
}
@@ -314,7 +413,7 @@ impl RadrootsTransportTarget {
#[cfg(feature = "serde")]
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
-struct RadrootsTransportTargetWire {
+struct TargetWire {
kind: RadrootsTransportKind,
uri: String,
scope: Option<String>,
@@ -323,21 +422,21 @@ struct RadrootsTransportTargetWire {
}
#[cfg(feature = "serde")]
-impl<'de> serde::Deserialize<'de> for RadrootsTransportTarget {
+impl<'de> serde::Deserialize<'de> for Target {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
- let wire = RadrootsTransportTargetWire::deserialize(deserializer)?;
+ let wire = TargetWire::deserialize(deserializer)?;
let scope = wire
.scope
- .map(RadrootsTransportMeshScopeId::parse)
+ .map(TargetScope::parse)
.transpose()
.map_err(serde::de::Error::custom)?;
let label = wire
.label
.map(|label| {
- let parsed = RadrootsTransportTargetLabel::parse(label.as_str())?;
+ let parsed = TargetLabel::parse(label.as_str())?;
if parsed.as_str() != label {
return Err(RadrootsTransportError::InvalidTargetLabel);
}
@@ -345,7 +444,7 @@ impl<'de> serde::Deserialize<'de> for RadrootsTransportTarget {
})
.transpose()
.map_err(serde::de::Error::custom)?;
- let fingerprint = RadrootsTransportTargetFingerprint::parse(wire.fingerprint.as_str())
+ let fingerprint = TargetFingerprint::parse(wire.fingerprint.as_str())
.map_err(serde::de::Error::custom)?;
if fingerprint.as_str() != wire.fingerprint {
return Err(serde::de::Error::custom(
@@ -368,21 +467,25 @@ impl<'de> serde::Deserialize<'de> for RadrootsTransportTarget {
}
}
-fn default_scope_for_kind(kind: &RadrootsTransportKind) -> Option<RadrootsTransportMeshScopeId> {
- (*kind == RadrootsTransportKind::Reticulum).then(RadrootsTransportMeshScopeId::local_reticulum)
+fn default_scope_for_kind(kind: &RadrootsTransportKind) -> Option<TargetScope> {
+ (*kind == RadrootsTransportKind::Reticulum).then(TargetScope::local_reticulum)
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsTransportTargetSet {
- targets: Vec<RadrootsTransportTarget>,
+/// Bounded non-empty set of targets with unique fingerprints.
+pub struct TargetSet {
+ targets: Vec<Target>,
}
-impl RadrootsTransportTargetSet {
- pub fn new(targets: Vec<RadrootsTransportTarget>) -> Result<Self, RadrootsTransportError> {
+impl TargetSet {
+ pub fn new(targets: Vec<Target>) -> Result<Self, RadrootsTransportError> {
if targets.is_empty() {
return Err(RadrootsTransportError::EmptyTargetSet);
}
+ if targets.len() > TARGET_SET_MAX_ITEMS {
+ return Err(RadrootsTransportError::TargetSetTooLarge);
+ }
let mut fingerprints = BTreeSet::new();
for target in &targets {
if !fingerprints.insert(target.fingerprint.as_str().to_string()) {
@@ -392,7 +495,7 @@ impl RadrootsTransportTargetSet {
Ok(Self { targets })
}
- pub fn targets(&self) -> &[RadrootsTransportTarget] {
+ pub fn targets(&self) -> &[Target] {
&self.targets
}
@@ -408,17 +511,17 @@ impl RadrootsTransportTargetSet {
#[cfg(feature = "serde")]
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
-struct RadrootsTransportTargetSetWire {
- targets: Vec<RadrootsTransportTarget>,
+struct TargetSetWire {
+ targets: Vec<Target>,
}
#[cfg(feature = "serde")]
-impl<'de> serde::Deserialize<'de> for RadrootsTransportTargetSet {
+impl<'de> serde::Deserialize<'de> for TargetSet {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
- let wire = RadrootsTransportTargetSetWire::deserialize(deserializer)?;
+ let wire = TargetSetWire::deserialize(deserializer)?;
Self::new(wire.targets).map_err(serde::de::Error::custom)
}
}
@@ -431,6 +534,9 @@ fn canonicalize_uri(raw: &str) -> Result<String, RadrootsTransportError> {
if raw != trimmed {
return Err(RadrootsTransportError::InvalidTargetUri);
}
+ if trimmed.len() > ENDPOINT_URI_MAX_BYTES {
+ return Err(RadrootsTransportError::InvalidTargetUri);
+ }
if trimmed
.chars()
.any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace())
@@ -475,6 +581,9 @@ fn canonicalize_nostr_relay_uri(raw: &str) -> Result<String, RadrootsTransportEr
if raw != trimmed {
return Err(RadrootsTransportError::InvalidTargetUri);
}
+ if trimmed.len() > ENDPOINT_URI_MAX_BYTES {
+ return Err(RadrootsTransportError::InvalidTargetUri);
+ }
if trimmed
.chars()
.any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace())
@@ -736,3 +845,27 @@ fn upper_hex_digit(byte: u8) -> bool {
fn is_local_ws_relay_host(host: &str) -> bool {
matches!(host, "localhost" | "127.0.0.1" | "[::1]")
}
+
+/// Compatibility endpoint URI name retained until the workspace cutover.
+#[doc(hidden)]
+pub type RadrootsTransportTargetUri = EndpointUri;
+
+/// Compatibility target scope name retained until the workspace cutover.
+#[doc(hidden)]
+pub type RadrootsTransportMeshScopeId = TargetScope;
+
+/// Compatibility target label name retained until the workspace cutover.
+#[doc(hidden)]
+pub type RadrootsTransportTargetLabel = TargetLabel;
+
+/// Compatibility target fingerprint name retained until the workspace cutover.
+#[doc(hidden)]
+pub type RadrootsTransportTargetFingerprint = TargetFingerprint;
+
+/// Compatibility target name retained until the workspace cutover.
+#[doc(hidden)]
+pub type RadrootsTransportTarget = Target;
+
+/// Compatibility target-set name retained until the workspace cutover.
+#[doc(hidden)]
+pub type RadrootsTransportTargetSet = TargetSet;
diff --git a/crates/transport/tests/source_boundary.rs b/crates/transport/tests/source_boundary.rs
@@ -594,6 +594,7 @@ fn transport_target_identity_sources_reject_silent_dedupe() {
for required in [
"let mut fingerprints = BTreeSet::new();",
"RadrootsTransportError::DuplicateTargetFingerprint",
+ "targets.len() > TARGET_SET_MAX_ITEMS",
] {
assert!(
transport_source.contains(required),
@@ -602,8 +603,8 @@ fn transport_target_identity_sources_reject_silent_dedupe() {
}
let target_struct = source_between(
transport_source.as_str(),
- "pub struct RadrootsTransportTarget {",
- "impl RadrootsTransportTarget {",
+ "pub struct Target {",
+ "impl Target {",
);
for forbidden in [
"pub kind:",
@@ -618,8 +619,8 @@ fn transport_target_identity_sources_reject_silent_dedupe() {
);
}
for required in [
- "impl<'de> serde::Deserialize<'de> for RadrootsTransportTarget",
- "impl<'de> serde::Deserialize<'de> for RadrootsTransportTargetSet",
+ "impl<'de> serde::Deserialize<'de> for Target",
+ "impl<'de> serde::Deserialize<'de> for TargetSet",
] {
assert!(
transport_source.contains(required),
diff --git a/crates/transport/tests/target_contract.rs b/crates/transport/tests/target_contract.rs
@@ -0,0 +1,73 @@
+use core::str::FromStr;
+
+use radroots_transport::{
+ Error, TARGET_SET_MAX_ITEMS, Target, TargetSet, TransportId,
+ endpoint::{
+ ENDPOINT_URI_MAX_BYTES, EndpointUri, TARGET_LABEL_MAX_BYTES, TARGET_SCOPE_MAX_BYTES,
+ TargetLabel, TargetScope,
+ },
+ target::TargetFingerprint,
+};
+
+#[test]
+fn endpoint_scope_and_label_values_are_canonical_and_bounded() {
+ let endpoint = EndpointUri::from_str("MESH://Node.Example/path").expect("endpoint");
+ assert_eq!(endpoint.as_ref(), "mesh://node.example/path");
+ assert_eq!(EndpointUri::try_from(endpoint.as_str()).unwrap(), endpoint);
+ assert_eq!(
+ EndpointUri::parse("a".repeat(ENDPOINT_URI_MAX_BYTES + 1)).unwrap_err(),
+ Error::InvalidTargetUri
+ );
+
+ let scope = TargetScope::from_str("farm_1.alpha-beta").expect("scope");
+ assert_eq!(scope.as_ref(), "farm_1.alpha-beta");
+ assert_eq!(TargetScope::try_from(scope.as_str()).unwrap(), scope);
+ assert_eq!(
+ TargetScope::parse("a".repeat(TARGET_SCOPE_MAX_BYTES + 1)).unwrap_err(),
+ Error::InvalidTargetScope
+ );
+
+ let label = TargetLabel::from_str(" Relay One ").expect("label");
+ assert_eq!(label.as_ref(), "Relay One");
+ assert_eq!(TargetLabel::try_from(label.as_str()).unwrap(), label);
+ assert_eq!(
+ TargetLabel::parse("a".repeat(TARGET_LABEL_MAX_BYTES + 1)).unwrap_err(),
+ Error::InvalidTargetLabel
+ );
+}
+
+#[test]
+fn fingerprints_and_target_sets_are_deterministic_unique_and_bounded() {
+ let target = Target::new(TransportId::LOCAL, "local:node-0").expect("target");
+ let fingerprint =
+ TargetFingerprint::from_str(target.fingerprint().as_str()).expect("target fingerprint");
+ assert_eq!(&fingerprint, target.fingerprint());
+ assert_eq!(
+ TargetFingerprint::try_from(fingerprint.as_str()).unwrap(),
+ fingerprint
+ );
+
+ let targets = (0..TARGET_SET_MAX_ITEMS)
+ .map(|index| {
+ Target::new(TransportId::LOCAL, format!("local:node-{index}")).expect("bounded target")
+ })
+ .collect();
+ let target_set = TargetSet::new(targets).expect("maximum target set");
+ assert_eq!(target_set.len(), TARGET_SET_MAX_ITEMS);
+
+ let oversized = (0..=TARGET_SET_MAX_ITEMS)
+ .map(|index| {
+ Target::new(TransportId::LOCAL, format!("local:oversized-{index}"))
+ .expect("oversized target member")
+ })
+ .collect();
+ assert_eq!(
+ TargetSet::new(oversized).unwrap_err(),
+ Error::TargetSetTooLarge
+ );
+
+ assert_eq!(
+ TargetSet::new(vec![target.clone(), target]).unwrap_err(),
+ Error::DuplicateTargetFingerprint
+ );
+}
diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs
@@ -644,6 +644,10 @@ fn transport_errors_have_stable_display_strings() {
"transport target set is empty",
),
(
+ RadrootsTransportError::TargetSetTooLarge,
+ "transport target set exceeds its item limit",
+ ),
+ (
RadrootsTransportError::DuplicateTargetFingerprint,
"transport target set contains duplicate fingerprints",
),
diff --git a/crates/transport_nostr/src/outbox.rs b/crates/transport_nostr/src/outbox.rs
@@ -787,6 +787,7 @@ fn transport_error_to_relay_error(error: RadrootsTransportError) -> RadrootsRela
RadrootsTransportError::EmptyTargetUri
| RadrootsTransportError::InvalidTargetUri
| RadrootsTransportError::EmptyTargetSet
+ | RadrootsTransportError::TargetSetTooLarge
| RadrootsTransportError::DuplicateTargetFingerprint
| RadrootsTransportError::InvalidTargetFingerprint
| RadrootsTransportError::UnexpectedDeliveryTargetReceipt