lib

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

commit a891f09aedb6f219ed544184edcbf3e896e9e5dd
parent 06aac3d5f9b573c8923ee094eb6efe2e3ad52a11
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Jul 2026 07:14:13 +0000

transport: seal bounded payload state

- hide payload variant fields behind validated constructors and accessors
- preserve the established serialized payload representation
- reject oversized strings and byte sequences through bounded visitors
- migrate runtime and Nostr consumers to the sealed payload API

Diffstat:
Mcrates/runtime/src/transport.rs | 16++++++----------
Mcrates/transport/src/lib.rs | 2++
Mcrates/transport/src/payload.rs | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Acrates/transport/src/serde_bounds.rs | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/transport/tests/transport.rs | 116++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mcrates/transport_nostr/src/publish.rs | 9+++------
Mcrates/transport_nostr/tests/phase1_outbox_publication.rs | 6+++---
Mcrates/transport_nostr/tests/transport.rs | 15+++++----------
8 files changed, 404 insertions(+), 94 deletions(-)

diff --git a/crates/runtime/src/transport.rs b/crates/runtime/src/transport.rs @@ -686,10 +686,9 @@ mod tests { RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest, RadrootsTransportFuture, RadrootsTransportImplementationState, RadrootsTransportKind, - RadrootsTransportOutcome, RadrootsTransportOutcomeKind, RadrootsTransportPayload, - RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, - RadrootsTransportStatus, RadrootsTransportTarget, RadrootsTransportTargetReceipt, - RadrootsTransportTargetSet, + RadrootsTransportOutcome, RadrootsTransportOutcomeKind, RadrootsTransportSatisfactionClass, + RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus, RadrootsTransportTarget, + RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, }; #[cfg(feature = "transport-workers")] use std::sync::{Arc, Mutex}; @@ -912,12 +911,9 @@ mod tests { let via_variant = RadrootsRuntimeTransportPayload::signed_event(event.clone()) .transport_payload() .expect("transport payload"); - let RadrootsTransportPayload::SignedEventJson { - event_id, raw_json, .. - } = payload.clone() - else { - panic!("signed event payload expected"); - }; + let (event_id, raw_json) = payload + .signed_event_json_parts() + .expect("signed event payload"); assert_eq!(payload, via_variant); assert_eq!(event_id, event.signed_event().id_str()); diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs @@ -11,6 +11,8 @@ mod limits; mod message; mod payload; mod reticulum; +#[cfg(feature = "serde")] +mod serde_bounds; mod status; mod target; mod transport; diff --git a/crates/transport/src/payload.rs b/crates/transport/src/payload.rs @@ -7,9 +7,14 @@ use alloc::string::{String, ToString}; use alloc::vec::Vec; use sha2::{Digest, Sha256}; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsTransportPayload { + body: RadrootsTransportPayloadBody, +} + #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[derive(Clone, Debug, PartialEq, Eq)] -pub enum RadrootsTransportPayload { +enum RadrootsTransportPayloadBody { SignedEventJson { event_id: String, raw_json: String, @@ -39,10 +44,12 @@ impl RadrootsTransportPayload { let event_id = validate_hex_id(event_id)?; let raw_json = validate_raw_json(raw_json)?; let digest = sha256_hex(raw_json.as_bytes()); - Ok(Self::SignedEventJson { - event_id, - raw_json, - digest, + Ok(Self { + body: RadrootsTransportPayloadBody::SignedEventJson { + event_id, + raw_json, + digest, + }, }) } @@ -82,10 +89,12 @@ impl RadrootsTransportPayload { RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES, )?; let digest = sha256_hex(bytes.as_slice()); - Ok(Self::MeshFrameCbor { - message_id, - bytes, - digest, + Ok(Self { + body: RadrootsTransportPayloadBody::MeshFrameCbor { + message_id, + bytes, + digest, + }, }) } @@ -126,10 +135,12 @@ impl RadrootsTransportPayload { RADROOTS_TRANSPORT_OPAQUE_PAYLOAD_MAX_BYTES, )?; let digest = sha256_hex(bytes.as_slice()); - Ok(Self::OpaqueBytes { - label, - bytes, - digest, + Ok(Self { + body: RadrootsTransportPayloadBody::OpaqueBytes { + label, + bytes, + digest, + }, }) } @@ -152,34 +163,62 @@ impl RadrootsTransportPayload { } pub fn digest(&self) -> &str { - match self { - Self::SignedEventJson { digest, .. } - | Self::MeshFrameCbor { digest, .. } - | Self::OpaqueBytes { digest, .. } => digest.as_str(), + match &self.body { + RadrootsTransportPayloadBody::SignedEventJson { digest, .. } + | RadrootsTransportPayloadBody::MeshFrameCbor { digest, .. } + | RadrootsTransportPayloadBody::OpaqueBytes { digest, .. } => digest.as_str(), } } pub fn payload_kind(&self) -> &'static str { - match self { - Self::SignedEventJson { .. } => "signed_event_json", - Self::MeshFrameCbor { .. } => "mesh_frame_cbor", - Self::OpaqueBytes { .. } => "opaque_bytes", + match &self.body { + RadrootsTransportPayloadBody::SignedEventJson { .. } => "signed_event_json", + RadrootsTransportPayloadBody::MeshFrameCbor { .. } => "mesh_frame_cbor", + RadrootsTransportPayloadBody::OpaqueBytes { .. } => "opaque_bytes", + } + } + + pub fn signed_event_json_parts(&self) -> Option<(&str, &str)> { + match &self.body { + RadrootsTransportPayloadBody::SignedEventJson { + event_id, raw_json, .. + } => Some((event_id, raw_json)), + RadrootsTransportPayloadBody::MeshFrameCbor { .. } + | RadrootsTransportPayloadBody::OpaqueBytes { .. } => None, + } + } + + pub fn mesh_frame_cbor_parts(&self) -> Option<(&str, &[u8])> { + match &self.body { + RadrootsTransportPayloadBody::MeshFrameCbor { + message_id, bytes, .. + } => Some((message_id, bytes)), + RadrootsTransportPayloadBody::SignedEventJson { .. } + | RadrootsTransportPayloadBody::OpaqueBytes { .. } => None, + } + } + + pub fn opaque_bytes_parts(&self) -> Option<(&str, &[u8])> { + match &self.body { + RadrootsTransportPayloadBody::OpaqueBytes { label, bytes, .. } => Some((label, bytes)), + RadrootsTransportPayloadBody::SignedEventJson { .. } + | RadrootsTransportPayloadBody::MeshFrameCbor { .. } => None, } } pub fn validate(&self) -> Result<(), RadrootsTransportError> { - let canonical = match self { - Self::SignedEventJson { + let canonical = match &self.body { + RadrootsTransportPayloadBody::SignedEventJson { event_id, raw_json, digest, } => Self::signed_event_json_with_digest(event_id, raw_json, digest)?, - Self::MeshFrameCbor { + RadrootsTransportPayloadBody::MeshFrameCbor { message_id, bytes, digest, } => Self::validated_mesh_frame_cbor_with_digest(message_id, bytes, digest)?, - Self::OpaqueBytes { + RadrootsTransportPayloadBody::OpaqueBytes { label, bytes, digest, @@ -193,21 +232,41 @@ impl RadrootsTransportPayload { } #[cfg(feature = "serde")] +impl serde::Serialize for RadrootsTransportPayload { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + self.body.serialize(serializer) + } +} + +#[cfg(feature = "serde")] #[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] enum RadrootsTransportPayloadWire { SignedEventJson { + #[serde(deserialize_with = "deserialize_payload_event_id")] event_id: String, + #[serde(deserialize_with = "deserialize_signed_event_json")] raw_json: String, + #[serde(deserialize_with = "deserialize_payload_digest")] digest: String, }, MeshFrameCbor { + #[serde(deserialize_with = "deserialize_payload_identifier")] message_id: String, + #[serde(deserialize_with = "deserialize_reticulum_payload_bytes")] bytes: Vec<u8>, + #[serde(deserialize_with = "deserialize_payload_digest")] digest: String, }, OpaqueBytes { + #[serde(deserialize_with = "deserialize_payload_identifier")] label: String, + #[serde(deserialize_with = "deserialize_opaque_payload_bytes")] bytes: Vec<u8>, + #[serde(deserialize_with = "deserialize_payload_digest")] digest: String, }, } @@ -219,40 +278,91 @@ impl<'de> serde::Deserialize<'de> for RadrootsTransportPayload { D: serde::Deserializer<'de>, { let wire = RadrootsTransportPayloadWire::deserialize(deserializer)?; - let payload = match wire { + match wire { RadrootsTransportPayloadWire::SignedEventJson { event_id, raw_json, digest, - } => Self::SignedEventJson { - event_id, - raw_json, - digest, - }, + } => Self::signed_event_json_with_digest(&event_id, &raw_json, &digest), RadrootsTransportPayloadWire::MeshFrameCbor { message_id, bytes, digest, - } => Self::MeshFrameCbor { - message_id, - bytes, - digest, - }, + } => Self::validated_mesh_frame_cbor_with_digest(&message_id, &bytes, &digest), RadrootsTransportPayloadWire::OpaqueBytes { label, bytes, digest, - } => Self::OpaqueBytes { - label, - bytes, - digest, - }, - }; - payload.validate().map_err(serde::de::Error::custom)?; - Ok(payload) + } => Self::validated_opaque_bytes_with_digest(&label, &bytes, &digest), + } + .map_err(serde::de::Error::custom) } } +#[cfg(feature = "serde")] +fn deserialize_payload_event_id<'de, D>(deserializer: D) -> Result<String, D::Error> +where + D: serde::Deserializer<'de>, +{ + crate::serde_bounds::deserialize_string(deserializer, "payload_id", 64) +} + +#[cfg(feature = "serde")] +fn deserialize_payload_identifier<'de, D>(deserializer: D) -> Result<String, D::Error> +where + D: serde::Deserializer<'de>, +{ + crate::serde_bounds::deserialize_string( + deserializer, + "payload_id", + RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES, + ) +} + +#[cfg(feature = "serde")] +fn deserialize_signed_event_json<'de, D>(deserializer: D) -> Result<String, D::Error> +where + D: serde::Deserializer<'de>, +{ + crate::serde_bounds::deserialize_string( + deserializer, + "signed_event_json_bytes", + RADROOTS_TRANSPORT_SIGNED_EVENT_JSON_MAX_BYTES, + ) +} + +#[cfg(feature = "serde")] +fn deserialize_payload_digest<'de, D>(deserializer: D) -> Result<String, D::Error> +where + D: serde::Deserializer<'de>, +{ + crate::serde_bounds::deserialize_string(deserializer, "digest", 64) +} + +#[cfg(feature = "serde")] +fn deserialize_reticulum_payload_bytes<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error> +where + D: serde::Deserializer<'de>, +{ + crate::serde_bounds::deserialize_vec( + deserializer, + "mesh_frame_cbor_bytes", + RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES, + ) +} + +#[cfg(feature = "serde")] +fn deserialize_opaque_payload_bytes<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error> +where + D: serde::Deserializer<'de>, +{ + crate::serde_bounds::deserialize_vec( + deserializer, + "opaque_payload_bytes", + RADROOTS_TRANSPORT_OPAQUE_PAYLOAD_MAX_BYTES, + ) +} + fn validate_hex_id(raw: &str) -> Result<String, RadrootsTransportError> { if raw.len() != 64 || !raw.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err(RadrootsTransportError::InvalidPayloadId); diff --git a/crates/transport/src/serde_bounds.rs b/crates/transport/src/serde_bounds.rs @@ -0,0 +1,136 @@ +use crate::RadrootsTransportError; +use alloc::string::String; +use alloc::vec::Vec; +use core::fmt; +use core::marker::PhantomData; +use serde::de::{Error as _, SeqAccess, Visitor}; + +pub(crate) fn deserialize_string<'de, D>( + deserializer: D, + field: &'static str, + max: usize, +) -> Result<String, D::Error> +where + D: serde::Deserializer<'de>, +{ + deserializer.deserialize_string(BoundedStringVisitor { field, max }) +} + +pub(crate) fn deserialize_vec<'de, D, T>( + deserializer: D, + field: &'static str, + max: usize, +) -> Result<Vec<T>, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, +{ + deserializer.deserialize_seq(BoundedVecVisitor { + field, + max, + marker: PhantomData, + }) +} + +struct BoundedStringVisitor { + field: &'static str, + max: usize, +} + +impl BoundedStringVisitor { + fn validate<E>(self, value: &str) -> Result<String, E> + where + E: serde::de::Error, + { + if value.len() > self.max { + return Err(E::custom(RadrootsTransportError::ResourceLimitExceeded { + field: self.field, + max: self.max, + actual: value.len(), + })); + } + Ok(value.into()) + } +} + +impl Visitor<'_> for BoundedStringVisitor { + type Value = String; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "a string of at most {} UTF-8 bytes", self.max) + } + + fn visit_borrowed_str<E>(self, value: &str) -> Result<Self::Value, E> + where + E: serde::de::Error, + { + self.validate(value) + } + + fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> + where + E: serde::de::Error, + { + self.validate(value) + } + + fn visit_string<E>(self, value: String) -> Result<Self::Value, E> + where + E: serde::de::Error, + { + if value.len() > self.max { + return Err(E::custom(RadrootsTransportError::ResourceLimitExceeded { + field: self.field, + max: self.max, + actual: value.len(), + })); + } + Ok(value) + } +} + +struct BoundedVecVisitor<T> { + field: &'static str, + max: usize, + marker: PhantomData<T>, +} + +impl<'de, T> Visitor<'de> for BoundedVecVisitor<T> +where + T: serde::Deserialize<'de>, +{ + type Value = Vec<T>; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "a sequence of at most {} items", self.max) + } + + fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error> + where + A: SeqAccess<'de>, + { + if let Some(actual) = sequence.size_hint().filter(|actual| *actual > self.max) { + return Err(A::Error::custom( + RadrootsTransportError::ResourceLimitExceeded { + field: self.field, + max: self.max, + actual, + }, + )); + } + let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(self.max)); + while let Some(value) = sequence.next_element()? { + if values.len() == self.max { + return Err(A::Error::custom( + RadrootsTransportError::ResourceLimitExceeded { + field: self.field, + max: self.max, + actual: self.max.saturating_add(1), + }, + )); + } + values.push(value); + } + Ok(values) + } +} diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs @@ -1458,11 +1458,7 @@ fn delivery_requests_and_receipts_reject_forged_identity_and_cardinality() { .expect_err("empty request id"), RadrootsTransportError::EmptyDeliveryRequestId ); - for request_id in [ - " request".to_owned(), - "request\n".to_owned(), - "x".repeat(RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES + 1), - ] { + for request_id in [" request".to_owned(), "request\n".to_owned()] { assert_eq!( RadrootsTransportDeliveryRequest::new( request_id, @@ -1491,22 +1487,6 @@ fn delivery_requests_and_receipts_reject_forged_identity_and_cardinality() { RadrootsTransportError::RequiredTargetNotRequested ); - let forged_payload = RadrootsTransportPayload::OpaqueBytes { - label: "opaque".to_owned(), - bytes: vec![1], - digest: "0".repeat(64), - }; - assert_eq!( - RadrootsTransportDeliveryRequest::new( - "request", - forged_payload, - targets.clone(), - RadrootsTransportSatisfactionPolicy::all_accepted(), - ) - .expect_err("forged payload"), - RadrootsTransportError::PayloadDigestMismatch - ); - let request = RadrootsTransportDeliveryRequest::new( "request", payload, @@ -2298,6 +2278,100 @@ fn transport_bounds_payloads_enforce_exact_and_one_over_before_copying() { ); } +#[cfg(feature = "serde")] +#[test] +fn transport_bounds_payload_wire_is_strict_and_bounded_for_every_variant() { + let exact_signed_json = format!( + "{{{}}}", + "a".repeat(RADROOTS_TRANSPORT_SIGNED_EVENT_JSON_MAX_BYTES - 2) + ); + let exact_signed = + RadrootsTransportPayload::unchecked_signed_event_json("a".repeat(64), &exact_signed_json) + .expect("exact signed payload"); + assert_eq!( + serde_json::from_value::<RadrootsTransportPayload>( + serde_json::to_value(&exact_signed).expect("serialize signed payload") + ) + .expect("decode exact signed payload"), + exact_signed + ); + + let one_over_signed_json = format!( + "{{{}}}", + "a".repeat(RADROOTS_TRANSPORT_SIGNED_EVENT_JSON_MAX_BYTES - 1) + ); + let one_over_signed_wire = serde_json::json!({ + "SignedEventJson": { + "event_id": "a".repeat(64), + "raw_json": one_over_signed_json, + "digest": "0".repeat(64), + } + }); + assert!( + serde_json::from_value::<RadrootsTransportPayload>(one_over_signed_wire) + .expect_err("reject one-over signed wire") + .to_string() + .contains("signed_event_json_bytes") + ); + + for exact in [ + RadrootsTransportPayload::mesh_frame_cbor( + "mesh", + vec![0; RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES], + ) + .expect("exact mesh payload"), + RadrootsTransportPayload::opaque_bytes( + "opaque", + vec![0; RADROOTS_TRANSPORT_OPAQUE_PAYLOAD_MAX_BYTES], + ) + .expect("exact opaque payload"), + ] { + assert_eq!( + serde_json::from_value::<RadrootsTransportPayload>( + serde_json::to_value(&exact).expect("serialize byte payload") + ) + .expect("decode exact byte payload"), + exact + ); + } + + for (variant, field) in [ + ("MeshFrameCbor", "mesh_frame_cbor_bytes"), + ("OpaqueBytes", "opaque_payload_bytes"), + ] { + let id_field = if variant == "MeshFrameCbor" { + "message_id" + } else { + "label" + }; + let mut body = serde_json::Map::new(); + body.insert(id_field.to_owned(), Value::String("bounded".to_owned())); + body.insert( + "bytes".to_owned(), + Value::Array( + core::iter::repeat_n( + Value::from(0), + RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES + 1, + ) + .collect(), + ), + ); + body.insert("digest".to_owned(), Value::String("0".repeat(64))); + let mut wire = serde_json::Map::new(); + wire.insert(variant.to_owned(), Value::Object(body)); + assert!( + serde_json::from_value::<RadrootsTransportPayload>(Value::Object(wire)) + .expect_err("reject one-over byte wire") + .to_string() + .contains(field) + ); + } + + let mut unknown = serde_json::to_value(opaque_payload()).expect("payload wire"); + unknown["OpaqueBytes"]["unknown"] = Value::Bool(true); + assert!(serde_json::from_value::<RadrootsTransportPayload>(unknown).is_err()); +} + #[test] fn every_transport_error_has_a_stable_display_message() { let remaining = [ diff --git a/crates/transport_nostr/src/publish.rs b/crates/transport_nostr/src/publish.rs @@ -570,18 +570,15 @@ mod contract_tests { fn signed_event_from_transport_payload( payload: &RadrootsTransportPayload, ) -> Result<RadrootsVerifiedSignedEvent, RadrootsTransportError> { - let RadrootsTransportPayload::SignedEventJson { - event_id, raw_json, .. - } = payload - else { + let Some((event_id, raw_json)) = payload.signed_event_json_parts() else { return Err(RadrootsTransportError::InvalidPayloadBytes); }; let wire = RadrootsNip01EventWire::parse_json(raw_json) .map_err(|_| RadrootsTransportError::InvalidPayloadBytes)?; - if wire.id != *event_id { + if wire.id != event_id { return Err(RadrootsTransportError::InvalidPayloadId); } - RadrootsSignedEvent::from_wire_verified_id(wire, raw_json.clone()) + RadrootsSignedEvent::from_wire_verified_id(wire, raw_json.to_owned()) .map_err(|_| RadrootsTransportError::InvalidPayloadBytes)? .verify_signature() .map_err(|_| RadrootsTransportError::InvalidPayloadSignature) diff --git a/crates/transport_nostr/tests/phase1_outbox_publication.rs b/crates/transport_nostr/tests/phase1_outbox_publication.rs @@ -245,9 +245,9 @@ async fn outbox_publication_all_seven_leaves_reuse_exact_bytes_and_dispatch_iden } fn assert_payload_exact(payload: &RadrootsTransportPayload, expected_raw: &str) { - let RadrootsTransportPayload::SignedEventJson { raw_json, .. } = payload else { - panic!("Phase 1 Nostr dispatch must carry signed event JSON"); - }; + let (_, raw_json) = payload + .signed_event_json_parts() + .expect("Phase 1 Nostr dispatch must carry signed event JSON"); assert_eq!(raw_json.as_bytes(), expected_raw.as_bytes()); } diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs @@ -1218,18 +1218,13 @@ fn verified_signed_event_payload_preserves_transport_payload_identity() { let signed = signed_post("verified payload"); let payload = verified_signed_event_payload(&verified_signed_event(signed.clone())) .expect("verified payload"); - let RadrootsTransportPayload::SignedEventJson { - event_id, - raw_json, - digest, - } = payload - else { - panic!("signed event payload expected"); - }; + let (event_id, raw_json) = payload + .signed_event_json_parts() + .expect("signed event payload"); assert_eq!(event_id, signed.id_str()); - assert_eq!(raw_json, signed.raw_json().to_owned()); - assert_eq!(digest.len(), 64); + assert_eq!(raw_json, signed.raw_json()); + assert_eq!(payload.digest().len(), 64); } #[tokio::test]