lib

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

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:
Mcrates/transport/src/lib.rs | 5+++--
Mcrates/transport/src/target.rs | 95++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/transport/tests/transport.rs | 116++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
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]