lib

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

commit 33456558a86995abb9775bed8ef639e8b6659c69
parent 0932ec0b0931e87f61beee24225e6b6733d56efe
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Jul 2026 07:49:03 +0000

transport: seal generic fetch contracts

- validate and hide generic fetch request and receipt fields
- bind receipts to canonical request targets with deterministic ordering
- cap admitted events target receipts and aggregate diagnostics
- migrate transport consumers and cover strict exact-limit wire behavior

Diffstat:
Mcrates/runtime/src/transport.rs | 50++++++++++++++++++++++++++++----------------------
Mcrates/transport/src/delivery.rs | 2+-
Mcrates/transport/src/error.rs | 24++++++++++++++++++++++++
Mcrates/transport/src/lib.rs | 4++--
Mcrates/transport/src/transport.rs | 252++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/transport/tests/source_boundary.rs | 44++++++++++++++++++++++++++++++++++++++++++++
Mcrates/transport/tests/transport.rs | 351++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/transport_nostr/src/outbox.rs | 9++++++++-
Mcrates/transport_nostr/tests/transport.rs | 8++++----
Mcrates/transport_reticulum/src/lib.rs | 13+++++--------
Mcrates/transport_reticulum/tests/reticulum.rs | 6+++---
11 files changed, 686 insertions(+), 77 deletions(-)

diff --git a/crates/runtime/src/transport.rs b/crates/runtime/src/transport.rs @@ -780,10 +780,10 @@ mod tests { request: RadrootsTransportFetchRequest, ) -> RadrootsTransportFuture<'a, RadrootsTransportFetchReceipt> { Box::pin(async move { - Ok(RadrootsTransportFetchReceipt::new( - request.request_id, + RadrootsTransportFetchReceipt::for_request( + &request, request - .target_set + .target_set() .targets() .iter() .cloned() @@ -795,7 +795,7 @@ mod tests { }) .collect(), 0, - )) + ) }) } } @@ -988,17 +988,20 @@ mod tests { RadrootsTransportCapabilities::deliver_and_fetch() ); let fetch = transport - .fetch(RadrootsTransportFetchRequest::new( - "nostr-fetch", - RadrootsTransportTargetSet::new(vec![target( - RadrootsTransportKind::Nostr, - "wss://relay.example", - )]) - .expect("target set"), - )) + .fetch( + RadrootsTransportFetchRequest::new( + "nostr-fetch", + RadrootsTransportTargetSet::new(vec![target( + RadrootsTransportKind::Nostr, + "wss://relay.example", + )]) + .expect("target set"), + ) + .expect("fetch request"), + ) .await .expect("fetch"); - assert_eq!(fetch.fetched_count, 0); + assert_eq!(fetch.fetched_count(), 0); assert_eq!( receipt.satisfied_target_count(RadrootsTransportSatisfactionClass::Accepted), @@ -1070,17 +1073,20 @@ mod tests { assert!(!status.capabilities.deliver); assert!(!status.capabilities.fetch); let fetch = transport - .fetch(RadrootsTransportFetchRequest::new( - "reticulum-fetch", - RadrootsTransportTargetSet::new(vec![target( - RadrootsTransportKind::Reticulum, - "reticulum:local", - )]) - .expect("target set"), - )) + .fetch( + RadrootsTransportFetchRequest::new( + "reticulum-fetch", + RadrootsTransportTargetSet::new(vec![target( + RadrootsTransportKind::Reticulum, + "reticulum:local", + )]) + .expect("target set"), + ) + .expect("fetch request"), + ) .await .expect("fetch"); - assert_eq!(fetch.fetched_count, 0); + assert_eq!(fetch.fetched_count(), 0); assert_eq!( receipt.satisfied_target_count(RadrootsTransportSatisfactionClass::Accepted), diff --git a/crates/transport/src/delivery.rs b/crates/transport/src/delivery.rs @@ -538,7 +538,7 @@ impl RadrootsTransportTargetReceipt { &self.outcome } - fn validate(&self) -> Result<(), RadrootsTransportError> { + pub(crate) fn validate(&self) -> Result<(), RadrootsTransportError> { self.outcome.validate()?; if self.status != self.outcome.status() { return Err(RadrootsTransportError::DeliveryTargetReceiptStatusMismatch); diff --git a/crates/transport/src/error.rs b/crates/transport/src/error.rs @@ -29,6 +29,13 @@ pub enum RadrootsTransportError { TransportOutcomeStatusMismatch, DeliveryReceiptRequestIdMismatch, DeliveryReceiptTargetSetMismatch, + EmptyFetchRequestId, + InvalidFetchRequestId, + UnexpectedFetchTargetReceipt, + DuplicateFetchTargetReceipt, + MissingFetchTargetReceipt, + FetchReceiptRequestIdMismatch, + FetchReceiptTargetSetMismatch, EmptyPayloadId, InvalidPayloadId, EmptyPayloadLabel, @@ -105,6 +112,23 @@ impl fmt::Display for RadrootsTransportError { Self::DeliveryReceiptTargetSetMismatch => { f.write_str("transport delivery receipt target set does not match its request") } + Self::EmptyFetchRequestId => f.write_str("transport fetch request id is empty"), + Self::InvalidFetchRequestId => f.write_str("transport fetch request id is invalid"), + Self::UnexpectedFetchTargetReceipt => { + f.write_str("transport fetch receipt contains an unexpected target") + } + Self::DuplicateFetchTargetReceipt => { + f.write_str("transport fetch receipt contains a duplicate target") + } + Self::MissingFetchTargetReceipt => { + f.write_str("transport fetch receipt is missing a requested target") + } + Self::FetchReceiptRequestIdMismatch => { + f.write_str("transport fetch receipt request id does not match its request") + } + Self::FetchReceiptTargetSetMismatch => { + f.write_str("transport fetch receipt target set does not match its request") + } Self::EmptyPayloadId => f.write_str("transport payload id is empty"), Self::InvalidPayloadId => f.write_str("transport payload id is invalid"), Self::EmptyPayloadLabel => f.write_str("transport payload label is empty"), diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs @@ -60,8 +60,8 @@ pub use target::{ RadrootsTransportTargetSet, RadrootsTransportTargetUri, }; pub use transport::{ - RadrootsTransport, RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest, - RadrootsTransportFuture, + RADROOTS_TRANSPORT_FETCH_REQUEST_ID_MAX_BYTES, RadrootsTransport, + RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest, RadrootsTransportFuture, }; #[cfg(test)] diff --git a/crates/transport/src/transport.rs b/crates/transport/src/transport.rs @@ -1,9 +1,12 @@ use crate::{ + RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES, RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT, + RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES, RADROOTS_TRANSPORT_TARGET_MAX_COUNT, RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest, RadrootsTransportError, RadrootsTransportKind, RadrootsTransportStatus, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, }; use alloc::boxed::Box; +use alloc::collections::{BTreeMap, BTreeSet}; use alloc::string::String; use alloc::vec::Vec; use core::future::Future; @@ -12,6 +15,9 @@ use core::pin::Pin; pub type RadrootsTransportFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, RadrootsTransportError>> + Send + 'a>>; +pub const RADROOTS_TRANSPORT_FETCH_REQUEST_ID_MAX_BYTES: usize = + RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES; + pub trait RadrootsTransport: Send + Sync { fn transport_kind(&self) -> RadrootsTransportKind; @@ -28,40 +34,258 @@ pub trait RadrootsTransport: Send + Sync { ) -> RadrootsTransportFuture<'a, RadrootsTransportFetchReceipt>; } -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTransportFetchRequest { - pub request_id: String, - pub target_set: RadrootsTransportTargetSet, + request_id: String, + target_set: RadrootsTransportTargetSet, } impl RadrootsTransportFetchRequest { - pub fn new(request_id: impl Into<String>, target_set: RadrootsTransportTargetSet) -> Self { - Self { - request_id: request_id.into(), + pub fn new( + request_id: impl Into<String>, + target_set: RadrootsTransportTargetSet, + ) -> Result<Self, RadrootsTransportError> { + let request_id = request_id.into(); + validate_fetch_request_id(request_id.as_str())?; + Ok(Self { + request_id, target_set, - } + }) + } + + pub fn request_id(&self) -> &str { + self.request_id.as_str() + } + + pub fn target_set(&self) -> &RadrootsTransportTargetSet { + &self.target_set + } +} + +fn validate_fetch_request_id(value: &str) -> Result<(), RadrootsTransportError> { + if value.is_empty() { + return Err(RadrootsTransportError::EmptyFetchRequestId); + } + crate::limits::ensure_resource_limit( + "fetch_request_id", + value.len(), + RADROOTS_TRANSPORT_FETCH_REQUEST_ID_MAX_BYTES, + )?; + if value != value.trim() || value.chars().any(char::is_control) { + return Err(RadrootsTransportError::InvalidFetchRequestId); + } + Ok(()) +} + +#[cfg(feature = "serde")] +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct RadrootsTransportFetchRequestWire { + #[serde(deserialize_with = "deserialize_fetch_request_id")] + request_id: String, + target_set: RadrootsTransportTargetSet, +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for RadrootsTransportFetchRequest { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let wire = RadrootsTransportFetchRequestWire::deserialize(deserializer)?; + Self::new(wire.request_id, wire.target_set).map_err(serde::de::Error::custom) } } -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTransportFetchReceipt { - pub request_id: String, - pub target_receipts: Vec<RadrootsTransportTargetReceipt>, - pub fetched_count: usize, + request_id: String, + target_set: RadrootsTransportTargetSet, + target_receipts: Vec<RadrootsTransportTargetReceipt>, + fetched_count: usize, } impl RadrootsTransportFetchReceipt { + pub fn for_request( + request: &RadrootsTransportFetchRequest, + target_receipts: Vec<RadrootsTransportTargetReceipt>, + fetched_count: usize, + ) -> Result<Self, RadrootsTransportError> { + Self::new( + request.request_id(), + request.target_set().clone(), + target_receipts, + fetched_count, + ) + } + pub fn new( request_id: impl Into<String>, + target_set: RadrootsTransportTargetSet, target_receipts: Vec<RadrootsTransportTargetReceipt>, fetched_count: usize, - ) -> Self { - Self { - request_id: request_id.into(), + ) -> Result<Self, RadrootsTransportError> { + let request_id = request_id.into(); + validate_fetch_request_id(request_id.as_str())?; + crate::limits::ensure_resource_limit( + "fetch_admitted_event_count", + fetched_count, + RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT, + )?; + let target_receipts = canonicalize_fetch_target_receipts(&target_set, target_receipts)?; + Ok(Self { + request_id, + target_set, target_receipts, fetched_count, + }) + } + + pub fn request_id(&self) -> &str { + self.request_id.as_str() + } + + pub fn target_set(&self) -> &RadrootsTransportTargetSet { + &self.target_set + } + + pub fn target_receipts(&self) -> &[RadrootsTransportTargetReceipt] { + &self.target_receipts + } + + pub fn fetched_count(&self) -> usize { + self.fetched_count + } + + pub fn validate_for_request( + &self, + request: &RadrootsTransportFetchRequest, + ) -> Result<(), RadrootsTransportError> { + if self.request_id() != request.request_id() { + return Err(RadrootsTransportError::FetchReceiptRequestIdMismatch); + } + if self.target_set() != request.target_set() { + return Err(RadrootsTransportError::FetchReceiptTargetSetMismatch); } + Ok(()) + } +} + +fn canonicalize_fetch_target_receipts( + target_set: &RadrootsTransportTargetSet, + target_receipts: Vec<RadrootsTransportTargetReceipt>, +) -> Result<Vec<RadrootsTransportTargetReceipt>, RadrootsTransportError> { + crate::limits::ensure_resource_limit( + "fetch_target_receipt_count", + target_receipts.len(), + RADROOTS_TRANSPORT_TARGET_MAX_COUNT, + )?; + let mut receipt_fingerprints = BTreeSet::new(); + let mut diagnostic_bytes = 0usize; + for receipt in &target_receipts { + receipt.validate()?; + diagnostic_bytes = diagnostic_bytes + .checked_add(receipt.outcome().message().map_or(0, str::len)) + .ok_or(RadrootsTransportError::ResourceLimitExceeded { + field: "fetch_diagnostic_bytes", + max: RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES, + actual: usize::MAX, + })?; + crate::limits::ensure_resource_limit( + "fetch_diagnostic_bytes", + diagnostic_bytes, + RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES, + )?; + let Some(requested_target) = target_set + .targets() + .iter() + .find(|target| target.fingerprint() == receipt.target().fingerprint()) + else { + return Err(RadrootsTransportError::UnexpectedFetchTargetReceipt); + }; + if requested_target != receipt.target() { + return Err(RadrootsTransportError::UnexpectedFetchTargetReceipt); + } + if !receipt_fingerprints.insert(receipt.target().fingerprint().as_str()) { + return Err(RadrootsTransportError::DuplicateFetchTargetReceipt); + } + } + if receipt_fingerprints.len() != target_set.len() { + return Err(RadrootsTransportError::MissingFetchTargetReceipt); + } + let mut receipts_by_fingerprint = target_receipts + .into_iter() + .map(|receipt| { + ( + String::from(receipt.target().fingerprint().as_str()), + receipt, + ) + }) + .collect::<BTreeMap<_, _>>(); + target_set + .targets() + .iter() + .map(|target| { + receipts_by_fingerprint + .remove(target.fingerprint().as_str()) + .ok_or(RadrootsTransportError::MissingFetchTargetReceipt) + }) + .collect() +} + +#[cfg(feature = "serde")] +fn deserialize_fetch_request_id<'de, D>(deserializer: D) -> Result<String, D::Error> +where + D: serde::Deserializer<'de>, +{ + crate::serde_bounds::deserialize_string( + deserializer, + "fetch_request_id", + RADROOTS_TRANSPORT_FETCH_REQUEST_ID_MAX_BYTES, + ) +} + +#[cfg(feature = "serde")] +fn deserialize_fetch_target_receipts<'de, D>( + deserializer: D, +) -> Result<Vec<RadrootsTransportTargetReceipt>, D::Error> +where + D: serde::Deserializer<'de>, +{ + crate::serde_bounds::deserialize_vec( + deserializer, + "fetch_target_receipt_count", + RADROOTS_TRANSPORT_TARGET_MAX_COUNT, + ) +} + +#[cfg(feature = "serde")] +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct RadrootsTransportFetchReceiptWire { + #[serde(deserialize_with = "deserialize_fetch_request_id")] + request_id: String, + target_set: RadrootsTransportTargetSet, + #[serde(deserialize_with = "deserialize_fetch_target_receipts")] + target_receipts: Vec<RadrootsTransportTargetReceipt>, + fetched_count: usize, +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for RadrootsTransportFetchReceipt { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let wire = RadrootsTransportFetchReceiptWire::deserialize(deserializer)?; + Self::new( + wire.request_id, + wire.target_set, + wire.target_receipts, + wire.fetched_count, + ) + .map_err(serde::de::Error::custom) } } diff --git a/crates/transport/tests/source_boundary.rs b/crates/transport/tests/source_boundary.rs @@ -759,6 +759,50 @@ fn required_target_semantics_stay_fingerprint_exact() { } #[test] +fn core_fetch_contract_keeps_validated_state_sealed_and_request_bound() { + let crates_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("transport crate parent"); + let transport_source = read_source(crates_root.join("transport/src/transport.rs").as_path()); + for (start, end) in [ + ( + "pub struct RadrootsTransportFetchRequest {", + "impl RadrootsTransportFetchRequest {", + ), + ( + "pub struct RadrootsTransportFetchReceipt {", + "impl RadrootsTransportFetchReceipt {", + ), + ] { + let model = source_between(transport_source.as_str(), start, end); + for forbidden in [ + "pub request_id:", + "pub target_set:", + "pub target_receipts:", + "pub fetched_count:", + ] { + assert!( + !model.contains(forbidden), + "generic fetch validated state must remain sealed: `{forbidden}`" + ); + } + } + for required in [ + "impl<'de> serde::Deserialize<'de> for RadrootsTransportFetchRequest", + "impl<'de> serde::Deserialize<'de> for RadrootsTransportFetchReceipt", + "deserialize_fetch_request_id", + "deserialize_fetch_target_receipts", + "canonicalize_fetch_target_receipts", + "validate_for_request", + ] { + assert!( + transport_source.contains(required), + "generic fetch source must retain bounded request binding witness `{required}`" + ); + } +} + +#[test] fn transport_hardening_sources_reject_removed_execution_kind_and_keep_reticulum_contracts() { let crates_root = Path::new(env!("CARGO_MANIFEST_DIR")) .parent() diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs @@ -1,18 +1,21 @@ +#[cfg(feature = "serde")] +use radroots_transport::RADROOTS_TRANSPORT_TARGET_FINGERPRINT_BYTES; use radroots_transport::{ RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_SCOPE_ID, RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES, RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES, - RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES, RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES, + RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES, RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT, + RADROOTS_TRANSPORT_FETCH_REQUEST_ID_MAX_BYTES, RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES, 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,8 +23,6 @@ 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; @@ -503,11 +504,11 @@ fn deferred_transport_outcomes_are_terminal_but_not_satisfied() { #[cfg(feature = "serde")] fn request_models_round_trip_with_serde() { let target = RadrootsTransportTarget::nostr_relay("wss://relay.example").expect("target"); - let target_set = RadrootsTransportTargetSet::new(vec![target]).expect("target set"); + let target_set = RadrootsTransportTargetSet::new(vec![target.clone()]).expect("target set"); let request = RadrootsTransportDeliveryRequest::new( "req-1", opaque_payload(), - target_set, + target_set.clone(), RadrootsTransportSatisfactionPolicy::any_accepted(), ) .expect("request"); @@ -517,6 +518,32 @@ fn request_models_round_trip_with_serde() { serde_json::from_str(&json).expect("decode request"); assert_eq!(decoded, request); + + let fetch_request = + RadrootsTransportFetchRequest::new("fetch-1", target_set).expect("fetch request"); + let fetch_receipt = RadrootsTransportFetchReceipt::for_request( + &fetch_request, + vec![RadrootsTransportTargetReceipt::new( + target, + RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Seen), + )], + 1, + ) + .expect("fetch receipt"); + let fetch_request_json = + serde_json::to_string(&fetch_request).expect("serialize fetch request"); + let fetch_receipt_json = + serde_json::to_string(&fetch_receipt).expect("serialize fetch receipt"); + assert_eq!( + serde_json::from_str::<RadrootsTransportFetchRequest>(&fetch_request_json) + .expect("decode fetch request"), + fetch_request + ); + assert_eq!( + serde_json::from_str::<RadrootsTransportFetchReceipt>(&fetch_receipt_json) + .expect("decode fetch receipt"), + fetch_receipt + ); } #[test] @@ -1288,14 +1315,14 @@ fn neutral_transport_trait_covers_status_delivery_and_fetch() { request: RadrootsTransportFetchRequest, ) -> RadrootsTransportFuture<'a, RadrootsTransportFetchReceipt> { Box::pin(async move { - Ok(RadrootsTransportFetchReceipt::new( - request.request_id, + RadrootsTransportFetchReceipt::for_request( + &request, vec![RadrootsTransportTargetReceipt::new( self.target.clone(), RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Seen), )], 1, - )) + ) }) } } @@ -1326,13 +1353,14 @@ fn neutral_transport_trait_covers_status_delivery_and_fetch() { delivery.target_receipts()[0].outcome().kind(), RadrootsTransportOutcomeKind::Delivered ); - let fetch = futures::executor::block_on( - transport.fetch(RadrootsTransportFetchRequest::new("fetch-1", target_set)), - ) - .expect("fetch"); - assert_eq!(fetch.fetched_count, 1); + let fetch = + futures::executor::block_on(transport.fetch( + RadrootsTransportFetchRequest::new("fetch-1", target_set).expect("fetch request"), + )) + .expect("fetch"); + assert_eq!(fetch.fetched_count(), 1); assert_eq!( - fetch.target_receipts[0].outcome().kind(), + fetch.target_receipts()[0].outcome().kind(), RadrootsTransportOutcomeKind::Seen ); } @@ -2416,6 +2444,278 @@ fn transport_bounds_outcomes_and_receipts_enforce_exact_and_one_over() { } #[test] +fn transport_bounds_fetch_requests_and_receipts_bind_limits_and_targets() { + let targets = (0..RADROOTS_TRANSPORT_TARGET_MAX_COUNT) + .map(|index| { + RadrootsTransportTarget::local(format!("local:fetch-{index}")) + .expect("bounded fetch target") + }) + .collect::<Vec<_>>(); + let target_set = RadrootsTransportTargetSet::new(targets.clone()).expect("target set"); + let request = RadrootsTransportFetchRequest::new( + "r".repeat(RADROOTS_TRANSPORT_FETCH_REQUEST_ID_MAX_BYTES), + target_set.clone(), + ) + .expect("exact fetch request id"); + assert_eq!( + request.request_id().len(), + RADROOTS_TRANSPORT_FETCH_REQUEST_ID_MAX_BYTES + ); + assert_eq!(request.target_set(), &target_set); + for (request_id, expected) in [ + (String::new(), RadrootsTransportError::EmptyFetchRequestId), + ( + " fetch".to_owned(), + RadrootsTransportError::InvalidFetchRequestId, + ), + ( + "r".repeat(RADROOTS_TRANSPORT_FETCH_REQUEST_ID_MAX_BYTES + 1), + RadrootsTransportError::ResourceLimitExceeded { + field: "fetch_request_id", + max: RADROOTS_TRANSPORT_FETCH_REQUEST_ID_MAX_BYTES, + actual: RADROOTS_TRANSPORT_FETCH_REQUEST_ID_MAX_BYTES + 1, + }, + ), + ] { + assert_eq!( + RadrootsTransportFetchRequest::new(request_id, target_set.clone()) + .expect_err("invalid fetch request id"), + expected + ); + } + + let reversed_receipts = targets + .iter() + .rev() + .cloned() + .map(|target| { + RadrootsTransportTargetReceipt::new( + target, + RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Seen), + ) + }) + .collect::<Vec<_>>(); + let receipt = RadrootsTransportFetchReceipt::for_request( + &request, + reversed_receipts, + RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT, + ) + .expect("exact fetch receipt bounds"); + assert_eq!(receipt.request_id(), request.request_id()); + assert_eq!(receipt.target_set(), request.target_set()); + assert_eq!( + receipt.fetched_count(), + RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT + ); + assert!( + receipt + .target_receipts() + .iter() + .zip(targets.iter()) + .all(|(receipt, target)| receipt.target() == target) + ); + receipt + .validate_for_request(&request) + .expect("bound fetch request"); + let wrong_id_receipt = RadrootsTransportFetchReceipt::new( + "other-request", + request.target_set().clone(), + receipt.target_receipts().to_vec(), + 0, + ) + .expect("wrong-id receipt"); + assert_eq!( + wrong_id_receipt + .validate_for_request(&request) + .expect_err("fetch request id mismatch"), + RadrootsTransportError::FetchReceiptRequestIdMismatch + ); + let other_target = RadrootsTransportTarget::local("local:other-fetch").expect("other target"); + let other_set = RadrootsTransportTargetSet::new(vec![other_target.clone()]).expect("other set"); + let wrong_target_receipt = RadrootsTransportFetchReceipt::new( + request.request_id(), + other_set, + vec![RadrootsTransportTargetReceipt::new( + other_target.clone(), + RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Seen), + )], + 0, + ) + .expect("wrong-target receipt"); + assert_eq!( + wrong_target_receipt + .validate_for_request(&request) + .expect_err("fetch target set mismatch"), + RadrootsTransportError::FetchReceiptTargetSetMismatch + ); + + assert_eq!( + RadrootsTransportFetchReceipt::for_request( + &request, + receipt.target_receipts().to_vec(), + RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT + 1, + ) + .expect_err("one-over admitted fetch count"), + RadrootsTransportError::ResourceLimitExceeded { + field: "fetch_admitted_event_count", + max: RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT, + actual: RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT + 1, + } + ); + assert_eq!( + RadrootsTransportFetchReceipt::for_request( + &request, + receipt.target_receipts()[..RADROOTS_TRANSPORT_TARGET_MAX_COUNT - 1].to_vec(), + 0, + ) + .expect_err("missing fetch target receipt"), + RadrootsTransportError::MissingFetchTargetReceipt + ); + let mut duplicate_receipts = receipt.target_receipts().to_vec(); + duplicate_receipts[RADROOTS_TRANSPORT_TARGET_MAX_COUNT - 1] = duplicate_receipts[0].clone(); + assert_eq!( + RadrootsTransportFetchReceipt::for_request(&request, duplicate_receipts, 0) + .expect_err("duplicate fetch target receipt"), + RadrootsTransportError::DuplicateFetchTargetReceipt + ); + + assert_eq!( + RadrootsTransportFetchReceipt::new( + "fetch", + RadrootsTransportTargetSet::new(vec![targets[0].clone()]).expect("one target"), + vec![RadrootsTransportTargetReceipt::new( + other_target, + RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Seen), + )], + 0, + ) + .expect_err("unexpected fetch target receipt"), + RadrootsTransportError::UnexpectedFetchTargetReceipt + ); + + let first = RadrootsTransportTarget::local("local:fetch-diagnostic-one").expect("first"); + let second = RadrootsTransportTarget::local("local:fetch-diagnostic-two").expect("second"); + let diagnostic_set = + RadrootsTransportTargetSet::new(vec![first.clone(), second.clone()]).expect("target set"); + let diagnostic_request = + RadrootsTransportFetchRequest::new("fetch-diagnostics", diagnostic_set) + .expect("diagnostic request"); + let half = RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES / 2; + let diagnostic_receipts = |second_len| { + vec![ + RadrootsTransportTargetReceipt::new( + first.clone(), + RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Seen) + .try_with_message("a".repeat(half)) + .expect("first diagnostic"), + ), + RadrootsTransportTargetReceipt::new( + second.clone(), + RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Seen) + .try_with_message("b".repeat(second_len)) + .expect("second diagnostic"), + ), + ] + }; + RadrootsTransportFetchReceipt::for_request(&diagnostic_request, diagnostic_receipts(half), 0) + .expect("exact fetch diagnostic budget"); + assert_eq!( + RadrootsTransportFetchReceipt::for_request( + &diagnostic_request, + diagnostic_receipts(half + 1), + 0, + ) + .expect_err("one-over fetch diagnostic budget"), + RadrootsTransportError::ResourceLimitExceeded { + field: "fetch_diagnostic_bytes", + max: RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES, + actual: RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES + 1, + } + ); +} + +#[test] +#[cfg(feature = "serde")] +fn transport_bounds_fetch_wire_is_strict_bounded_and_request_bound() { + let targets = (0..RADROOTS_TRANSPORT_TARGET_MAX_COUNT) + .map(|index| { + RadrootsTransportTarget::local(format!("local:fetch-wire-{index}")) + .expect("fetch target") + }) + .collect::<Vec<_>>(); + let request = RadrootsTransportFetchRequest::new( + "r".repeat(RADROOTS_TRANSPORT_FETCH_REQUEST_ID_MAX_BYTES), + RadrootsTransportTargetSet::new(targets.clone()).expect("target set"), + ) + .expect("fetch request"); + let receipt = RadrootsTransportFetchReceipt::for_request( + &request, + targets + .into_iter() + .map(|target| { + RadrootsTransportTargetReceipt::new( + target, + RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Seen), + ) + }) + .collect(), + RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT, + ) + .expect("fetch receipt"); + let request_wire = serde_json::to_value(&request).expect("request wire"); + let receipt_wire = serde_json::to_value(&receipt).expect("receipt wire"); + serde_json::from_value::<RadrootsTransportFetchRequest>(request_wire.clone()) + .expect("exact request wire"); + serde_json::from_value::<RadrootsTransportFetchReceipt>(receipt_wire.clone()) + .expect("exact receipt wire"); + + let mut one_over_request_id = request_wire.clone(); + one_over_request_id["request_id"] = + Value::String("r".repeat(RADROOTS_TRANSPORT_FETCH_REQUEST_ID_MAX_BYTES + 1)); + assert!( + serde_json::from_value::<RadrootsTransportFetchRequest>(one_over_request_id) + .expect_err("one-over fetch request id") + .to_string() + .contains("fetch_request_id") + ); + let mut unknown_request = request_wire; + unknown_request["unknown"] = Value::Bool(true); + assert!(serde_json::from_value::<RadrootsTransportFetchRequest>(unknown_request).is_err()); + + let mut one_over_receipts = receipt_wire.clone(); + let extra = one_over_receipts["target_receipts"][0].clone(); + one_over_receipts["target_receipts"] + .as_array_mut() + .expect("receipt array") + .push(extra); + assert!( + serde_json::from_value::<RadrootsTransportFetchReceipt>(one_over_receipts) + .expect_err("one-over fetch target receipt count") + .to_string() + .contains("fetch_target_receipt_count") + ); + let mut one_over_events = receipt_wire.clone(); + one_over_events["fetched_count"] = + Value::from(RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT + 1); + assert!( + serde_json::from_value::<RadrootsTransportFetchReceipt>(one_over_events) + .expect_err("one-over admitted fetch count") + .to_string() + .contains("fetch_admitted_event_count") + ); + let mut mismatched_target_set = receipt_wire.clone(); + mismatched_target_set["target_set"] = serde_json::json!({ + "targets": [RadrootsTransportTarget::local("local:mismatch").expect("target")], + }); + assert!( + serde_json::from_value::<RadrootsTransportFetchReceipt>(mismatched_target_set).is_err() + ); + let mut unknown_receipt = receipt_wire; + unknown_receipt["unknown"] = Value::Bool(true); + assert!(serde_json::from_value::<RadrootsTransportFetchReceipt>(unknown_receipt).is_err()); +} + +#[test] fn transport_bounds_payloads_enforce_exact_and_one_over_before_copying() { let exact_json = format!( "{{{}}}", @@ -2634,6 +2934,13 @@ fn every_transport_error_has_a_stable_display_message() { RadrootsTransportError::TransportOutcomeStatusMismatch, RadrootsTransportError::DeliveryReceiptRequestIdMismatch, RadrootsTransportError::DeliveryReceiptTargetSetMismatch, + RadrootsTransportError::EmptyFetchRequestId, + RadrootsTransportError::InvalidFetchRequestId, + RadrootsTransportError::UnexpectedFetchTargetReceipt, + RadrootsTransportError::DuplicateFetchTargetReceipt, + RadrootsTransportError::MissingFetchTargetReceipt, + RadrootsTransportError::FetchReceiptRequestIdMismatch, + RadrootsTransportError::FetchReceiptTargetSetMismatch, RadrootsTransportError::EmptyPayloadId, RadrootsTransportError::InvalidPayloadId, RadrootsTransportError::EmptyPayloadLabel, diff --git a/crates/transport_nostr/src/outbox.rs b/crates/transport_nostr/src/outbox.rs @@ -834,6 +834,8 @@ fn transport_error_to_relay_error(error: RadrootsTransportError) -> RadrootsRela | RadrootsTransportError::RequiredTargetNotRequested | RadrootsTransportError::EmptyDeliveryRequestId | RadrootsTransportError::InvalidDeliveryRequestId + | RadrootsTransportError::EmptyFetchRequestId + | RadrootsTransportError::InvalidFetchRequestId | RadrootsTransportError::InvalidPayloadSignature | RadrootsTransportError::InvalidDeliveryTimestamp => { RadrootsRelayTransportError::Transport(error.to_string()) @@ -851,7 +853,12 @@ fn transport_error_to_relay_error(error: RadrootsTransportError) -> RadrootsRela | RadrootsTransportError::DeliveryTargetReceiptAttemptMismatch | RadrootsTransportError::TransportOutcomeStatusMismatch | RadrootsTransportError::DeliveryReceiptRequestIdMismatch - | RadrootsTransportError::DeliveryReceiptTargetSetMismatch => { + | RadrootsTransportError::DeliveryReceiptTargetSetMismatch + | RadrootsTransportError::UnexpectedFetchTargetReceipt + | RadrootsTransportError::DuplicateFetchTargetReceipt + | RadrootsTransportError::MissingFetchTargetReceipt + | RadrootsTransportError::FetchReceiptRequestIdMismatch + | RadrootsTransportError::FetchReceiptTargetSetMismatch => { RadrootsRelayTransportError::TransportContract(error.to_string()) } RadrootsTransportError::EmptyPayloadId diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs @@ -1245,10 +1245,10 @@ async fn nostr_transport_facade_reports_fetch_as_unsupported_operation() { let target_set = RadrootsTransportTargetSet::new(vec![nostr_target(RELAY_PRIMARY_WSS)]).expect("targets"); let error = transport - .fetch(RadrootsTransportFetchRequest::new( - "facade-fetch-unsupported", - target_set, - )) + .fetch( + RadrootsTransportFetchRequest::new("facade-fetch-unsupported", target_set) + .expect("fetch request"), + ) .await .expect_err("fetch unsupported"); diff --git a/crates/transport_reticulum/src/lib.rs b/crates/transport_reticulum/src/lib.rs @@ -330,21 +330,17 @@ impl RadrootsTransport for RadrootsReticulumTransport { request: RadrootsTransportFetchRequest, ) -> RadrootsTransportFuture<'a, RadrootsTransportFetchReceipt> { Box::pin(async move { - ensure_reticulum_targets(request.target_set.targets()) + ensure_reticulum_targets(request.target_set().targets()) .map_err(reticulum_error_to_transport_error)?; let outcome = reticulum_outcome(self.profile.behavior); let target_receipts = request - .target_set + .target_set() .targets() .iter() .cloned() .map(|target| RadrootsTransportTargetReceipt::skipped(target, outcome.clone())) .collect::<Result<Vec<_>, _>>()?; - Ok(RadrootsTransportFetchReceipt::new( - request.request_id, - target_receipts, - 0, - )) + RadrootsTransportFetchReceipt::for_request(&request, target_receipts, 0) }) } } @@ -618,7 +614,8 @@ mod tests { let core_fetch = RadrootsTransportFetchRequest::new( "core-fetch", RadrootsTransportTargetSet::new(vec![reticulum_target()]).expect("target set"), - ); + ) + .expect("fetch request"); assert!(block_on(RadrootsTransport::fetch(&rejecting, core_fetch)).is_ok()); let deferring = RadrootsReticulumTransport::new( diff --git a/crates/transport_reticulum/tests/reticulum.rs b/crates/transport_reticulum/tests/reticulum.rs @@ -376,12 +376,12 @@ fn core_transport_trait_reports_reticulum_status_delivery_and_fetch() { let fetch = futures::executor::block_on(RadrootsTransport::fetch( &transport, - RadrootsTransportFetchRequest::new("core-fetch", target_set), + RadrootsTransportFetchRequest::new("core-fetch", target_set).expect("fetch request"), )) .expect("fetch receipt"); - assert_eq!(fetch.fetched_count, 0); + assert_eq!(fetch.fetched_count(), 0); assert_eq!( - fetch.target_receipts[0].status(), + fetch.target_receipts()[0].status(), RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented ); }