commit 0932ec0b0931e87f61beee24225e6b6733d56efe
parent 0d47e5ad9c47d6cc3c196b7da38a63188d0ff311
Author: triesap <tyson@radroots.org>
Date: Mon, 27 Jul 2026 07:41:20 +0000
transport: bound target wire decoding
- reject oversized target strings through allocation-aware visitors
- bound target-set sequences before canonical identity validation
- expose the fixed fingerprint byte width as contract authority
- cover direct wrappers nested targets and one-over collections
Diffstat:
3 files changed, 200 insertions(+), 16 deletions(-)
diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs
@@ -55,8 +55,9 @@ pub use status::{
RadrootsTransportOutcomeKind, RadrootsTransportStatus,
};
pub use target::{
- RadrootsTransportMeshScopeId, RadrootsTransportTarget, RadrootsTransportTargetFingerprint,
- RadrootsTransportTargetLabel, RadrootsTransportTargetSet, RadrootsTransportTargetUri,
+ RADROOTS_TRANSPORT_TARGET_FINGERPRINT_BYTES, RadrootsTransportMeshScopeId,
+ RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetLabel,
+ RadrootsTransportTargetSet, RadrootsTransportTargetUri,
};
pub use transport::{
RadrootsTransport, RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest,
diff --git a/crates/transport/src/target.rs b/crates/transport/src/target.rs
@@ -11,6 +11,8 @@ use alloc::vec::Vec;
use core::net::Ipv6Addr;
use sha2::{Digest, Sha256};
+pub const RADROOTS_TRANSPORT_TARGET_FINGERPRINT_BYTES: usize = 64;
+
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct RadrootsTransportTargetUri(String);
@@ -53,7 +55,11 @@ impl<'de> serde::Deserialize<'de> for RadrootsTransportTargetUri {
where
D: serde::Deserializer<'de>,
{
- let raw = <String as serde::Deserialize>::deserialize(deserializer)?;
+ let raw = crate::serde_bounds::deserialize_string(
+ deserializer,
+ "target_uri",
+ RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES,
+ )?;
let parsed = Self::parse(raw.as_str()).map_err(serde::de::Error::custom)?;
if parsed.as_str() != raw {
return Err(serde::de::Error::custom(
@@ -110,7 +116,11 @@ impl<'de> serde::Deserialize<'de> for RadrootsTransportMeshScopeId {
where
D: serde::Deserializer<'de>,
{
- let raw = <String as serde::Deserialize>::deserialize(deserializer)?;
+ let raw = crate::serde_bounds::deserialize_string(
+ deserializer,
+ "target_scope",
+ RADROOTS_TRANSPORT_TARGET_SCOPE_MAX_BYTES,
+ )?;
Self::parse(raw).map_err(serde::de::Error::custom)
}
}
@@ -154,7 +164,11 @@ impl<'de> serde::Deserialize<'de> for RadrootsTransportTargetLabel {
where
D: serde::Deserializer<'de>,
{
- let raw = <String as serde::Deserialize>::deserialize(deserializer)?;
+ let raw = crate::serde_bounds::deserialize_string(
+ deserializer,
+ "target_label",
+ RADROOTS_TRANSPORT_TARGET_LABEL_MAX_BYTES,
+ )?;
let parsed = Self::parse(raw.as_str()).map_err(serde::de::Error::custom)?;
if parsed.as_str() != raw {
return Err(serde::de::Error::custom(
@@ -189,7 +203,9 @@ impl RadrootsTransportTargetFingerprint {
pub fn parse(raw: impl AsRef<str>) -> Result<Self, RadrootsTransportError> {
let raw = raw.as_ref();
- if raw.len() != 64 || !raw.bytes().all(|byte| byte.is_ascii_hexdigit()) {
+ if raw.len() != RADROOTS_TRANSPORT_TARGET_FINGERPRINT_BYTES
+ || !raw.bytes().all(|byte| byte.is_ascii_hexdigit())
+ {
return Err(RadrootsTransportError::InvalidTargetFingerprint);
}
Ok(Self(raw.to_ascii_lowercase()))
@@ -222,7 +238,11 @@ impl<'de> serde::Deserialize<'de> for RadrootsTransportTargetFingerprint {
where
D: serde::Deserializer<'de>,
{
- let raw = <String as serde::Deserialize>::deserialize(deserializer)?;
+ let raw = crate::serde_bounds::deserialize_string(
+ deserializer,
+ "target_fingerprint",
+ RADROOTS_TRANSPORT_TARGET_FINGERPRINT_BYTES,
+ )?;
let parsed = Self::parse(raw.as_str()).map_err(serde::de::Error::custom)?;
if parsed.as_str() != raw {
return Err(serde::de::Error::custom(
@@ -339,13 +359,65 @@ impl RadrootsTransportTarget {
#[serde(deny_unknown_fields)]
struct RadrootsTransportTargetWire {
kind: RadrootsTransportKind,
+ #[serde(deserialize_with = "deserialize_target_uri")]
uri: String,
+ #[serde(deserialize_with = "deserialize_target_scope")]
scope: Option<String>,
+ #[serde(deserialize_with = "deserialize_target_label")]
label: Option<String>,
+ #[serde(deserialize_with = "deserialize_target_fingerprint")]
fingerprint: String,
}
#[cfg(feature = "serde")]
+fn deserialize_target_uri<'de, D>(deserializer: D) -> Result<String, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ crate::serde_bounds::deserialize_string(
+ deserializer,
+ "target_uri",
+ RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES,
+ )
+}
+
+#[cfg(feature = "serde")]
+fn deserialize_target_scope<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ crate::serde_bounds::deserialize_option_string(
+ deserializer,
+ "target_scope",
+ RADROOTS_TRANSPORT_TARGET_SCOPE_MAX_BYTES,
+ )
+}
+
+#[cfg(feature = "serde")]
+fn deserialize_target_label<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ crate::serde_bounds::deserialize_option_string(
+ deserializer,
+ "target_label",
+ RADROOTS_TRANSPORT_TARGET_LABEL_MAX_BYTES,
+ )
+}
+
+#[cfg(feature = "serde")]
+fn deserialize_target_fingerprint<'de, D>(deserializer: D) -> Result<String, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ crate::serde_bounds::deserialize_string(
+ deserializer,
+ "target_fingerprint",
+ RADROOTS_TRANSPORT_TARGET_FINGERPRINT_BYTES,
+ )
+}
+
+#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for RadrootsTransportTarget {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
@@ -437,10 +509,23 @@ impl RadrootsTransportTargetSet {
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct RadrootsTransportTargetSetWire {
+ #[serde(deserialize_with = "deserialize_targets")]
targets: Vec<RadrootsTransportTarget>,
}
#[cfg(feature = "serde")]
+fn deserialize_targets<'de, D>(deserializer: D) -> Result<Vec<RadrootsTransportTarget>, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ crate::serde_bounds::deserialize_vec(
+ deserializer,
+ "target_count",
+ RADROOTS_TRANSPORT_TARGET_MAX_COUNT,
+ )
+}
+
+#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for RadrootsTransportTargetSet {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs
@@ -5,14 +5,14 @@ use radroots_transport::{
RADROOTS_TRANSPORT_OPAQUE_PAYLOAD_MAX_BYTES, RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES,
RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES, RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES,
RADROOTS_TRANSPORT_SIGNED_EVENT_JSON_MAX_BYTES, RADROOTS_TRANSPORT_TARGET_LABEL_MAX_BYTES,
- RADROOTS_TRANSPORT_TARGET_MAX_COUNT, RADROOTS_TRANSPORT_TARGET_SCOPE_MAX_BYTES,
- RadrootsTransport, RadrootsTransportCapabilities, RadrootsTransportCapabilityAvailability,
- RadrootsTransportCapabilityMaturity, RadrootsTransportDeliveryReceipt,
- RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus,
- RadrootsTransportError, RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest,
- RadrootsTransportFuture, RadrootsTransportImplementationState, RadrootsTransportKind,
- RadrootsTransportMeshScopeId, RadrootsTransportOutcome, RadrootsTransportOutcomeKind,
- RadrootsTransportPayload, RadrootsTransportSatisfactionClass,
+ RADROOTS_TRANSPORT_TARGET_MAX_COUNT,
+ RADROOTS_TRANSPORT_TARGET_SCOPE_MAX_BYTES, RadrootsTransport, RadrootsTransportCapabilities,
+ RadrootsTransportCapabilityAvailability, RadrootsTransportCapabilityMaturity,
+ RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest,
+ RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, RadrootsTransportFetchReceipt,
+ RadrootsTransportFetchRequest, RadrootsTransportFuture, RadrootsTransportImplementationState,
+ RadrootsTransportKind, RadrootsTransportMeshScopeId, RadrootsTransportOutcome,
+ RadrootsTransportOutcomeKind, RadrootsTransportPayload, RadrootsTransportSatisfactionClass,
RadrootsTransportSatisfactionPolicy, RadrootsTransportSatisfactionPolicyKind,
RadrootsTransportStatus, RadrootsTransportTarget, RadrootsTransportTargetFingerprint,
RadrootsTransportTargetLabel, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet,
@@ -20,6 +20,8 @@ use radroots_transport::{
ReticulumDuplicateFragmentBehaviorV1, ReticulumFragmentIntegrityV1,
ReticulumFragmentationModeV1, ReticulumGatewaySemanticsV1, ReticulumPrivacySemanticsV1,
};
+#[cfg(feature = "serde")]
+use radroots_transport::RADROOTS_TRANSPORT_TARGET_FINGERPRINT_BYTES;
use serde_json::Value;
use std::borrow::ToOwned;
use std::boxed::Box;
@@ -2010,10 +2012,106 @@ fn transport_target_set_deserialization_revalidates_nonempty_unique_targets() {
);
assert!(
serde_json::from_value::<RadrootsTransportTargetSet>(
- serde_json::json!({ "targets": [target, target] })
+ serde_json::json!({ "targets": [target.clone(), target.clone()] })
)
.is_err()
);
+ let one_over = vec![target; RADROOTS_TRANSPORT_TARGET_MAX_COUNT + 1];
+ assert!(
+ serde_json::from_value::<RadrootsTransportTargetSet>(
+ serde_json::json!({ "targets": one_over })
+ )
+ .expect_err("reject one-over target set before duplicate validation")
+ .to_string()
+ .contains("target_count")
+ );
+}
+
+#[test]
+#[cfg(feature = "serde")]
+fn transport_bounds_target_wire_strings_fail_before_canonical_validation() {
+ let target = RadrootsTransportTarget::nostr_relay_with_metadata(
+ "wss://relay.example",
+ Some(RadrootsTransportMeshScopeId::parse("scope").expect("scope")),
+ Some(RadrootsTransportTargetLabel::parse("label").expect("label")),
+ )
+ .expect("target");
+ let canonical = serde_json::to_value(&target).expect("target wire");
+ for (field, value, expected_limit) in [
+ (
+ "uri",
+ "u".repeat(RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES + 1),
+ "target_uri",
+ ),
+ (
+ "scope",
+ "s".repeat(RADROOTS_TRANSPORT_TARGET_SCOPE_MAX_BYTES + 1),
+ "target_scope",
+ ),
+ (
+ "label",
+ "l".repeat(RADROOTS_TRANSPORT_TARGET_LABEL_MAX_BYTES + 1),
+ "target_label",
+ ),
+ (
+ "fingerprint",
+ "f".repeat(RADROOTS_TRANSPORT_TARGET_FINGERPRINT_BYTES + 1),
+ "target_fingerprint",
+ ),
+ ] {
+ let mut one_over = canonical.clone();
+ one_over[field] = Value::String(value);
+ let encoded = serde_json::to_string(&one_over).expect("one-over target JSON");
+ assert!(
+ serde_json::from_str::<RadrootsTransportTarget>(&encoded)
+ .expect_err("reject one-over target wire")
+ .to_string()
+ .contains(expected_limit),
+ "{field}"
+ );
+ }
+
+ for (encoded, expected_limit) in [
+ (
+ serde_json::to_string(&"u".repeat(RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES + 1))
+ .expect("URI JSON"),
+ "target_uri",
+ ),
+ (
+ serde_json::to_string(&"s".repeat(RADROOTS_TRANSPORT_TARGET_SCOPE_MAX_BYTES + 1))
+ .expect("scope JSON"),
+ "target_scope",
+ ),
+ (
+ serde_json::to_string(&"l".repeat(RADROOTS_TRANSPORT_TARGET_LABEL_MAX_BYTES + 1))
+ .expect("label JSON"),
+ "target_label",
+ ),
+ (
+ serde_json::to_string(&"f".repeat(RADROOTS_TRANSPORT_TARGET_FINGERPRINT_BYTES + 1))
+ .expect("fingerprint JSON"),
+ "target_fingerprint",
+ ),
+ ] {
+ let error = match expected_limit {
+ "target_uri" => serde_json::from_str::<RadrootsTransportTargetUri>(&encoded)
+ .expect_err("reject URI")
+ .to_string(),
+ "target_scope" => serde_json::from_str::<RadrootsTransportMeshScopeId>(&encoded)
+ .expect_err("reject scope")
+ .to_string(),
+ "target_label" => serde_json::from_str::<RadrootsTransportTargetLabel>(&encoded)
+ .expect_err("reject label")
+ .to_string(),
+ "target_fingerprint" => {
+ serde_json::from_str::<RadrootsTransportTargetFingerprint>(&encoded)
+ .expect_err("reject fingerprint")
+ .to_string()
+ }
+ _ => unreachable!("closed target wire field"),
+ };
+ assert!(error.contains(expected_limit));
+ }
}
#[test]