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:
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
);
}